// Deterministic parser for Mega Plant purchase orders ("STD_po_DN.rpt"). // // Deliberately NOT an AI extractor, and deliberately mirroring // `lib/invoice-parser.ts`: this report is a fixed template, so the columns sit at the // same x-positions on every PO and the figures are read by COORDINATE. Plain text // extraction reorders these columns — on MEGPPO49676 the trailing "1 / 2 / 3" reads as // if it were a VAT% column when it is the line No — which is exactly why the text // pass cannot be trusted here. // // Everything is then checked arithmetically. A PO that does not reconcile is reported // as a reason and never written: a purchase order that books the wrong amount against // a machine is worse than one that is refused. // // Calibrated against MEGPPO50532 / MEGPPO49421 / MEGPPO49676. // `node:module` is imported STATICALLY on purpose. A dynamic // `await import("node:module")` inside the function parses fine under bare Node but // hands webpack's ESM interop a namespace whose `createRequire` is not a function, // and every upload fails with "createRequire is not a function". import { createRequire } from "node:module"; import { normaliseCostCode } from "./cost-code"; // Re-exported so existing importers of the parser keep a single import site. export { normaliseCostCode }; export type ParsedPurchaseLine = { line_no: number; material_code: string; /** Exactly as printed, e.g. "TLB06" or "SS-02". Normalise before matching the register. */ cost_code: string; description: string; qty: number | null; rate: number | null; disc_pct: number | null; vat_pct: number | null; amount: number; }; export type ParsedPurchase = { po_no: string; supplier: string; supplier_code: string; po_date: string; required_by: string; req_no: string; currency: string; subtotal: number; vat: number; total: number; lines: ParsedPurchaseLine[]; }; // Written with an explicit field rather than a TypeScript constructor parameter // property, which is NOT erasable syntax and makes this module unloadable by bare // Node — see the same rule on lib/insurance.ts. export class PurchaseParseError extends Error { reason: string; constructor(reason: string) { super(reason); this.name = "PurchaseParseError"; this.reason = reason; } } type Word = { str: string; x: number; y: number }; type Row = { y: number; words: Word[] }; // Column left-edges, calibrated from the three POs. Values are right-aligned inside // their column, so each one's RIGHT edge is the stable thing: the amount sits at x=545.9 // for "295.00" but x=534.8 for "11,940.00". The bands are therefore wide on the right. const COL = { NO: [22, 46], MATERIAL: [48, 100], COST_CODE: [100, 165], DESC: [165, 285], QTY: [285, 345], RATE: [345, 425], DISC: [425, 462], VAT_PCT: [462, 528], AMOUNT: [528, 620], // Header-block values all start at the same left edge. FIELD_VALUE: [92, 300], RIGHT_VALUE: [400, 620], }; const inBand = (x: number, [lo, hi]: number[]) => x >= lo && x < hi; const wordIn = (row: Row, band: number[]) => row.words.find((w) => inBand(w.x, band)); function num(s: string | undefined): number | null { if (s == null) return null; const t = s.replace(/[\s,]/g, ""); if (!/^-?\d+(\.\d+)?$/.test(t)) return null; return Number(t); } function clusterRows(words: Word[]): Row[] { const rows: Row[] = []; for (const w of [...words].sort((a, b) => b.y - a.y || a.x - b.x)) { const last = rows[rows.length - 1]; // The table's lines are ~16pt apart here, but a wrapped description sits ~10pt // below its own line, so 4pt is the tightest band that never merges two rows. if (last && Math.abs(w.y - last.y) <= 4) last.words.push(w); else rows.push({ y: w.y, words: [w] }); } for (const r of rows) r.words.sort((a, b) => a.x - b.x); return rows; } /** "MEGPPO49676" from "Purchase Order(s) #MEGPPO49676". */ function poNumberOf(rows: Row[]): string { for (const r of rows) { for (const w of r.words) { const m = /#\s*([A-Z]{4,}\d{3,})/.exec(w.str) ?? /\b(MEG[A-Z]*PO\d{3,})\b/i.exec(w.str); if (m) return m[1].toUpperCase(); } } throw new PurchaseParseError("No purchase-order number found on the document"); } /** * The value that follows a `Label :` in the header block. Every right-hand field value * starts at x=409.5 and the left-hand ones at x=95.1, so the label's own x decides * which side to read from. */ function fieldAfter(rows: Row[], label: string): string { const wanted = label.toLowerCase(); for (const r of rows) { const labelWord = r.words.find( (w) => w.str.toLowerCase().replace(/\s*:?\s*$/, "") === wanted || w.str.toLowerCase().startsWith(wanted), ); if (!labelWord) continue; const value = r.words.find((w) => w.x > labelWord.x + 2); if (value) return value.str.trim(); } return ""; } /** dd/mm/yyyy → yyyy-mm-dd. The POs print South African dates. */ function isoDate(s: string): string { const t = s.trim(); const m = /^(\d{1,2})\/(\d{2})\/(\d{4})$/.exec(t); if (m) return `${m[3]}-${m[2]}-${m[1].padStart(2, "0")}`; const m2 = /^(\d{4})-(\d{2})-(\d{2})$/.exec(t); if (m2) return t; return ""; } /** * One line per printed line-No. A description that WRAPS onto the next y-band carries * no line number, so it belongs to the line above it — MEGPPO50532's * "repair Skid Steer Quick / Coupler Frame" is one description, not two. */ function extractLines(rows: Row[]): ParsedPurchaseLine[] { const lines: ParsedPurchaseLine[] = []; const headerY = findHeaderY(rows); for (let i = 0; i < rows.length; i++) { const r = rows[i]; if (headerY !== null && r.y <= headerY) continue; // header block / column headings const no = num(wordIn(r, COL.NO)?.str); const amount = num(wordIn(r, COL.AMOUNT)?.str); // A line row is numbered AND carries an amount. Anything else in that band is a // wrapped description, a total, or template furniture. if (no === null || amount === null) continue; // A description AND a cost code can both WRAP onto the next y-band, carrying no // line number of their own — MEGPPO50532's "repair Skid Steer Quick / Coupler // Frame", and MEGPPO50027's "MPLANT, / ENGOIL,". Both belong to the line above. const descParts: string[] = []; const first = wordIn(r, COL.DESC); if (first) descParts.push(first.str); const codeParts: string[] = []; const firstCode = wordIn(r, COL.COST_CODE); if (firstCode) codeParts.push(firstCode.str); for (let j = i + 1; j < rows.length; j++) { if (num(wordIn(rows[j], COL.NO)?.str) !== null) break; const cont = wordIn(rows[j], COL.DESC); const contCode = wordIn(rows[j], COL.COST_CODE); if (cont) descParts.push(cont.str); if (contCode) codeParts.push(contCode.str); else if (!cont && rows[j].y < 150) break; // left the table into the totals block } lines.push({ line_no: no, material_code: wordIn(r, COL.MATERIAL)?.str.trim() ?? "", // Joined with a space then stripped of the punctuation the report prints around // store names, so "MPLANT," + "ENGOIL," reads as the one code "MPLANT ENGOIL". cost_code: codeParts.join(" ").replace(/\s+/g, " ").trim().replace(/[,;]+$/, "").trim(), description: descParts.join(" ").replace(/\s+/g, " ").trim(), qty: num(wordIn(r, COL.QTY)?.str), rate: num(wordIn(r, COL.RATE)?.str), disc_pct: num(wordIn(r, COL.DISC)?.str), vat_pct: num(wordIn(r, COL.VAT_PCT)?.str), amount, }); } return lines; } /** y of the column-heading row ("No Material Cost Code Description …"), which ends the header. */ function findHeaderY(rows: Row[]): number | null { for (const r of rows) { const has = (s: string) => r.words.some((w) => w.str === s); if (has("Cost") && has("Description") && has("Amount")) return r.y; } return null; } function totalAfter(rows: Row[], label: string): number { const wanted = label.toLowerCase(); for (const r of rows) { const lw = r.words.find((w) => w.str.toLowerCase().startsWith(wanted)); if (!lw) continue; const v = num(r.words.filter((w) => w.x > lw.x + 2).map((w) => w.str).join("")); if (v !== null) return v; } throw new PurchaseParseError(`Could not read "${label}" from the document`); } export async function parsePurchasePdf(data: Uint8Array): Promise { const req = createRequire(import.meta.url); const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs"); // See lib/invoice-parser.ts: pdfjs must be pointed at the real worker file, or every // parse dies with "Setting up fake worker failed". try { // Same shape as lib/invoice-parser.ts. webpack warns that an ESM .mjs cannot be // required, so the pin throws at runtime and pdfjs falls back to a worker path // relative to pdf.mjs — which resolves correctly. The warning is pre-existing // behaviour of pinning this worker, not a failure. pdfjs.GlobalWorkerOptions.workerSrc = req.resolve("pdfjs-dist/legacy/build/pdf.worker.mjs"); } catch { // Not fatal; pdfjs falls back to a worker path relative to pdf.mjs. } const doc = await pdfjs.getDocument({ data, useSystemFonts: true }).promise; const all: Word[] = []; for (let p = 1; p <= doc.numPages; p++) { const page = await doc.getPage(p); const tc = await page.getTextContent(); for (const item of tc.items as any[]) { if (!item.str || !item.str.trim()) continue; all.push({ str: item.str, x: item.transform[4], y: item.transform[5] }); } } const rows = clusterRows(all); const lines = extractLines(rows); if (!lines.length) throw new PurchaseParseError("No purchase-order lines found — is this a PO document?"); const po = { po_no: poNumberOf(rows), supplier: fieldAfter(rows, "To"), supplier_code: fieldAfter(rows, "Code"), po_date: isoDate(fieldAfter(rows, "PO Creation Date")), required_by: isoDate(fieldAfter(rows, "Required By")), req_no: fieldAfter(rows, "Req No"), currency: fieldAfter(rows, "Currency"), subtotal: totalAfter(rows, "Sub Total Amount"), vat: totalAfter(rows, "VAT Amount"), total: totalAfter(rows, "Total Amount"), lines, }; if (!po.supplier) throw new PurchaseParseError("No supplier found in the 'To :' field"); // Arithmetic checks. A PO that does not balance is reported, never written. const lineSum = lines.reduce((s, l) => s + l.amount, 0); if (Math.abs(lineSum - po.subtotal) > 0.5) { throw new PurchaseParseError( `Lines total R${lineSum.toFixed(2)} but the document's sub-total is R${po.subtotal.toFixed(2)}`, ); } if (Math.abs(po.subtotal + po.vat - po.total) > 0.5) { throw new PurchaseParseError( `Sub-total + VAT is R${(po.subtotal + po.vat).toFixed(2)} but the total reads R${po.total.toFixed(2)}`, ); } // A line with no cost code cannot be allocated to a machine — and it is NOT free. const missing = lines.filter((l) => !normaliseCostCode(l.cost_code)); if (missing.length) { throw new PurchaseParseError( `${missing.length} line(s) carry no cost code, so they cannot be allocated to a machine: ` + missing.map((l) => `#${l.line_no} ${l.description || "(no description)"}`).join("; "), ); } return po; }