A container line prints a generic code ("MPLANT05") that the plant register
does not carry, so it had no asset to book to and sat unbooked forever. Each
site now records the container it hires — the code the invoice uses and the
plant item it books to — and a line arriving on an invoice for that site is
allocated to it automatically on import.
The lookup is scoped to the invoice's OWN site, not globally: two sites can
each hire an "MPLANT05" unit, and each line must land on the unit actually at
its own site. Covered by unit tests rather than by deleting a live invoice
and re-importing it.
A code with no asset is refused on save — it would auto-allocate to nothing —
and both or neither is required.
Assigned and booked: Autumns Gate "MPLANT05" -> SC02, The Hills "MPLANT05" ->
SC03, and The Hills' stuck line is now booked and earning. Container lines on
future invoices for those two sites will be booked without anyone touching them.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
159 lines
8.3 KiB
TypeScript
159 lines
8.3 KiB
TypeScript
// 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");
|
|
});
|