mega-plant/lib/purchase-parser.ts

291 lines
11 KiB
TypeScript
Raw Normal View History

Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics Splits the Running Costs group into six purpose-built pages, each with its own collection and its own source data, plus a coordinate parser for purchase orders. Insurance (R503 330/yr, 20 vehicles) A premium can move every month or three, so it is stored as a RATE PERIOD in insurance_rates and each month's cost is resolved against the rate in force that month. cost_lines insurance rows are derived from those periods. An absent rate is null, never 0 — nobody has entered the date, which is not the same as being insured for free. Financing (20 agreements, R428 585/mo) Instalment from the sheet; start date, paid-off date and the settled toggle are human-entered and preserved across re-seeds. Months-left and outstanding are null until a date is entered, never R0 on a live debt. The two paid-off agreements (WB02, DT01) show as settled and are excluded from the run-rate. Purchases + Oil Stock Uploads PO PDFs the way /income/invoices does. The parser reads columns by x-position, because plain text extraction reorders them, and refuses any PO whose lines do not sum to the sub-total. A cost code either names a machine ("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never guessed. Oil POs move litres into oil_stock_movements instead of onto a machine; stock is the sum of movements so the first usage is a subtraction from history. Operators (19) and Mechanics (2) Both are people with an employee number, separately reconcilable by the seed so someone added in the app survives. Mechanics get a badge QR; a mechanic with no employee number cannot be printed, because the code is what the QR encodes. Cross-cutting - The seed no longer deletes operators, mechanics or oil items: people are added in the app, and those rows exist in no file. - Re-pointing allocations that PocketBase clears because `assets` is rebuilt with new ids each seed (operators, purchase lines). - One QR encoder for screen and PDF, so a printed code cannot drift from the one on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them. - Every figure is null-safe: unknown is never rendered as zero. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-08 23:06:27 +00:00
// 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;
}