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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user