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>
Constrains the implementer away from the two failure modes that have
already bitten this work: writing to production PocketBase, and guessing
when the plan does not match the code. Also states plainly that a wrong
product match costs money and a block does not, since Task 6 is where
that trade-off lives.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The script read dn_batch_notes.date, which does not exist on that
collection — the backfill would have silently set nothing while
reporting success. My plan specified the wrong field.
Now sources from delivery_notes.date_received via the batch link (11 of
34), falling back to the scanner-generated filename date (17), leaving 6
empty rather than inventing one. Dry-run by default, and it reports the
source per batch so the numbers can be checked before writing.
Applied: 17 existing fields preserved, uploaded_at added, 28 of 34
batches backfilled. -uploaded_at now sorts newest first.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Making mass_unit required breaks typecheck in three test files the task's
file list did not name. Anyone re-running Task 3 in isolation hits the
same failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Twelve tasks against the nine defects the first production run exposed.
Task 6 is the core: a three-outcome PO item matcher (matched / ambiguous /
none) with a dimension gate, where the set of integers >= 100 must match
exactly. Two naive relaxations tested during design both produced wrong
matches -- U PIPES-600 resolving to S&S Pipes-1050, Kabelflex to uPVC Pipe
-- and a wrong match applies the wrong rate to money. The task ships a
regression test asserting no known input returns a wrong match, and the
plan tells the implementer not to move the thresholds without extending
that table first.
Task 7 adds the alias table, keyed on material_code so a human decision
carries onto the next purchase order. That is what makes wording like
"Kabelflex Pipe & Coupling" -> "160mm Sleeves Black" tractable at all.
Task 11 deliberately narrows the original page-accounting guarantee: a
stray page becomes a dismissable open question instead of failing the
batch, because batch 5qzcv6tvgb6cqks promoted 3 of 3 notes correctly and
still read as a failure over a tax invoice cover sheet. The guarantee that
every detected note must promote is unchanged.
Task 12 re-runs all 34 batches and audits every ledger link, so a matcher
regression cannot land silently.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ran the pipeline over 34 real documents: 7 processed, 14 blocked, 13 never
picked up. 13 of the 14 blocks were our own bugs.
Root causes, measured from dn_batches.checks:
- 5 batches are tax invoices or collection notes, which carry no delivery
note number. The pipeline was built assuming every document has one.
- 4 blocked on product_matched; the matcher requires the PO line's first
token to be 3+ chars, so "IJ Pipes 450x2.5 100D" never matches
"IJ PIPES-450 X 2.5(100D)".
- 2 on weighbridge_arithmetic comparing nett in kg against quantity in tons.
- 2 on a single stray page condemning notes that promoted correctly.
Critically, two candidate relaxations of the matcher were tested against the
real descriptions and BOTH produced wrong matches (U PIPES-600 to S&S
Pipes-1050; Kabelflex to uPVC Pipe). A wrong match applies the wrong rate to
money, so the fix is a three-outcome matcher -- matched / ambiguous / none --
with a dimension gate, plus a learned alias table for wording that can never
match textually. The specified algorithm was verified against all 23 failing
descriptions with zero wrong matches.
Also found: dn_batches.created is never populated, so board ordering is
arbitrary; and the tick has never claimed a batch, so the n8n schedule is
not running.
Co-Authored-By: Claude Opus 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.
The earlier commit made the script include these fields for from-scratch
creates, but didn't repair the existing live dn_batch_notes collection.
The repair path (add_fields) covers that case so re-running the script
against an already-deployed PB collection also adds them.
The dn_batches and dn_batch_notes collections were created without
PB's standard 'created' / 'updated' date fields, which broke any
query that sorted on them (the tick route, the inbox page).
Live PB was patched inline by the Task 16 verification; this commit
makes the script that creates these collections include the fields
from the start so a fresh deployment doesn't trip on the same bug.
Fixes two bugs in delivery-note processing:
1. 1000x unit conversion error: slips quoted in kg but PO lines priced per ton.
2. Off-by-one that paired delivery lines to the wrong PO items.
All 8 plan tasks complete: units module, dn-item-row builder, route rewire,
dual-unit UI rendering, backfill migration (56/57 rows patched), and operational
cleanup. Final state: PO MEGCPO9165 line bxqbo8rs04pjqwc reconciles to
133.5 ton / R22,494.75 across 4 notes (not 1 515% or R10.9 m).
See docs/superpowers/plans/2026-09-17-delivery-note-unit-correctness.md.
Seventeen tasks covering the batch collections, the pure segmentation and
check modules, page rendering via poppler, per-page extraction, promotion,
the orchestrator, the tick route, the Kanban board and the batch detail view
with note correction.
The design point: pages are read independently and grouped into notes by the
delivery note numbers printed on them, so a note spanning three pages is one
note and a page belonging to no note blocks the batch. segmentPages throws
rather than return a segmentation that loses a page, and runBatch cannot
reach "processed" unless notes_promoted equals notes_detected -- the check
whose absence let two notes vanish from scan_20260915 while the pipeline
reported success at 95% confidence.
Task 16 step 7 is the regression test for exactly that: force a page read to
fail and confirm the batch lands in needs_attention rather than processed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rewrites qty_received into the purchase order line's unit, recovering the
slip's own unit from the condition_notes format the OCR callback wrote.
Rows with no recorded unit are left untouched and reported.
Fetches the PO item unit and delegates row construction to
buildDeliveryNoteItemRows, which also removes the matched-id off-by-one
that linked lines to the wrong PO item after a zero-quantity line.
Filters once so matched PO item ids cannot drift when a zero-quantity
line is dropped, and converts the slip quantity into the PO line's unit.
An unconvertible line contributes zero rather than entering the maths at
the wrong magnitude.