Invoices: one line per charge, and fold an abbreviated site
PHI 179 was refused: "Machine lines total R5265 but the invoice subtotal is R13730". Three separate faults, each of which only this invoice exposed. 1. The parser read ONE line per machine. A machine hired over more than one period is printed as one heading followed by a repeating block per charge, and PHI 179 is a single CT01 crane truck billed on 4, 16 and 18 Sep at R5 265 + R3 200 + R5 265. The parser only ever looked at the first block, so two thirds of the invoice went missing and the arithmetic check — which did its job — rejected it. extractLines() now walks y-banded rows and emits one line per money row, all carrying the machine's code. A machine billed once still yields exactly one line, which is what PHI 168/169 do. The banding matters: pdfjs splits one printed row across two y positions (the TOTAL sits ~2pt above the rest), so the fixed +-12pt window the old code used was never a row, it was a coincidence. 2. siteFromDescription required the description to START with "Hire Plant" or "Plant Hire". PHI 179 reads "Crane Truck Hire Sep 2026 Cosmo" — it leads with the machine type — so the regex returned "" and the invoice linked to no site at all. The month/year pair is the part every description shares, so that is the anchor now. 3. Even read correctly the site is "Cosmo", where every other invoice says "Cosmo City". Sites are DERIVED from the invoice wording, so saving the abbreviation would create a second "Cosmo" and split this customer across two rows on /sites. resolveSiteName() folds a shorter whole-word prefix onto a site already in the register, one direction only, so a genuinely new site is still recorded as new. Also: a problem invoice stored its PDF with no way to open it. The inbox reported the failure but not the document behind it, and /api/invoices/:id/ document only serves imported invoices — the one case you most want to inspect. New GET /api/invoices/intake/:id/document, plus a PDF link on each inbox row. Verified: PHI 179 reconciles in full (R13 730 + R2 059.50 VAT = R15 789.50, three lines, each balancing). PHI 168 and PHI 169 still reproduce their hand extraction field for field, including line count. 40 tests, typecheck clean.
This commit is contained in:
parent
afa95a9389
commit
ad4ac9173b
6 changed files with 362 additions and 46 deletions
62
app/api/invoices/intake/[id]/document/route.ts
Normal file
62
app/api/invoices/intake/[id]/document/route.ts
Normal file
|
|
@ -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/<filename>` 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -402,6 +402,16 @@ export function ProblemInbox() {
|
|||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<a
|
||||
href={`/api/invoices/intake/${it.id}/document`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`Open ${it.file_name} in a new tab`}
|
||||
className="inline-flex items-center gap-1.5 min-h-[44px] px-3 rounded-lg text-sm font-medium text-[#003D5B] dark:text-white hover:bg-gray-100 dark:hover:bg-white/5 transition"
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
PDF
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => retry(it.id)}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<ParsedInvoice> {
|
||||
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<ParsedInvoice>
|
|||
|
||||
// ---- 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 -----------------------------------------------------------
|
||||
|
|
|
|||
31
lib/sites.ts
31
lib/sites.ts
|
|
@ -232,4 +232,33 @@ export function findSiteContainer(
|
|||
Boolean(s.container_asset),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue