Add searchable and flattened export, history re-export and diagnostics self-test

issueAndArchive keeps an issued invoice even when rendering or archiving
fails (Issued - not archived, with a Render & archive action). The export
menu offers a searchable PDF or a 300 DPI image-only PDF whose metadata is
the invoice number alone; flatten failures offer Retry or searchable export
and never silently lower the resolution. History re-exports the original
as issued by default and warns when a re-render's layout fingerprint
differs. The render client gains a FIFO assemble lane that previews cannot
supersede. A Diagnostics panel runs fixtures in the real worker and
compares against goldens generated from Node.
This commit is contained in:
2026-10-04 06:11:23 +05:30
parent 7162dac6e7
commit f1b7502e9d
30 changed files with 2727 additions and 154 deletions
+219
View File
@@ -0,0 +1,219 @@
import { FlattenCancelled } from "../pdf/flatten";
import type { FlattenResult } from "../pdf/flatten";
import type { AssemblePageData } from "../pdf/render/jobs";
import { exportFileName, type ExportMode } from "./filename";
import type { Invoice, InvoiceInput } from "./types";
export type { ExportMode };
export type ExportSource = "original" | "rerender";
/** Everything the flow touches outside itself, so it can run against fakes. */
export interface ExportDeps {
issueInvoice(input: InvoiceInput, renderPrefs: object): Promise<Invoice>;
/** Renders with the invoice's frozen prefs and supplier snapshot and the current template code. */
render(invoice: Invoice): Promise<{ bytes: Uint8Array; fingerprint: string }>;
archivePdf(invoiceId: number, bytes: Uint8Array, fingerprint: string): Promise<string>;
readArchive(invoiceId: number): Promise<Uint8Array>;
/** Rasterises PDF bytes page by page. Throws FlattenError / FlattenCancelled. */
flatten(bytes: Uint8Array, opts: { signal?: AbortSignal; onProgress?: (page: number, pages: number) => void }): Promise<FlattenResult>;
assemble(pages: AssemblePageData[], title: string): Promise<Uint8Array>;
getLastExportDir(): Promise<string>;
/** The save dialog: the chosen path, or null when it is dismissed. */
save(opts: { defaultPath: string }): Promise<string | null>;
writeExportFile(path: string, bytes: Uint8Array): Promise<string>;
}
export type ExportPhase = "rendering" | "flattening" | "assembling" | "saving";
export interface ExportProgress {
phase: ExportPhase;
/** While flattening: the page being rendered and the page count. */
page?: number;
pages?: number;
}
export type ExportErrorCode = "RENDER_FAILED" | "ARCHIVE_FAILED" | "FLATTEN_FAILED" | "WRITE_FAILED";
export class ExportError extends Error {
constructor(
readonly code: ExportErrorCode,
message: string,
readonly cause?: unknown,
) {
super(message);
this.name = "ExportError";
}
}
const messageOf = (e: unknown) => (e instanceof Error ? e.message : String(e));
export const isFlattenFailure = (e: unknown): boolean => (e as { code?: unknown } | null)?.code === "FLATTEN_FAILED";
export const isExportCancelled = (e: unknown): boolean => e instanceof FlattenCancelled || (e as { name?: string } | null)?.name === "FlattenCancelled";
const checkAbort = (signal?: AbortSignal) => {
if (signal?.aborted) throw new FlattenCancelled();
};
// --- archive at issue ------------------------------------------------------------------------------
export interface ArchiveOutcome {
/** The invoice with its archive fields filled in when archiving worked. */
invoice: Invoice;
archived: boolean;
error?: string;
}
/** Fields the backend sets when it archives, mirrored locally so the UI needs no refetch. */
const withArchive = (invoice: Invoice, sha256: string, fingerprint: string): Invoice => ({
...invoice,
archivedPdfSha256: sha256,
archivedFingerprint: fingerprint,
archivedAt: invoice.archivedAt ?? new Date().toISOString(),
});
/**
* Renders an issued invoice with its frozen prefs and archives the result. Never throws: a failure
* comes back as `archived: false` with the reason, because the invoice already exists.
*/
export async function renderAndArchive(invoice: Invoice, deps: Pick<ExportDeps, "render" | "archivePdf">): Promise<ArchiveOutcome> {
let rendered: { bytes: Uint8Array; fingerprint: string };
try {
rendered = await deps.render(invoice);
} catch (e) {
return { invoice, archived: false, error: `The PDF could not be rendered: ${messageOf(e)}` };
}
try {
const sha = await deps.archivePdf(invoice.id, rendered.bytes, rendered.fingerprint);
return { invoice: withArchive(invoice, sha, rendered.fingerprint), archived: true };
} catch (e) {
return { invoice, archived: false, error: `The PDF could not be archived: ${messageOf(e)}` };
}
}
/**
* Issues the invoice, then renders it with the prefs the backend froze and archives that PDF. The
* issue call's own failure throws (nothing was issued); everything after it is reported, never thrown.
*/
export async function issueAndArchive(
input: InvoiceInput,
renderPrefs: object,
deps: Pick<ExportDeps, "issueInvoice" | "render" | "archivePdf">,
): Promise<ArchiveOutcome> {
const invoice = await deps.issueInvoice(input, renderPrefs);
return renderAndArchive(invoice, deps);
}
// --- export ----------------------------------------------------------------------------------------
export interface ExportRequest {
invoice: Invoice;
mode: ExportMode;
source: ExportSource;
signal?: AbortSignal;
onProgress?: (p: ExportProgress) => void;
}
export interface ExportResult {
path: string;
mode: ExportMode;
/** Only for a re-render of an archived invoice: did the layout come out the same as at issue? */
identicalToIssued?: boolean;
/** The invoice with archive fields filled in when this export had to archive it first. */
invoice: Invoice;
/** The export used a fresh render because archiving the missing original failed. */
archiveError?: string;
}
function joinPath(dir: string, name: string): string {
if (!dir) return name;
const sep = dir.includes("\\") && !dir.includes("/") ? "\\" : "/";
return /[\\/]$/.test(dir) ? `${dir}${name}` : `${dir}${sep}${name}`;
}
/**
* Produces the PDF bytes for one export and saves them. Returns null when the save dialog is
* dismissed. Errors: FlattenCancelled when aborted; an ExportError (or a FlattenError, which carries
* the same `FLATTEN_FAILED` code) otherwise. A failed flatten is never replaced by a searchable export
* or a lower resolution; the caller offers that choice.
*/
export async function exportInvoice(req: ExportRequest, deps: ExportDeps): Promise<ExportResult | null> {
const { mode, source, signal, onProgress } = req;
let invoice = req.invoice;
checkAbort(signal);
let bytes: Uint8Array;
let identicalToIssued: boolean | undefined;
let archiveError: string | undefined;
if (source === "rerender") {
onProgress?.({ phase: "rendering" });
const rendered = await renderOrThrow(invoice, deps);
bytes = rendered.bytes;
if (invoice.archivedPdfSha256 && invoice.archivedFingerprint) {
identicalToIssued = rendered.fingerprint === invoice.archivedFingerprint;
}
} else if (invoice.archivedPdfSha256) {
onProgress?.({ phase: "rendering" });
bytes = await deps.readArchive(invoice.id);
} else {
// Issued but never archived (the render failed at issue): do it now, then use that file.
onProgress?.({ phase: "rendering" });
const rendered = await renderOrThrow(invoice, deps);
bytes = rendered.bytes;
try {
const sha = await deps.archivePdf(invoice.id, rendered.bytes, rendered.fingerprint);
invoice = withArchive(invoice, sha, rendered.fingerprint);
} catch (e) {
// The invoice is intact; export the fresh render and let the caller say the archive is still missing.
archiveError = `The PDF could not be archived: ${messageOf(e)}`;
}
}
checkAbort(signal);
if (mode === "flattened") {
try {
const flat = await deps.flatten(bytes, {
signal,
onProgress: (page, pages) => onProgress?.({ phase: "flattening", page, pages }),
});
checkAbort(signal);
onProgress?.({ phase: "assembling" });
// The title is the invoice number only; anything more would leak what the image-only file hides.
bytes = await deps.assemble(
flat.pages.map((p) => ({ jpeg: p.jpeg, widthPt: p.widthPt, heightPt: p.heightPt })),
invoice.number,
);
} catch (e) {
if (isExportCancelled(e) || isFlattenFailure(e)) throw e;
throw new ExportError("FLATTEN_FAILED", `The flattened PDF could not be built: ${messageOf(e)}`, e);
}
checkAbort(signal);
}
let dir = "";
try {
dir = await deps.getLastExportDir();
} catch {
// No remembered folder: the dialog opens wherever the system prefers.
}
const name = exportFileName({ number: invoice.number, client: invoice.clientName, date: invoice.invoiceDate, mode });
onProgress?.({ phase: "saving" });
const chosen = await deps.save({ defaultPath: joinPath(dir, name) });
if (!chosen) return null;
let path: string;
try {
path = (await deps.writeExportFile(chosen, bytes)) || chosen;
} catch (e) {
throw new ExportError("WRITE_FAILED", `The file could not be saved: ${messageOf(e)}`, e);
}
return { path, mode, invoice, ...(identicalToIssued === undefined ? {} : { identicalToIssued }), ...(archiveError ? { archiveError } : {}) };
}
async function renderOrThrow(invoice: Invoice, deps: Pick<ExportDeps, "render">) {
try {
return await deps.render(invoice);
} catch (e) {
throw new ExportError("RENDER_FAILED", `The PDF could not be rendered: ${messageOf(e)}`, e);
}
}