291 lines
11 KiB
TypeScript
291 lines
11 KiB
TypeScript
|
|
// 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<ParsedPurchase> {
|
||
|
|
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;
|
||
|
|
}
|