diff --git a/app/api/invoices/intake/[id]/document/route.ts b/app/api/invoices/intake/[id]/document/route.ts
new file mode 100644
index 0000000..82d9a95
--- /dev/null
+++ b/app/api/invoices/intake/[id]/document/route.ts
@@ -0,0 +1,62 @@
+import { serverAuth } from "@/lib/pb";
+
+export const dynamic = "force-dynamic";
+
+// GET /api/invoices/intake/:id/document
+// Streams the PDF a FAILED intake kept.
+//
+// A problem invoice is exactly the one you most need to look at — it was refused
+// because something about it did not reconcile — and before this route its PDF was
+// stored on `invoice_intake.source_pdf` with no way to open it. /api/invoices/:id/
+// document only serves the `invoices` collection, which a failed import never reaches,
+// so the inbox could report a problem and still not show the document behind it.
+//
+// Same reasoning as that route: do not link at PocketBase directly. The file is
+// protected, so a bare `/api/files/` 404s in the browser. Fetch it
+// server-side with the admin credentials and proxy.
+
+export async function GET(
+ _req: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const { id } = await params;
+
+ try {
+ const pb = await serverAuth();
+ const intake = await pb.collection("invoice_intake").getOne(id);
+
+ const token = intake.source_pdf;
+ if (!token) {
+ return Response.json({ ok: false, error: "No document attached" }, { status: 404 });
+ }
+
+ // PocketBase resolves protected files by the full collectionId/recordId/filename
+ // path, so let the SDK build the URL from THIS record rather than hand-writing one.
+ const upstream = pb.files.getURL(intake, token);
+
+ const res = await fetch(upstream, {
+ headers: pb.authStore.token ? { Authorization: pb.authStore.token } : undefined,
+ });
+ if (!res.ok) {
+ return Response.json(
+ { ok: false, error: "Document could not be read from storage" },
+ { status: 502 },
+ );
+ }
+
+ const name = String(intake.file_name || "invoice.pdf").replace(/["\\]/g, "_");
+ return new Response(res.body, {
+ status: 200,
+ headers: {
+ "Content-Type": res.headers.get("Content-Type") ?? "application/pdf",
+ "Content-Disposition": `inline; filename="${name}"`,
+ "Cache-Control": "private, max-age=0, must-revalidate",
+ },
+ });
+ } catch (e: any) {
+ return Response.json(
+ { ok: false, error: e?.message || "Could not load document" },
+ { status: 500 },
+ );
+ }
+}
\ No newline at end of file
diff --git a/app/api/invoices/intake/route.ts b/app/api/invoices/intake/route.ts
index b7a0a3d..adcd068 100644
--- a/app/api/invoices/intake/route.ts
+++ b/app/api/invoices/intake/route.ts
@@ -1,7 +1,7 @@
import { revalidatePath } from "next/cache";
import { serverAuth } from "@/lib/pb";
import { parseInvoicePdf, ParseError, registerCodeFrom } from "@/lib/invoice-parser";
-import { syncSitesFromInvoices, findSiteContainer } from "@/lib/sites";
+import { syncSitesFromInvoices, findSiteContainer, resolveSiteName } from "@/lib/sites";
export const dynamic = "force-dynamic";
@@ -124,6 +124,15 @@ export async function POST(req: Request) {
);
}
+ // The supplier abbreviates the site on the invoice ("…Sep 2026 Cosmo" for the site the
+ // register calls "Cosmo City"). Resolve it BEFORE writing, because sites are derived
+ // from the invoice wording — saving the abbreviation would create a second "Cosmo" and
+ // split this customer across two sites on /sites.
+ parsed.site = resolveSiteName(
+ parsed.site,
+ [...new Set(allInvoices.map((i: any) => i.site).filter((s: unknown): s is string => typeof s === "string" && !!s.trim()))],
+ );
+
const periodEnd = new Date(parsed.period_to);
const bookedMonth = periodEnd.getUTCMonth() + 1;
const bookedYear = periodEnd.getUTCFullYear();
diff --git a/components/InvoiceIntake.tsx b/components/InvoiceIntake.tsx
index 657a776..57c8b64 100644
--- a/components/InvoiceIntake.tsx
+++ b/components/InvoiceIntake.tsx
@@ -402,6 +402,16 @@ export function ProblemInbox() {
+
+
+ PDF
+
retry(it.id)}
diff --git a/lib/invoice-parser.test.ts b/lib/invoice-parser.test.ts
index b7f64f0..9430508 100644
--- a/lib/invoice-parser.test.ts
+++ b/lib/invoice-parser.test.ts
@@ -10,7 +10,7 @@ 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";
+import { parseInvoicePdf, registerCodeFrom, siteFromDescription, isoDate, extractLines, ParseError } from "./invoice-parser";
// --- unit level: the pieces that do not need a PDF -------------------------------
@@ -30,6 +30,10 @@ test("site is read from the invoice description", () => {
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"), "");
+ // The wording BEFORE the date is not fixed: PHI 179 leads with the machine type.
+ // Anchoring on "Hire Plant"/"Plant Hire" returned "" and left the invoice unlinked.
+ assert.equal(siteFromDescription("Crane Truck Hire Sep 2026 Cosmo"), "Cosmo");
+ assert.equal(siteFromDescription("Boom Lift Hire October 2026 The Hills"), "The Hills");
});
test("dates accept the report's written formats", () => {
@@ -38,6 +42,100 @@ test("dates accept the report's written formats", () => {
assert.throws(() => isoDate("not a date"), ParseError);
});
+// --- the line table: one line per CHARGE -------------------------------------
+//
+// PHI 179 is a single CT01 crane truck hired on 4 Sep, 16 Sep and 18 Sep. The report
+// prints one machine heading followed by a repeating block per charge. The parser used
+// to read exactly one block per machine, so it returned R5 265 against a R13 730
+// subtotal and the invoice was refused — two thirds of a real invoice went missing
+// because it had been hired over more than one period.
+//
+// These are the REAL word positions from that PDF, including the detail that pdfjs
+// splits one printed row across two y positions (the TOTAL sits ~2pt above the rest).
+
+const w = (x: number, y: number, str: string) => ({ x, y, str });
+
+const PHI179_LINES = [
+ w(29.6, 441, "CT01-Crane Truck ISUZU FREIGHTER 1800"),
+
+ // charge 1 — 4 Sep, dry rate
+ w(29.6, 457, "SEP"), w(68.7, 457, "04-Sep-2026"), w(119.4, 457, "-"), w(546.8, 457, "6,054.75"),
+ w(142.2, 458, "04-Sep-2026"),
+ w(211.5, 459, "1.00"), w(255.6, 459, "-"), w(278.5, 459, "8.00"), w(306.7, 459, "100.00"),
+ w(357.8, 459, "5,265.00"), w(426.6, 459, "-"), w(459.0, 459, "0.00"), w(500.2, 459, "789.75"),
+ w(313.7, 485, "Rate"), w(359.3, 485, "Amount"),
+ w(159.8, 486, "Description"), w(234.4, 486, "Qty"), w(270.4, 486, "Unit"), w(395.5, 486, "Activity"),
+ w(159.8, 498, "Dry Rate"), w(233.9, 498, "1.00"), w(273.1, 498, "Day"),
+ w(300.8, 498, "5,265.00"), w(357.8, 498, "5,265.00"), w(395.5, 498, "0001"),
+
+ // charge 2 — 16 Sep, idle time
+ w(29.6, 511, "SEP"), w(68.6, 511, "16-Sep-2026"), w(119.3, 511, "-"), w(546.8, 511, "3,680.00"),
+ w(142.1, 512, "16-Sep-2026"),
+ w(211.5, 513, "1.00"), w(255.6, 513, "-"), w(278.5, 513, "8.00"), w(306.7, 513, "100.00"),
+ w(357.8, 513, "3,200.00"), w(426.6, 513, "-"), w(459.0, 513, "0.00"), w(500.2, 513, "480.00"),
+ w(159.8, 540, "Description"), w(234.4, 540, "Qty"), w(270.4, 540, "Unit"),
+ w(313.7, 540, "Rate"), w(359.3, 540, "Amount"), w(395.5, 540, "Activity"),
+ w(159.8, 553, "Idle Time"), w(233.9, 553, "1.00"), w(273.1, 553, "Day"),
+ w(300.8, 553, "3,200.00"), w(357.8, 553, "3,200.00"), w(395.5, 553, "0001"),
+
+ // charge 3 — 18 Sep, dry rate
+ w(29.6, 566, "SEP"), w(68.6, 566, "18-Sep-2026"), w(119.3, 566, "-"), w(546.8, 566, "6,054.75"),
+ w(142.1, 567, "18-Sep-2026"),
+ w(211.5, 567, "1.00"), w(255.6, 567, "-"), w(278.5, 567, "8.00"), w(306.7, 567, "100.00"),
+ w(357.8, 567, "5,265.00"), w(426.6, 567, "-"), w(459.0, 567, "0.00"), w(500.2, 567, "789.75"),
+ w(313.7, 594, "Rate"), w(359.3, 594, "Amount"),
+ w(159.8, 595, "Description"), w(234.4, 595, "Qty"), w(270.4, 595, "Unit"), w(395.5, 595, "Activity"),
+ w(159.8, 607, "Dry Rate"), w(233.9, 607, "1.00"), w(273.1, 607, "Day"),
+ w(300.8, 607, "5,265.00"), w(357.8, 607, "5,265.00"), w(395.5, 607, "0001"),
+
+ // the grand total — it is a money row too, and must not become a fourth line
+ w(245.5, 628, "Plant Hire Total"),
+ w(354.9, 628, "13,730.00"), w(420.7, 628, "0.00"), w(462.0, 628, "0.00"),
+ w(494.9, 628, "2,059.50"), w(543.9, 628, "15,789.50"),
+];
+
+test("a machine hired over several periods yields one line per charge", () => {
+ const lines = extractLines(PHI179_LINES);
+ assert.equal(lines.length, 3, "three charges, so three lines — not one per machine");
+ assert.deepEqual(lines.map((l) => l.amount_excl), [5265, 3200, 5265]);
+ assert.deepEqual(lines.map((l) => l.vat), [789.75, 480, 789.75]);
+ assert.deepEqual(lines.map((l) => l.total_incl), [6054.75, 3680, 6054.75]);
+ // All three belong to the same plant item, and each must reconcile on its own.
+ assert.deepEqual(lines.map((l) => l.machine_code), ["CT01", "CT01", "CT01"]);
+ for (const l of lines) {
+ assert.equal(l.qty * l.rate, l.amount_excl, `${l.rate_type}: qty x rate != amount`);
+ assert.ok(Math.abs(l.amount_excl + l.vat - l.total_incl) < 0.01, "line does not balance");
+ }
+});
+
+test("the grand total row is never emitted as a line", () => {
+ // It carries a figure in HIRE, VAT and TOTAL exactly like a charge does, so only its
+ // position after the machine block keeps it out of the invoice.
+ const withTotal = PHI179_LINES.filter(
+ (x) => !["Plant Hire Total", "13,730.00", "2,059.50", "15,789.50"].includes(x.str),
+ );
+ assert.equal(extractLines(withTotal).length, 3);
+ assert.equal(extractLines(PHI179_LINES).length, 3);
+});
+
+test("a machine billed once still yields exactly one line", () => {
+ // The single-period invoices (PHI 166-171) all have one charge per machine, and that
+ // behaviour must not change.
+ const single = PHI179_LINES.filter((x) => x.y >= 441 && x.y <= 498);
+ const lines = extractLines(single);
+ assert.equal(lines.length, 1);
+ assert.equal(lines[0].amount_excl, 5265);
+ assert.equal(lines[0].rate_type, "Dry Rate");
+});
+
+test("the activity header is not mistaken for the activity row", () => {
+ // "Description Qty Unit Rate Amount" has no NUMBER in the Qty column; the rate type
+ // comes from the row below it, so a wrong pick yields an empty label, not a wrong one.
+ const [first] = extractLines(PHI179_LINES);
+ assert.equal(first.rate_type, "Dry Rate");
+ assert.equal(first.unit, "Day");
+});
+
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);
@@ -133,7 +231,7 @@ test("the parser never returns an invoice whose totals do not reconcile", { skip
// 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";
+import { findSiteContainer, resolveSiteName } from "./sites";
const site = (name: string, code: string, asset: string) =>
({ id: name, name, container_code: code, container_asset: asset }) as any;
@@ -157,3 +255,28 @@ test("no allocation when the site has no matching container", () => {
assert.equal(findSiteContainer(sites, "Elsewhere", "WT02"), null, "different site");
assert.equal(findSiteContainer([], "Pitsana", "WT02"), null, "no sites at all");
});
+
+test("an abbreviated site folds onto the name the register already uses", () => {
+ // PHI 179 prints "Crane Truck Hire Sep 2026 Cosmo" for the site every other invoice
+ // calls "Cosmo City". Sites are derived from the invoice wording, so taking it
+ // literally would create a SECOND "Cosmo" and split the customer across two rows.
+ const known = ["West Park", "Halfway Gardens", "Cosmo City"];
+ assert.equal(resolveSiteName("Cosmo", known), "Cosmo City");
+ assert.equal(resolveSiteName("Cosmo City", known), "Cosmo City");
+ assert.equal(resolveSiteName("cosmo city", known), "Cosmo City");
+ assert.equal(resolveSiteName("West Park", known), "West Park");
+});
+
+test("folding never invents a site or splits a different one", () => {
+ const known = ["Cosmo City", "West Park"];
+ // No match at all — a genuinely new site keeps the invoice's own wording.
+ assert.equal(resolveSiteName("Rooihuiskraal", known), "Rooihuiskraal");
+ assert.equal(resolveSiteName("", known), "");
+ assert.equal(resolveSiteName("Cosmo", []), "Cosmo");
+ // A partial word must not match — "Cos" is not a shorter name for "Cosmo City".
+ assert.equal(resolveSiteName("Cos", known), "Cos");
+ // Folding runs one way only. An invoice that names a LONGER place is not an
+ // abbreviation of the shorter one, and merging the two would lose a real site.
+ assert.equal(resolveSiteName("Cosmo City Extension", known), "Cosmo City Extension");
+ assert.equal(resolveSiteName("Cosmo City North", known), "Cosmo City North");
+});
diff --git a/lib/invoice-parser.ts b/lib/invoice-parser.ts
index 88d4647..1e2be12 100644
--- a/lib/invoice-parser.ts
+++ b/lib/invoice-parser.ts
@@ -141,9 +141,20 @@ export function registerCodeFrom(invoiceCode: string): string {
return m ? `${m[1].toUpperCase()}${m[2]}` : "";
}
-// "Hire Plant Sep 2026 Rooihuiskraal" / "Plant Hire SEP 2026 West Park" -> the site.
+// The site is whatever follows the month and year in the description:
+//
+// "Hire Plant Sep 2026 Cosmo City" -> Cosmo City
+// "Plant Hire Sep 2026 West Park" -> West Park
+// "Crane Truck Hire Sep 2026 Cosmo" -> Cosmo
+//
+// The wording BEFORE the date is not fixed — PHI 179 leads with the machine type, not
+// with "Hire Plant" — so anchoring on those two phrases silently returned "" and left the
+// invoice unlinked to any site. The month/year pair is the one part every description
+// shares. A description with no month and year is not an invoice description, so it
+// yields "" rather than a guess.
export function siteFromDescription(desc: string): string {
- const m = /^(?:hire\s+plant|plant\s+hire)\s+\S+\s+\d{4}\s+(.+)$/i.exec(desc.trim());
+ const m =
+ /(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\s+\d{4}\s+(.+)$/i.exec(desc.trim());
return m ? m[1].trim() : "";
}
@@ -201,6 +212,117 @@ async function loadPdfjs() {
return pdfjs;
}
+type Row = { y: number; words: Word[] };
+
+// pdfjs splits ONE printed table row across two or three y positions — on PHI 179 the
+// TOTAL sits ~2pt above the rest of its own row — so a "row" has to be a y-BAND, not a
+// y. 4pt is the tightest grouping that still never merges two printed rows: the report
+// repeats its charge block every ~54pt and its lines are ~13pt apart at the closest.
+function clusterRows(words: Word[]): Row[] {
+ const rows: Row[] = [];
+ for (const w of [...words].sort((a, b) => a.y - b.y || a.x - b.x)) {
+ const last = rows[rows.length - 1];
+ if (last && 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;
+}
+
+// Left-column words that are NOT machine rows: the template's own labels.
+const NOT_A_MACHINE = /^(Return|Sep|Dry|Idle|Rib|Ref|Printed|Description|External)$/i;
+
+const machineWordOf = (row: Row): Word | undefined =>
+ row.words.find(
+ (w) =>
+ w.x < 45 &&
+ /[A-Za-z]/.test(w.str) &&
+ !NOT_A_MACHINE.test(w.str) &&
+ !/\bNo\.$/.test(w.str) &&
+ /[A-Za-z]{2,8}-?[0-9]{2,3}/.test(w.str),
+ );
+
+const isGrandTotalRow = (row: Row) => row.words.some((w) => w.str.startsWith("Plant Hire Total"));
+
+// A money row is one carrying a figure in HIRE, VAT and TOTAL.
+function moneyOf(row: Row): { hire: number; vat: number; total: number } | null {
+ const hire = num(row.words.find((w) => inBand(w.x, COL.HIRE))?.str);
+ const vat = num(row.words.find((w) => inBand(w.x, COL.VAT))?.str);
+ const total = num(row.words.find((w) => inBand(w.x, COL.TOTAL))?.str);
+ return hire == null || vat == null || total == null ? null : { hire, vat, total };
+}
+
+/**
+ * The activity sub-row belonging to a money row: the next row carrying a NUMBER in the
+ * Qty column. The "Description Qty Unit Rate" header printed between the two has no
+ * number there, so it is skipped by shape rather than by a fixed pixel offset.
+ */
+function activityAfter(rows: Row[], from: number): Word[] {
+ for (let i = from + 1; i < rows.length && i <= from + 4; i++) {
+ // The next charge begins — this row had no activity sub-row of its own.
+ if (moneyOf(rows[i])) return [];
+ if (num(rows[i].words.find((w) => inBand(w.x, ACT.QTY))?.str) != null) return rows[i].words;
+ }
+ return [];
+}
+
+/**
+ * One invoice line per CHARGE, not per machine.
+ *
+ * A machine hired over more than one period is printed as a single machine heading
+ * followed by a repeating block per charge. PHI 179 is one CT01 crane truck billed on
+ * 4 Sep, 16 Sep and 18 Sep, at R5 265 + R3 200 + R5 265 = R13 730. Reading only the
+ * first block per machine produced R5 265 against a R13 730 subtotal and the invoice
+ * was refused as a problem invoice. Every money row therefore yields its own line,
+ * carrying the same code as its machine, so the line count follows the CHARGES rather
+ * than the machines — and a machine billed once still yields exactly one line, which is
+ * what the single-period invoices all do.
+ */
+export function extractLines(words: Word[]): ParsedLine[] {
+ const rows = clusterRows(words);
+ const out: ParsedLine[] = [];
+ const machineIdxs = rows.map((r, i) => (machineWordOf(r) ? i : -1)).filter((i) => i >= 0);
+ const grandTotal = rows.findIndex(isGrandTotalRow);
+
+ machineIdxs.forEach((m, n) => {
+ const mw = machineWordOf(rows[m])!;
+ const next = machineIdxs[n + 1] ?? rows.length;
+ // The grand total is itself a money row, so each machine block is cut short at it.
+ const stop = grandTotal > m && grandTotal < next ? grandTotal : next;
+ const { code, description } = splitMachineRow(mw.str);
+
+ for (let i = m + 1; i < stop; i++) {
+ const money = moneyOf(rows[i]);
+ if (!money) continue;
+
+ const act = activityAfter(rows, i);
+ const qty = num(act.find((w) => inBand(w.x, ACT.QTY))?.str) ?? 0;
+ const unit = act.find((w) => inBand(w.x, ACT.UNIT))?.str ?? "";
+ const rate = num(act.find((w) => inBand(w.x, ACT.RATE))?.str) ?? 0;
+ const label = act
+ .filter((w) => w.x < ACT.QTY[0])
+ .map((w) => w.str)
+ .join(" ")
+ .replace(/\s+/g, " ")
+ .trim();
+
+ out.push({
+ machine_code: code,
+ description,
+ qty,
+ unit,
+ rate,
+ rate_type: label,
+ amount_excl: r2(money.hire),
+ vat: r2(money.vat),
+ total_incl: r2(money.total),
+ });
+ }
+ });
+
+ return out;
+}
+
export async function parseInvoicePdf(bytes: Uint8Array): Promise {
const pdfjs = await loadPdfjs();
// pdfjs TRANSFERS the buffer it is handed, which detaches it. Callers keep their own
@@ -279,46 +401,7 @@ export async function parseInvoicePdf(bytes: Uint8Array): Promise
// ---- 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),
- });
- }
- }
+ for (const words of pages) lines.push(...extractLines(words));
if (!lines.length) throw new ParseError("No machine lines were found on any page");
// ---- totals -----------------------------------------------------------
diff --git a/lib/sites.ts b/lib/sites.ts
index 2fff7de..0dab618 100644
--- a/lib/sites.ts
+++ b/lib/sites.ts
@@ -232,4 +232,33 @@ export function findSiteContainer(
Boolean(s.container_asset),
) ?? null
);
-}
\ No newline at end of file
+}
+
+/**
+ * Fold the site name an invoice prints onto the name the register already uses.
+ *
+ * The supplier abbreviates: PHI 179 reads "Crane Truck Hire Sep 2026 Cosmo" for the
+ * site every other invoice calls "Cosmo City". Taking that literally would create a
+ * SECOND site called "Cosmo" — `syncSitesFromInvoices` derives sites from the invoice
+ * wording — and the same customer would then split across two places on /sites, with
+ * neither row showing the whole picture.
+ *
+ * Only an unambiguous whole-word match folds, and only in ONE direction: the invoice's
+ * name must be shorter and be a whole-word prefix of a site already in the register.
+ * That is what an abbreviation looks like. The reverse — folding "Cosmo City Extension"
+ * onto "Cosmo City" — would silently merge two genuinely different places, so it is not
+ * done; an unknown name keeps the invoice's own wording and a new site is still recorded
+ * as new.
+ */
+export function resolveSiteName(raw: string, known: string[]): string {
+ const t = (raw || "").trim();
+ if (!t) return "";
+ const low = t.toLowerCase();
+ for (const k of known) {
+ const kk = (k || "").trim().toLowerCase();
+ if (!kk) continue;
+ if (kk === low) return k;
+ if (kk.startsWith(`${low} `)) return k;
+ }
+ return t;
+}