mega-plant/lib/invoice-parser.test.ts

160 lines
8.3 KiB
TypeScript
Raw Normal View History

Invoices: the app ingests PDFs itself, with a problem-invoice inbox Extraction was previously manual: an agent read each PDF, mapped the columns by hand and wrote data/source/invoice-*.json. That was the only way anything got in, and the upload button just filed the document without reading it. That supplier's report is one fixed template — the columns sit at identical x-positions on every invoice, and only the line count and page count vary — so the figures are now read by coordinate instead. Deliberately not an AI extractor: this input does not need one, and a deterministic parse that fails loudly is worth more here than a flexible one that can hallucinate a total. lib/invoice-parser.ts reads the header, the machine lines across all pages, the "Plant Hire Total" row and the SUB TOTAL / Escalation / PLUS VAT / INVOICE TOTAL block, then applies the same arithmetic gates the seed does: lines must sum to the subtotal, each line must satisfy qty x rate = amount and amount + VAT = total, and VAT must match the stated rate. Anything that does not reconcile is refused with the reason — never partially imported. Validated by parsing all five existing invoices and comparing every field against the hand-extracted JSON: identical, including the R24,205.13-VAT column that a plain text read gets wrong. PHI 169 was then deleted and re-ingested through the HTTP endpoint alone and came back field-for-field identical, with no agent involved. - "Add Invoice" drops a PDF in and imports it; lines are matched to plant items by the code the invoice prints, and an unmatched line (the container codes MPLANT04/MPLANT05) is left unassigned rather than guessed. - A "Problem invoices" inbox lists every PDF that failed, with the reason, and Retry re-reads the stored document and re-runs the parse. Retry re-reports rather than claiming success, and only files imported data. - invoice_intake collection records each attempt. pdfjs-dist is in serverExternalPackages: bundled, it rewrites its own lazy worker import to a .next path absent from the standalone image and every parse dies with "Setting up fake worker failed". The parser also copies the buffer before handing it over, since pdfjs transfers and detaches it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 12:30:17 +00:00
// Deterministic-parser tests.
//
// The parser reads figures by coordinate from a fixed report template, so the risk is
// silent drift: a column moves and it starts returning a plausible wrong number. These
// tests pin the behaviour that matters — the column mapping, the code/description split,
// date formats, and the fact that anything which does not reconcile is refused.
//
// Run: node --import tsx --test lib/invoice-parser.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { parseInvoicePdf, registerCodeFrom, siteFromDescription, isoDate, ParseError } from "./invoice-parser";
// --- unit level: the pieces that do not need a PDF -------------------------------
test("registerCodeFrom strips punctuation and suffix from an invoice code", () => {
assert.equal(registerCodeFrom("SS-01-CASE"), "SS01");
assert.equal(registerCodeFrom("TP601-TATA"), "TP601");
assert.equal(registerCodeFrom("MG-01-DEZI"), "MG01");
assert.equal(registerCodeFrom("RV-03-CAT"), "RV03");
assert.equal(registerCodeFrom("TLB07-CASE"), "TLB07");
// No register equivalent — must NOT be silently coerced into something plausible.
assert.equal(registerCodeFrom("MPLANT05"), "MPLANT05");
});
test("site is read from the invoice description", () => {
assert.equal(siteFromDescription("Plant Hire Sep 2026 West Park"), "West Park");
assert.equal(siteFromDescription("Hire Plant Sep 2026 Halfway Gardens"), "Halfway Gardens");
assert.equal(siteFromDescription("Hire Plant Sep 2026 Rooihuiskraal"), "Rooihuiskraal");
assert.equal(siteFromDescription("Plant Hire SEp 2026 Cosmo City"), "Cosmo City");
assert.equal(siteFromDescription("something else entirely"), "");
});
test("dates accept the report's written formats", () => {
assert.equal(isoDate("18-September-2026"), "2026-09-18");
assert.equal(isoDate("21/08/2026"), "2026-08-21");
assert.throws(() => isoDate("not a date"), ParseError);
});
test("a PDF with no recognisable content is refused, not guessed", async () => {
const notAPdf = new TextEncoder().encode("%PDF-1.4\nthis is not a real invoice\n");
await assert.rejects(() => parseInvoicePdf(notAPdf), /PDF|Could not/i);
});
// --- integration level: the real invoices -----------------------------------------
//
// The source PDFs are not committed (they are client documents), so these skip when
// they are absent. Point FIXTURE_DIR at a folder holding PHI*.pdf to run them.
const DIR = process.env.FIXTURE_DIR ?? "/tmp";
const REAL: [string, string][] = [
["PHI166.pdf", "invoice-phi166.json"],
["PHI167.pdf", "invoice-phi167.json"],
["PHI168.pdf", "invoice-phi168.json"],
["PHI169.pdf", "invoice-phi169.json"],
["PHI171.pdf", "invoice-phi171.json"],
];
const available = REAL.filter(([pdf]) => existsSync(resolve(DIR, pdf)));
test("parser reproduces the hand-extracted invoices exactly", { skip: available.length ? false : "no source PDFs present" }, async () => {
const failures: string[] = [];
for (const [pdfName, jsonName] of available) {
const src = JSON.parse(readFileSync(resolve("data/source", jsonName), "utf8"));
const got = await parseInvoicePdf(new Uint8Array(readFileSync(resolve(DIR, pdfName))));
const cmp = (label: string, a: unknown, b: unknown) => {
const ok = typeof a === "number" && typeof b === "number"
? Math.abs(a - b) < 0.01
: String(a ?? "") === String(b ?? "");
if (!ok) failures.push(`${jsonName} ${label}: parser=${a} source=${b}`);
};
cmp("invoice_no", got.invoice_no, src.invoice_no);
cmp("invoice_date", got.invoice_date, src.invoice_date);
cmp("order_no", got.order_no, src.order_no);
cmp("debtor", got.debtor, src.debtor);
cmp("debtor_no", got.debtor_no, src.debtor_no);
cmp("period_from", got.period_from, src.period_from);
cmp("period_to", got.period_to, src.period_to);
cmp("description", got.description, src.description);
cmp("site", got.site, src.site);
cmp("currency", got.currency, src.currency);
cmp("vat_pct", got.vat_pct, src.vat_pct);
cmp("subtotal", got.subtotal, src.subtotal);
cmp("escalation", got.escalation, src.escalation);
cmp("vat", got.vat, src.vat);
cmp("total_excl", got.total_excl, src.total_excl);
cmp("total_incl", got.total_incl, src.total_incl);
if (got.lines.length !== src.lines.length) {
failures.push(`${jsonName} line count: ${got.lines.length} vs ${src.lines.length}`);
continue;
}
got.lines.forEach((l, i) => {
const s = src.lines[i];
cmp(`line${i + 1}.amount_excl`, l.amount_excl, s.amount_excl);
cmp(`line${i + 1}.vat`, l.vat, s.vat);
cmp(`line${i + 1}.total_incl`, l.total_incl, s.total_incl);
cmp(`line${i + 1}.qty`, l.qty, s.qty);
cmp(`line${i + 1}.unit`, l.unit, s.unit);
cmp(`line${i + 1}.rate`, l.rate, s.rate);
// machine_code is compared through registerCodeFrom: one input ("RV-01-CATCB22")
// has no separator between code and description, so the parser's split differs
// cosmetically from the hand extraction. What must match is the plant item the
// line books to, which is derived from exactly this value.
cmp(`line${i + 1}.register code`, registerCodeFrom(l.machine_code), registerCodeFrom(s.machine_code));
});
}
assert.deepEqual(failures, [], `parser disagreed with the hand extraction:\n${failures.join("\n")}`);
});
test("the parser never returns an invoice whose totals do not reconcile", { skip: available.length ? false : "no source PDFs present" }, async () => {
// Every successful parse must satisfy the balance rules on its own — this is the
// guarantee the intake route relies on before it is allowed to write anything.
for (const [pdfName] of available) {
const got = await parseInvoicePdf(new Uint8Array(readFileSync(resolve(DIR, pdfName))));
const sumHire = Math.round(got.lines.reduce((s, l) => s + l.amount_excl, 0) * 100) / 100;
const sumVat = Math.round(got.lines.reduce((s, l) => s + l.vat, 0) * 100) / 100;
assert.ok(Math.abs(sumHire - got.subtotal) < 0.01, `${pdfName}: lines != subtotal`);
assert.ok(Math.abs(sumVat - got.vat) < 0.02, `${pdfName}: line VAT != header VAT`);
assert.ok(Math.abs(got.total_excl + got.vat - got.total_incl) < 0.01, `${pdfName}: totals do not balance`);
for (const l of got.lines) {
assert.ok(Math.abs(l.amount_excl + l.vat - l.total_incl) < 0.01, `${pdfName}: ${l.machine_code} does not balance`);
assert.ok(Math.abs(l.qty * l.rate - l.amount_excl) < 0.01, `${pdfName}: ${l.machine_code} qty x rate != amount`);
}
}
});
// --- container auto-allocation --------------------------------------------------
// A container line prints a generic code ("MPLANT05") that the plant register does not
// carry, so it has no natural asset. Each site records the container it hires, and the
// code the invoice uses for it. These tests pin the lookup — done here rather than by
// deleting a real invoice and re-importing it.
import { findSiteContainer } from "./sites";
const site = (name: string, code: string, asset: string) =>
({ id: name, name, container_code: code, container_asset: asset }) as any;
test("a container line is allocated to the container the site hires", () => {
const sites = [site("Autumns Gate", "MPLANT05", "a-sc02")];
assert.equal(findSiteContainer(sites, "Autumns Gate", "MPLANT05")?.container_asset, "a-sc02");
});
test("allocation is scoped to the invoice's own site", () => {
// Two sites can each hire an "MPLANT05"; a line must go to its own site's unit.
const sites = [site("Autumns Gate", "MPLANT05", "a-sc02"), site("The Hills", "MPLANT05", "h-sc03")];
assert.equal(findSiteContainer(sites, "The Hills", "MPLANT05")?.container_asset, "h-sc03");
assert.equal(findSiteContainer(sites, "Autumns Gate", "MPLANT05")?.container_asset, "a-sc02");
});
test("no allocation when the site has no matching container", () => {
const sites = [site("Pitsana", "WT02", "")];
assert.equal(findSiteContainer(sites, "Pitsana", "MPLANT05"), null, "code does not match");
assert.equal(findSiteContainer(sites, "Pitsana", "WT02"), null, "no asset assigned");
assert.equal(findSiteContainer(sites, "Elsewhere", "WT02"), null, "different site");
assert.equal(findSiteContainer([], "Pitsana", "WT02"), null, "no sites at all");
});