Adds a third tab to the clock PWA: point it at a delivery note or
invoice, take the photo, send it. It produces one dn_batches row per
photograph — the same thing the office upload button produces — so
splitting, PO matching, the checks and the board all already handle it.
One photograph per send, which sidesteps the multi-note splitting that
causes most of the trouble with scanned stacks.
Focus is measured, not hoped for. A soft photo does not fail loudly; it
extracts almost right — a 3 that reads as 8, a decimal comma lost — and
those become numbers a supplier gets paid on. Variance of the Laplacian
runs on the live preview four times a second and again on the frame
actually captured, because the shutter frame is not the one measured
250 ms earlier.
The thresholds were read off a real delivery note rather than invented.
The fixture's three pages rasterised at 800 px score 4825/4863/4801 as
scanned, then 322, 88, 41 and 16 through one to five box-blur passes.
Two passes is where the print starts to fight you, three is unreadable,
so the bands sit at 120 and 45. Those are flatbed scans and a handheld
photo will score well below 4800, which is why the bands come from the
degradation ladder and not from the top figure.
One check had to be deleted rather than tuned: "washed out by glare"
fired on every good document. A clean delivery note is 95.2% pure white
and reads 246 mean luminance, so any upper brightness threshold that
catches a blown-out photo also condemns the real thing. Real glare is a
local hotspot, not a global mean, and detecting it needs samples we do
not have. Measured, not assumed — see the test.
The frame is cyan at rest, not the app's orange: a delivery note is warm
white and orange on cream disappears exactly where it needs to be seen.
It turns green when the photo will read and red when it will not, so red
means something instead of being the colour of the frame. A bad photo
can still be sent — the truck does not wait, and a soft record of a real
delivery beats no record of it — the button just stops looking like the
obvious thing to press.
Site comes from geolocation because it has to: badge scanning reads the
site off the employee record and a delivery note has no employee to ask.
The fix proposes and the dropdown disposes; it is never submitted
unseen, because a delivery filed against the wrong site lands on another
site's purchase orders. A site stored at 0,0 is treated as unplaced —
the live collection has one, and null island is a real point in the Gulf
of Guinea that a bad fix would otherwise match.
1023 tests, typecheck clean, production build clean. Not verified in a
browser: the pane cannot reach localhost here (net::ERR_FAILED on every
resource), and it has no camera to drive the capture path with. curl
confirms the route renders and ships both sites with their coordinates.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six of the 37 blocked notes fail on one pair of units. Concrete pipes
are delivered by the pipe and ordered by the metre: the slip reads
"U PIPES-450 X 2.5(100D) | 34 ea", the order line reads
"IJ Pipes 450x2.5 100D | 222.5 m". There is no ea -> m conversion — one
"each" is not a fixed number of metres — so units.ts refused it, which
was the right answer to the wrong question. The missing number is a
property of the product and it is printed on both documents: the 2.5.
The rule only fires when the SLIP and the ORDER independently state the
same length. Two documents written by different people agreeing on 2.5
is the standard the rest of this pipeline already holds to — gross minus
tare equals nett, qty times rate equals amount. One description alone
would not do, because a misread digit would multiply what we pay.
The order corroborates the reading: five of its six pipe lines are exact
multiples of 2.5 m — 222.5, 237.5, 150, 27.5 and 252.5 are 89, 95, 60,
11 and 101 pipes. Those figures are not lengths by coincidence.
units.ts stays the closed table it was built as. It is tried first and
its answer is final when it has one; only a pair it genuinely cannot
express reaches the pack rule, so nothing that converted before converts
differently now. The bore floor (>= 100) and length ceiling (<= 20 m)
keep it off thread pitches and panel dimensions, and a disagreement
between the two documents falls through to the existing failure rather
than picking a side.
Wired through convertLineQty so the check and the ledger cannot diverge:
checks.ts and dn-item-row.ts both call it, for the same reason
resolveNoteLines exists. qty_plausible now judges the converted figure,
which is what makes it bite — 400 pipes is 1000 m against 222.5 ordered
and blocks, where the raw count of 400 would have passed.
Mutation-tested: disabling the fallback fails all three integration
tests (the check, the ledger row, and the plausibility bound).
993 tests, typecheck clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A blurred, torn or missing order number left a note with no route to its
purchase order. The pipeline resolves a PO from the printed number and
nothing else, so the note blocked on po_resolved and stayed there — five
notes are in that state right now, three of them with no order number on
the slip at all. The operator can read the supplier off the letterhead
every time and had no field in which to say so.
A searchable picker already existed but only rendered for old-flow rows,
which no longer occur, and it was handed supplier and projectCode as
empty strings — so the one search an operator actually performs matched
nothing. Both ends are fixed: the picker moves to src/components, and
buildPoChoices loads the supplier and the site's project code from the
records that hold them.
Choosing writes the order's number into po_number_raw, the same field
the free-text editor already edits, so the next re-run resolves it
exactly and the manual choice passes through every check like any other
note. Writing purchase_order directly would not survive: runBatch
recomputes that field from the number on every run.
The picker is also offered on a note that resolved to the WRONG order —
a number misread by one digit passes po_resolved while pointing at
someone else's delivery.
Verified against the live dev server: the inbox batch page now ships 25
purchase orders with real supplier names and project codes, newest
first, where it previously loaded none.
968 tests, typecheck clean. No component tests — this repo has none, and
the logic worth asserting on lives in po-choices.ts, which has eight.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Caused by re-running the 11 re-checkable batches: promoted notes fell
from 25 to 16, and batch ecvx9yojhjb5yje went from 8 promoted to 0.
not_duplicate loaded every delivery_note_number on the ledger, including
the rows the batch's own notes wrote on the previous run. So each note
saw the delivery note it had created and blocked as a duplicate of
itself, while the ledger quietly kept all 8 rows — the board reporting
nothing resolved for deliveries that were already recorded.
loadExistingNoteNumbers now excludes rows stamped with this batch's id.
promoteNote already sets `batch` on every row it writes, so the
exclusion is exact. Duplicates ACROSS batches are unaffected.
I first asserted this on the check context's Set and it failed, because
runBatch deliberately mutates that Set after each promotion to catch
duplicates within one batch — the assertion was reading it post-
mutation. The tests now assert on whether the note promoted, which is
the behaviour that matters either way.
959 tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found while preparing to re-run the 13 blocked batches. Two of them hold
18 verified duplicate dismissals between them, and re-running would have
silently reopened every one — then promoted them, putting the duplicates
straight onto the ledger. Exactly the double count they were dismissed
to prevent.
Two parts:
- upsertCandidate preserves a `dismissed` status instead of writing
candidate/blocked over it. Re-extracting a page tells us nothing
about a decision a person already made.
- runBatch skips promotion for a dismissed note entirely.
upsertCandidate now returns the dismissal as a value rather than the
caller reading `status` back off the PocketBase write response, which
does not echo it — that version looked correct and silently saw
undefined.
Also fixes the run.test.ts PocketBase mock, which had no
delivery_note_items branch since qty_plausible started loading ledger
totals. It was caught and warned rather than failing, so every run
printed "delivered totals load failed" and the check was silently
comparing against nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Seven blocked notes were failing weighbridge_arithmetic with
"0 - 0 = 0, but nett reads 29.224".
They are Vula invoices. An invoice has no weigh section, so the
extractor reports the invoice's printed total mass as nett and zero for
gross and tare. The check skipped only on null, so zero read as a real
measurement and the arithmetic "failed".
No weighbridge ticket weighs a truck at nothing. Zero on either side now
means the same as absent: there is nothing to verify, so the check is
skipped rather than failed. Also covers VCINV073787, which had a gross
of 25795 against a tare of 0 — a partial read, not a wrong weighing.
Re-checking the 54 blocked notes against the current code, weighbridge
failures drop from 7 to 0.
956 tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Corrects an over-correction. The previous change excluded invoices from
"delivered" to stop a note-and-invoice pair double counting. But which
document a supplier sends is the supplier's habit, not a statement about
whether goods arrived: plenty never issue a delivery note at all, and for
those the invoice is the ONLY evidence. Excluding it took R6.2m of orders
down to 0.8% delivered and showed suppliers like Global Precast at 0%
against R51k of invoices they had plainly fulfilled.
Every receipt document now counts, including ones we could not classify —
under-counting a real delivery is worse than the small risk of counting
something odd, and an unclassified document lands in neither evidence
bucket so it stays findable.
The genuine risk is narrower and is now measured rather than guessed at:
`possibleDoubleCount` is the quantity appearing on BOTH a delivery note
and an invoice for the same purchase order line. It is reported, never
silently subtracted, because a supplier can legitimately deliver the same
quantity twice and only a person can tell. Across the live book it is one
line — Building Sand 15.24 tons, about R3 539, the AS0002112 /
IRN010002042 pair.
Fulfilment by value goes from 0.8% to 7.1%. Still low, but for the real
reason: 28 documents against 24 purchase orders, with R3.3m of orders
carrying no paperwork at all yet.
952 tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Encountered two children with the same key, `1 match no PO line`."
Failed-check badges were keyed on the reason text. Reasons are free-form
and several notes on one batch routinely fail the same check with
word-for-word the same wording, so the keys collided.
Also deduplicates those badges at the source. Three identical red badges
saying "1 match no PO line" tell you nothing the "N of M notes" line
above already does; the detail view is where a failure is attributed to
its own note.
Fixes the same latent collision in the batch detail view, where the
product-decision list was keyed on a slip line's description (two lines
on one note can carry the same text) and the check lists were keyed on a
check id that nothing enforces as unique.
Verified by typecheck and the suite; the browser pane could not get past
the app's PIN gate to watch the console warning clear, so the fix rests
on reading the render sites rather than observing them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
delivery_notes holds three document kinds (delivery_note, collection_note,
invoice) distinguished by document_type, but every delivered-quantity
rollup treated all of them as proof goods arrived. A load with both a
weighbridge ticket and an invoice was counted twice — Building Sand
qty 15.24 was double-counted against one PO line via delivery_note
AS0002112 and invoice IRN010002042, reporting 30.48 tons delivered when
15.24 did. 63 of 79 pending notes are invoices, so this scaled badly.
Add src/lib/document-kind.ts (countsAsDelivered / countsAsInvoiced) and
thread a required document_type through po-fulfilment.ts and
supplier-ledger.ts so only delivery_note/collection_note lines feed
`delivered` and `outstanding`; invoice lines are tracked separately as
`invoiced` and never added in. document_type is required, not optional,
so every call site had to be consciously updated to join delivery_notes
and supply it — a missing join now falls into `unclassified` (counted in
neither bucket) rather than silently defaulting to delivered.
Updated call sites: purchase-orders/[poId]/page.tsx,
api/purchase-orders/by-number/[poNumber]/items/route.ts,
lib/agent/load.ts, purchase-orders/page.tsx (inline receivedByItem sum,
not routed through po-fulfilment.ts), suppliers/page.tsx,
suppliers/[supplierId]/page.tsx. po-detail-view.tsx now also shows the
invoiced figure next to delivered when non-zero.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: MiniMax <noreply@minimax.io>
Duplication happens per NOTE, not per batch. The same stack of Vula
invoices was scanned three times in overlapping groupings — 13, 16 and 14
pages with three different file hashes, so upload-time hash dedup could
not see it. Each of those batches mixes notes that promoted, notes
duplicating another batch, and invoices unique to it: vdn074468 alone
carries four that exist nowhere else.
So no batch-level action is correct. Dismissing one wholesale either
discards unique invoices or zeroes a counter for notes already on the
ledger. Adds POST /api/dn-batches/[id]/notes/[noteId]/dismiss, which
stands one note down, refuses to touch a promoted one, and parks the
batch only once nothing is outstanding.
Also fixes the batch dismiss route, which wrote notes_promoted: 0
unconditionally. A batch that had promoted 8 of 13 notes would report
nothing while 8 delivery notes sat in the figures people pay from — the
same board-versus-ledger disagreement already fixed in run.ts. It now
counts what actually reached the ledger and says so in the reason.
929 tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two new checks, both from one real ledger row. VCINV073935 promoted as
auto-verified claiming 11 000 pipes against 27.5 ordered — 400x the whole
purchase order line. Units were right and the product match was right;
nothing asked whether the number was possible.
The source invoice reads, in South African format where the comma is the
decimal separator:
P750-100 IJ PIPES-750 X 2.5(100D) ea 11,0000 1 970,75 21 678,25
qty_plausible compares each matched line, converted into the PO line's
unit and added to what the ledger already holds, against the ordered
quantity. Over 100% passes with the over-delivery named — sites really do
take 110% of an order. Over 300% blocks, because that is a misreading and
not a delivery.
line_arithmetic is the stronger and cheaper of the two: quantity x rate =
amount, needing no purchase order, no matching and no units. It is the
invoice equivalent of gross - tare = nett. 21678.25 / 1970.75 = 11 says
the quantity is eleven without consulting anything.
PageItem gains line_amount, and the extraction prompt now describes the
two number conventions these suppliers mix (space for thousands, comma OR
dot for decimals, last separator wins) and tells the model to settle
ambiguity with amount / rate rather than guessing.
927 tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A tick is fully sequential: one vision call per page, then each note's
checks and promotion in turn. Claiming three batches of a 13-page scan
can approach the route's 300s ceiling, and a tick that times out leaves
its batch to be reclaimed and re-read, paying for those pages twice.
One batch per tick also makes n8n's execution log directly readable —
one run, one document, one outcome. DN_TICK_BATCH_SIZE still overrides
it once the page-count distribution is known.
34 batches at a 1-minute schedule is ~34 minutes rather than ~12.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The alias table shipped keyed on material_code alone. Live data says
that cannot work:
- 16 of 24 purchase orders carry ONE material_code across every line.
MEGCPO9239 is "513095" sixteen times.
- Code "580070" spans 24 different products; "513080" spans 16.
An alias keyed on it would have resolved to every line on the order and
been permanently ambiguous — for Builders, the supplier whose truncated
descriptions need aliases most. I verified every PO item HAD a
material_code and never checked whether it discriminated.
Also found, by reading the source scan: the truncation is the supplier's
printer. Builders' form prints descriptions in a fixed ~20-character
column that wraps twice then stops ("Grip Fibreglass Meas" / "uring Tape
Orange a"). Extraction was faithful; nothing upstream can recover it.
But every line also carries an article number and a barcode, which are
never clipped and which we were discarding.
So:
- PageItem gains product_code, and the prompt asks for the Art No /
SKU / Prod Code column plus an explicit instruction to transcribe a
clipped description verbatim rather than guess its ending.
- Aliases record slip_product_code and po_description_key as well.
Lookup tries product code before description; resolution tries the
PO line description, then material_code ONLY when it narrows to a
single line on that order, then falls through to text matching.
- The resolve-item route no longer rejects a PO line for having no
material_code, which was based on the same wrong assumption.
dn_item_aliases had 0 rows, so there was nothing to migrate.
916 tests. Live regression unchanged: 25 matched, 3 ambiguous, 3 none,
zero wrong; 0 of 24 POs vulnerable to nonsense.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tape test asserted on "Grip Fibreglass Measuring Tape Orange and
Black 10 m". No such string exists in the data — the stored slip line is
truncated at "Grip Fibreglass Measuring Tape Orange a", and the PO line
is 50000 mm, not 5000. I wrote the fixture from a half-remembered
reading of truncated console output instead of from the record.
The underlying wrong match is real and the fix stands: the truncated slip
line carries no dimension while its correct PO line does, so matching
only dimensionless lines billed R129.13 for the R194.87 item. Quantities
confirm the intended pairing one to one (1 and 3 on both sides).
Now asserts on the verbatim stored descriptions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both verified by running the matcher over every slip description in the
34 real batches, not just the ground-truth table.
1. pickByWords short-circuited on a single candidate before scoring, so
a purchase order with one dimensionless line matched ANY description.
MEGCPO9164 has exactly one line and swallowed the string
"Unobtainium". A lone candidate is now decisive only on the dimension
path, where the exact dimension-set match is itself the evidence.
2. "A dimensionless slip line never matches a dimensioned PO line"
discarded the better answer when a slip abbreviates a dimension away:
slip: "Grip Fibreglass Measuring Tape Orange and Black 10 m"
PO: "Grip Fibreglass Measuring Tape Orange and White 5000" R194.87
PO: "Grip Tape Measure Black/Orange 10 m" R129.13
Only the second is dimensionless, so it matched and applied the wrong
rate. When a dimensioned line outscores the dimensionless winner we
now return ambiguous, which is what the three-outcome type is for.
Both were in the plan I wrote, not introduced by the implementer.
After: 31 real descriptions -> 25 matched, 3 ambiguous, 3 none, zero
wrong. Nonsense probe: 0 of 24 POs vulnerable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Batch 5qzcv6tvgb6cqks promoted 3 of 3 notes and still read needs_attention
because page 4 was a tax invoice. Stray pages become open questions that
can be dismissed, and every detected note must still promote.
Co-Authored-By: MiniMax <noreply@minimax.io>
Five of fourteen blocked batches were tax invoices or collection notes,
which have no delivery note number. Also stops reading a composite
reference like '22433700 / 2025344290' as three separate documents.
Co-Authored-By: MiniMax <noreply@minimax.io>
One resolution per line, shared by product_matched, units_convertible and
promote, so a note cannot pass its check and then be linked elsewhere.
Co-Authored-By: MiniMax <noreply@minimax.io>
matched / ambiguous / none. The dimension set must match exactly, so
U PIPES-600 can no longer resolve to S&S Pipes-1050 on shared tokens.
Includes a regression test asserting no known input returns a wrong match.
Co-Authored-By: MiniMax <noreply@minimax.io>
Found while reviewing the tasks 2-5 work.
promoteNote was unguarded, so a write failure on one note threw out of
runBatch. The notes promoted before it stayed in delivery_notes while the
final update -- the one that writes notes_promoted -- never ran, so the
board reported "0 of 5" for a ledger that already held two. The notes
after it were never attempted at all.
Now the failure is caught, attributed to that note as a failing check,
and the remaining notes still process. Verified by mutation: the new test
fails when the catch re-throws.
Also replaces two run.test.ts tests that injected a notes_reconciled
check the real runBatchChecks can no longer emit -- they passed while
describing a world that no longer exists. The allNotesPromoted gate is
documented as belt-and-braces: it cannot currently be the deciding factor,
because a skipped note always coincides with a failing check.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
61 of 148 purchase_order_items have an empty unit. Blocking every
delivery against them rejects 41% of lines over a gap on the order.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Delivery notes weigh in kg while the same supplier's invoices weigh in
tons, so the check was comparing 29980 kg against 30.275 tons. Also stops
failing documents that simply have no weigh section.
Also updates promote.test.ts, run.test.ts and segment.test.ts fixtures
for the new required mass_unit field on PageRead/NoteSegment (not named
in the plan's file list for this task, but required for typecheck).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
PocketBase's created is empty on every dn_batches record, making the
board's -created sort a no-op and the tick's oldest-first meaningless.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Operators can now drop a folder of slips at once instead of uploading
them one at a time. Each file becomes its own inbox batch; the route
processes all of them in one request and returns per-file outcomes
(created / duplicate / error).
Backward compatible: single-file requests still get the legacy
response shape. New multi-file response shape:
201 {
results: Array<{ ok: true, batch_id, ... } |
{ ok: true, duplicate: true, ... } |
{ ok: false, error, filename }>,
summary: { total, created, duplicates, errors }
}
The UI input accepts multiple files; the result panel shows one row
per file with a colour-coded icon.
8 new tests cover: per-batch outcomes, >50 files rejected,
mixed MIME types, byte-identical duplicates within one request,
site lookup short-circuits before per-file work.