Commit graph

4 commits

Author SHA1 Message Date
David
ab89113489 Test: a site typed without its space is deliberately NOT folded
PHI 180 prints "Westpark" where the register says "West Park", so a
spacing-insensitive fold looks like an obvious typo fix. Tried it, and it is wrong:
the register ALSO carries both "Autumns Gate" and "Autumnsgate", and no rule can
tell a missing space from two genuinely different places — it merged that pair.

Reverted lib/sites.ts to its previous conservative behaviour and pinned the
decision with a test. PHI 180's stored name was corrected to "West Park" by hand
instead. Merging the two Autumn sites is the client's call, not a side effect of a
re-import.
2026-10-08 19:06:30 +02:00
David
ad4ac9173b 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.
2026-10-06 11:25:10 +02:00
David
293276ccce Sites: auto-allocate container lines to the container the site hires
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>
2026-10-02 18:32:12 +02:00
David
5caa923375 Invoices: the app ingests PDFs itself, with a problem-invoice inbox
Extraction was previously manual: an agent read each PDF, mapped the columns
by hand and wrote data/source/invoice-*.json. That was the only way anything
got in, and the upload button just filed the document without reading it.

That supplier's report is one fixed template — the columns sit at identical
x-positions on every invoice, and only the line count and page count vary —
so the figures are now read by coordinate instead. Deliberately not an AI
extractor: this input does not need one, and a deterministic parse that fails
loudly is worth more here than a flexible one that can hallucinate a total.

lib/invoice-parser.ts reads the header, the machine lines across all pages,
the "Plant Hire Total" row and the SUB TOTAL / Escalation / PLUS VAT /
INVOICE TOTAL block, then applies the same arithmetic gates the seed does:
lines must sum to the subtotal, each line must satisfy qty x rate = amount
and amount + VAT = total, and VAT must match the stated rate. Anything that
does not reconcile is refused with the reason — never partially imported.

Validated by parsing all five existing invoices and comparing every field
against the hand-extracted JSON: identical, including the R24,205.13-VAT
column that a plain text read gets wrong. PHI 169 was then deleted and
re-ingested through the HTTP endpoint alone and came back field-for-field
identical, with no agent involved.

- "Add Invoice" drops a PDF in and imports it; lines are matched to plant
  items by the code the invoice prints, and an unmatched line (the
  container codes MPLANT04/MPLANT05) is left unassigned rather than guessed.
- A "Problem invoices" inbox lists every PDF that failed, with the reason,
  and Retry re-reads the stored document and re-runs the parse. Retry
  re-reports rather than claiming success, and only files imported data.
- invoice_intake collection records each attempt.

pdfjs-dist is in serverExternalPackages: bundled, it rewrites its own lazy
worker import to a .next path absent from the standalone image and every
parse dies with "Setting up fake worker failed". The parser also copies the
buffer before handing it over, since pdfjs transfers and detaches it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 14:30:17 +02:00