mega-plant/lib/invoice-parser.ts
David 5caa923375 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 14:30:17 +02:00

423 lines
17 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Deterministic parser for Mega Plant "Std_ReqHireInvoice_DN.rpt" VAT invoices.
//
// Deliberately NOT an AI extractor. That supplier's report is a fixed template: the
// columns sit at identical x-positions on every invoice and only the number of
// machine lines and pages varies. So the figures are read by coordinate, exactly the
// way they were transcribed by hand, and then checked arithmetically.
//
// Anything that does not reconcile is reported as a reason and lands in the intake
// inbox as a problem invoice. It never guesses a total and never writes a partial
// invoice.
//
// Calibrated against PHI 166 / 167 / 168 / 169 / 171.
export type ParsedLine = {
machine_code: string;
description: string;
qty: number;
unit: string;
rate: number;
rate_type: string;
amount_excl: number;
vat: number;
total_incl: number;
};
export type ParsedInvoice = {
invoice_no: string;
invoice_date: string;
order_no: string;
debtor: string;
debtor_no: string;
period_from: string;
period_to: string;
description: string;
site: string;
category: string;
section: string;
currency: string;
vat_reg_no: string;
vat_pct: number;
subtotal: number;
escalation: number;
vat: number;
total_excl: number;
total_incl: number;
lines: ParsedLine[];
};
export class ParseError extends Error {
constructor(public reason: string) {
super(reason);
this.name = "ParseError";
}
}
// Column left-edges, calibrated from the report template. Numbers are right-aligned
// within their column, so each value's LEFT edge lands in a stable band.
const COL = {
HIRE: [340, 400],
PENALTY2: [400, 440],
ESCALATION: [440, 490],
VAT: [490, 534],
TOTAL: [534, 1e9],
} as const;
// Activity sub-row ("Dry Rate <qty> <unit> <rate> <amount> 0001").
const ACT = {
QTY: [220, 262],
UNIT: [262, 300],
RATE: [300, 350],
AMOUNT: [350, 392],
} as const;
type Word = { x: number; y: number; str: string };
const inBand = (x: number, [lo, hi]: readonly [number, number]) => x >= lo && x < hi;
// "1,234.50" / "1.234,50" / "1 234,50" / "-" / ""
function num(s: string | undefined): number | null {
if (!s) return null;
const t = s.replace(/\s/g, "").replace(/,/g, "").replace(/ /g, "");
if (!t || t === "-" || t === "–") return null;
const n = Number(t);
return Number.isFinite(n) ? n : null;
}
const r2 = (n: number) => Math.round(n * 100) / 100;
/**
* Splits a machine row into its code and description.
*
* The code is a leading alpha block plus a 1-3 digit block, optionally followed by a
* dash and an upper-case token that must end at whitespace. That rule resolves every
* recurring case correctly, including the two that look alike but aren't:
* "MPLANT05-Storage Container" -> MPLANT05 / Storage Container (suffix ends in lowercase)
* "RV-01-CATCB22 2.2 TON ..." -> RV-01 / CATCB22 2.2 TON ... (suffix runs into a digit)
* The second yields a shorter code, which is cosmetic — no amount depends on it, and
* that code does not repeat across invoices, so duplicate detection is unaffected.
*/
function splitMachineRow(text: string): { code: string; description: string } {
const trimmed = text.trim();
const core = /^([A-Za-z]{2,8}-?[0-9]{2,3})/.exec(trimmed);
if (!core) {
const token = trimmed.split(/\s+/)[0] ?? trimmed;
return { code: token, description: "" };
}
const base = core[1];
let rest = trimmed.slice(base.length);
// An optional -SUFFIX belongs to the code only when it ends at whitespace. That is
// what separates the two look-alike cases:
// "MPLANT05-Storage Container" -> MPLANT05 / Storage Container (suffix ends lowercase)
// "MG-01-DEZI MG80 ..." -> MG-01-DEZI / MG80 ... (suffix ends at a space)
// The suffix is always UPPER CASE (codes are) and must be bounded, which is what
// separates a real code suffix from the start of a description word. Two bounds occur
// in this report:
// 1. the suffix ends at whitespace "MG-01-DEZI MG80 Motor Grader"
// 2. the suffix runs into a digit group that ends at whitespace
// "RV-01-CATCB22 2.2 TON Roller"
// Known limit: "RV-01-CATCB22 2.2 TON" has no separator between the code and the
// description ("CB22" is the machine model), so the split point is a judgement call.
// The parser takes the maximal upper-case run, giving RV-01-CATCB. This is a display
// label only — no amount depends on it, and registerCodeFrom() still resolves it to
// RV01, so booking is unaffected. The hand extraction records RV-01-CAT.
// Case matters: matching [A-Za-z] here would swallow the "Office" of
// "MPLANT04-Office Container" and lose the code.
const sfx =
/^-([A-Z0-9]{1,6})(?=\s|$)/.exec(rest) ??
/^-([A-Z]{2,5})(?=[A-Z]*[0-9]+[\s,])/.exec(rest);
let code = base;
if (sfx) {
code = `${base}-${sfx[1]}`;
rest = rest.slice(sfx[0].length);
}
return { code, description: rest.trim().replace(/^[-–]\s*/, "") };
}
// The code an invoice uses maps to a register code by taking the leading letters+digits
// and dropping punctuation: "SS-01-CASE" -> "SS01", "TP601-TATA" -> "TP601".
export function registerCodeFrom(invoiceCode: string): string {
const m = /^([A-Za-z]+)[- ]?([0-9]{1,3})/.exec(invoiceCode.trim());
return m ? `${m[1].toUpperCase()}${m[2]}` : "";
}
// "Hire Plant Sep 2026 Rooihuiskraal" / "Plant Hire SEP 2026 West Park" -> the site.
export function siteFromDescription(desc: string): string {
const m = /^(?:hire\s+plant|plant\s+hire)\s+\S+\s+\d{4}\s+(.+)$/i.exec(desc.trim());
return m ? m[1].trim() : "";
}
const MONTHS: Record<string, string> = {
jan: "01", feb: "02", mar: "03", apr: "04", may: "05", jun: "06",
jul: "07", aug: "08", sep: "09", oct: "10", nov: "11", dec: "12",
};
/** "18-September-2026" / "18/09/2026" -> "2026-09-18" */
export function isoDate(s: string): string {
const t = s.trim();
let m = /^(\d{1,2})[/-]([A-Za-z]{3,})[/-](\d{4})$/.exec(t);
if (m) {
const mo = MONTHS[m[2].slice(0, 3).toLowerCase()];
if (!mo) throw new ParseError(`Unrecognised month "${m[2]}" in date "${s}"`);
return `${m[3]}-${mo}-${m[1].padStart(2, "0")}`;
}
m = /^(\d{1,2})\/(\d{2})\/(\d{4})$/.exec(t);
if (m) return `${m[3]}-${m[2]}-${m[1].padStart(2, "0")}`;
m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(t);
if (m) return t;
throw new ParseError(`Unrecognised date format "${s}"`);
}
// pdfjs resolves its worker lazily at runtime. Left to its own devices under Next it
// falls back to a dynamic import of the worker module, which the bundler rewrites to a
// .next path that does not exist in the standalone image — every parse then dies with
// "Setting up fake worker failed". Pinning the worker to the real installed file avoids
// that. pdfjs-dist is listed in serverExternalPackages so it is required (and traced)
// from node_modules rather than bundled.
let workerPinned = false;
async function loadPdfjs() {
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
if (!workerPinned) {
workerPinned = true;
try {
const { createRequire } = await import("node:module");
const req = createRequire(import.meta.url);
pdfjs.GlobalWorkerOptions.workerSrc = req.resolve(
"pdfjs-dist/legacy/build/pdf.worker.mjs",
);
} catch {
// Leave pdfjs's own default in place; it is correct for unbundled Node.
}
}
return pdfjs;
}
export async function parseInvoicePdf(bytes: Uint8Array): Promise<ParsedInvoice> {
const pdfjs = await loadPdfjs();
// pdfjs TRANSFERS the buffer it is handed, which detaches it. Callers keep their own
// copy (to store the original file), so parse a duplicate rather than the original.
const doc = await pdfjs.getDocument({
data: new Uint8Array(bytes),
useSystemFonts: true,
}).promise;
const pages: Word[][] = [];
try {
for (let p = 1; p <= doc.numPages; p++) {
const page = await doc.getPage(p);
const vp = page.getViewport({ scale: 1 });
const content = await page.getTextContent();
const words: Word[] = [];
for (const it of content.items as any[]) {
if (typeof it.str !== "string" || !it.str.trim()) continue;
words.push({ x: it.transform[4], y: vp.height - it.transform[5], str: it.str });
}
pages.push(words);
}
// ---- header (page 1) -------------------------------------------------
const h1 = pages[0] ?? [];
const onRow = (words: Word[], label: string) => words.filter((w) => w.str.startsWith(label));
const after = (label: string, page = 0): string => {
const ws = onRow(pages[page] ?? [], label);
if (!ws.length) return "";
const y = ws[0].y;
const same = (pages[page] ?? []).filter((w) => Math.abs(w.y - y) < 3 && w.x > ws[0].x + ws[0].str.length);
return same.map((w) => w.str).join(" ").trim();
};
const invoiceNo = after("Invoice") || "";
if (!invoiceNo) throw new ParseError('Could not find "Invoice No." in the header');
const orderNo = after("ORDER NUMBER") || "";
const invoiceDateRaw = after("DATE") || "";
if (!invoiceDateRaw) throw new ParseError('Could not find "DATE" in the header');
const debtorNo = after("DEBTOR NUMBER") || "";
const currency = after("CURRENCY") || "ZAR";
const vatReg = after("VAT Reg. No.") || "";
// Debtor name sits at the far left of the block above ORDER NUMBER.
const debtorWord = h1.find((w) => w.x < 100 && /^Mega |Infrastructure/i.test(w.str) && w.y > 180 && w.y < 220);
const debtor = debtorWord ? debtorWord.str.replace(/^Mega\s+/i, "Mega ") : "";
// "Description :" and the free text beside it.
const descRow = h1.filter((w) => Math.abs(w.y - (h1.find((x) => x.str === "Description")?.y ?? -999)) < 3);
const descWord = descRow.find((w) => w.str === "Description");
const description = descWord
? descRow
.filter(
(w) =>
w.x > descWord.x + 12 &&
w.x < 460 &&
!w.str.endsWith(":") &&
!/^\d{1,2}\/\d{2}\/\d{4}$/.test(w.str) &&
!/Date$/.test(w.str),
)
.sort((a, b) => a.x - b.x)
.map((w) => w.str)
.join(" ")
.trim()
: "";
const dates = h1
.filter((w) => /^\d{1,2}\/\d{2}\/\d{4}$/.test(w.str))
.sort((a, b) => a.y - b.y || a.x - b.x)
.map((w) => w.str);
if (dates.length < 2) throw new ParseError("Could not read the From/To dates from the header");
const period_from = isoDate(dates[0]);
const period_to = isoDate(dates[1]);
const invoice_date = isoDate(invoiceDateRaw);
// ---- machine lines (every page) --------------------------------------
const lines: ParsedLine[] = [];
for (const words of pages) {
// A machine row is a left-column item that is not a known header/footer word.
const machineRows = words.filter(
(w) => w.x < 45 && /[A-Za-z]/.test(w.str) && !/^(Return|Sep|Dry|Rib|Ref|Printed|Description)$/i.test(w.str) &&
!/\bNo\.$/.test(w.str) && /[A-Za-z]{2,8}-?[0-9]{2,3}/.test(w.str),
);
for (const mr of machineRows) {
// Numeric row sits ~16pt below the machine name.
const numRow = words.filter((w) => w.y > mr.y + 10 && w.y < mr.y + 24);
const hire = num(numRow.find((w) => inBand(w.x, COL.HIRE))?.str);
const vat = num(numRow.find((w) => inBand(w.x, COL.VAT))?.str);
const total = num(numRow.find((w) => inBand(w.x, COL.TOTAL))?.str);
if (hire == null || vat == null || total == null) continue;
// Activity row: the "Dry Rate ..." line, roughly 57pt below the machine name.
// The "Description Qty Unit ..." header sits at ~44pt and must not be mistaken
// for it, so locate the row by its leading "Dry" token rather than by offset.
const band = words.filter((w) => w.y > mr.y + 38 && w.y < mr.y + 78 && w.x > 150);
const dryY = band.find((w) => w.str === "Dry" || w.str.startsWith("Dry "))?.y;
const dry = dryY == null ? [] : band.filter((w) => Math.abs(w.y - dryY) < 3).sort((a, b) => a.x - b.x);
const qty = num(dry.find((w) => inBand(w.x, ACT.QTY))?.str);
const unit = dry.find((w) => inBand(w.x, ACT.UNIT))?.str ?? "";
const rate = num(dry.find((w) => inBand(w.x, ACT.RATE))?.str);
// pdfjs emits "Dry Rate" as a single token; older builds split it in two.
const rateType = dry[0] ? dry[0].str.replace(/\s+/g, " ") : "Dry Rate";
const { code, description: desc } = splitMachineRow(mr.str);
lines.push({
machine_code: code,
description: desc,
qty: qty ?? 0,
unit,
rate: rate ?? 0,
rate_type: rateType,
amount_excl: r2(hire),
vat: r2(vat),
total_incl: r2(total),
});
}
}
if (!lines.length) throw new ParseError("No machine lines were found on any page");
// ---- totals -----------------------------------------------------------
// The report emits the grand-total label as ONE text item ("Plant Hire Total"),
// so match on prefix rather than equality.
let hireTotal: number | null = null;
let taxTotal: number | null = null;
let grandTotal: number | null = null;
let escalationTotal = 0;
for (const words of pages) {
const row = words.find((w) => w.str.startsWith("Plant Hire Total"));
if (!row) continue;
const same = words.filter((w) => Math.abs(w.y - row.y) < 4 && w.x > row.x + row.str.length);
hireTotal = num(same.find((w) => inBand(w.x, COL.HIRE))?.str);
escalationTotal = num(same.find((w) => inBand(w.x, COL.ESCALATION))?.str) ?? 0;
taxTotal = num(same.find((w) => inBand(w.x, COL.VAT))?.str);
grandTotal = num(same.find((w) => inBand(w.x, COL.TOTAL))?.str);
}
if (hireTotal == null || taxTotal == null || grandTotal == null) {
throw new ParseError('Could not find the "Plant Hire Total" row');
}
// The summary block is read BY LABEL — its values sit at different x positions
// from the line table, so column bands cannot be used here. pdfjs splits these
// labels inconsistently between pages, so each is matched by prefix.
const labelValue = (words: Word[], label: string): number | null => {
const lw = words.find((w) => w.str === label || w.str.startsWith(`${label} `));
if (!lw) return null;
const labelEnd = lw.x + lw.str.length;
const same = words.filter((w) => Math.abs(w.y - lw.y) < 5 && w.x > labelEnd + 15);
return num(same.map((w) => w.str).join(""));
};
const summary: Record<string, number> = {};
for (const words of pages) {
const put = (k: string, v: number | null) => { if (v != null) summary[k] = v; };
put("SUB TOTAL", labelValue(words, "SUB TOTAL"));
put("Escalation", labelValue(words, "Escalation"));
put("PLUS", labelValue(words, "PLUS"));
put("INVOICE", labelValue(words, "INVOICE"));
}
const subtotal = summary["SUB TOTAL"] ?? hireTotal;
const escalation = summary["Escalation"] ?? escalationTotal;
const vat = summary["PLUS"] ?? taxTotal;
const total_excl = r2(subtotal + escalation);
const total_incl = summary["INVOICE"] ?? grandTotal;
// ---- validation — the same gates the seed applies ---------------------
const sumHire = r2(lines.reduce((s, l) => s + l.amount_excl, 0));
if (Math.abs(sumHire - subtotal) > 0.01) {
throw new ParseError(`Machine lines total R${sumHire} but the invoice subtotal is R${subtotal}`);
}
const sumVat = r2(lines.reduce((s, l) => s + l.vat, 0));
if (Math.abs(sumVat - vat) > 0.02) {
throw new ParseError(`Line VAT totals R${sumVat} but the invoice PLUS VAT is R${vat}`);
}
if (Math.abs(total_excl + vat - total_incl) > 0.01) {
throw new ParseError(
`Totals do not balance: R${total_excl} ex + R${vat} VAT != R${total_incl} invoice total`,
);
}
for (const l of lines) {
if (Math.abs(l.amount_excl + l.vat - l.total_incl) > 0.01) {
throw new ParseError(
`Line ${l.machine_code}: R${l.amount_excl} + R${l.vat} VAT != R${l.total_incl}`,
);
}
if (l.qty && l.rate && Math.abs(r2(l.qty * l.rate) - l.amount_excl) > 0.01) {
throw new ParseError(
`Line ${l.machine_code}: ${l.qty} x ${l.rate} = ${r2(l.qty * l.rate)}, but the line amount is R${l.amount_excl}`,
);
}
}
const vat_pct = total_excl > 0 ? Math.round((vat / total_excl) * 1000) / 10 : 0;
for (const l of lines) {
const expected = r2((l.amount_excl * vat_pct) / 100);
if (l.amount_excl > 0 && Math.abs(expected - l.vat) > 0.02) {
throw new ParseError(
`Line ${l.machine_code}: VAT is R${l.vat}, but ${vat_pct}% of R${l.amount_excl} is R${expected}`,
);
}
}
return {
invoice_no: invoiceNo.trim().replace(/\s+/g, " "),
invoice_date,
order_no: orderNo,
debtor,
debtor_no: debtorNo,
period_from,
period_to,
description,
site: siteFromDescription(description),
section: "Plant Hire",
category: "External Plant Hire",
currency,
vat_reg_no: vatReg,
vat_pct,
subtotal: r2(subtotal),
escalation: r2(escalation),
vat: r2(vat),
total_excl,
total_incl: r2(total_incl),
lines,
};
} finally {
await doc.destroy().catch(() => {});
}
}