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>
Measured all 54 blocked notes first. Only 27 are agent work:
24 not_duplicate the same Vula invoices uploaded three times in
different groupings. Resolving these would
double-count real deliveries.
22 product_matched genuine decisions -> the agent
9 units_convertible pipes sold by the each, ordered by the metre.
The 2.5 in 'IJ Pipes 450x2.5' is the length, so
1 ea = 2.5 m. A conversion rule, not a judgement.
5 po_resolved misread order numbers -> the agent proposes only
The brief leads with the rule that governs it: a wrong product match is
worse than a block, because it applies the wrong rate to a supplier
payment. It names both real wrong matches from this data, ranks the
evidence that is actually decisive (quantity pairing first, word overlap
never), and routes every action through the API so each decision writes
an alias and compounds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Delivery note inbox: unit correctness, the batch pipeline corrections, and
the Kanban board fixes found by running over 34 real documents.
Headline fixes, all verified against live data rather than against the plan:
- Received quantities were 1000x too large: delivery notes weigh in kg
while purchase order lines are priced per ton, and nothing converted.
qty_received is now strictly in the PO line's unit, with qty_raw/unit_raw
preserving what the slip said.
- Two silent wrong-match paths in the PO item matcher closed. A single-line
purchase order matched any description (MEGCPO9164 swallowed the string
"Unobtainium"); and a slip that abbreviates a dimension away billed the
cheaper line (R129.13 for an R194.87 item).
- Tax invoices and collection notes are classified rather than orphaned.
Six batches detected zero notes purely because they carry no
delivery-note number.
- The alias table is keyed on what actually identifies a product. 16 of 24
purchase orders use one material_code for every line, so the shipped key
could never have resolved anything.
- A failed note write no longer aborts its batch, leaving the board
reporting fewer notes than the ledger holds.
- A stray page no longer condemns notes that promoted correctly.
916 tests, typecheck clean. Live regression on all 31 real slip
descriptions: 25 matched, 3 ambiguous, 3 none, zero wrong.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Updated the aliasing mechanism to utilize article numbers and barcodes for product identification, addressing issues with ambiguous material codes. The PageItem now requests the article number alongside the product code, ensuring accurate transcription of truncated descriptions. This change improves the resolution process by prioritizing product codes and refining the lookup strategy to reduce ambiguity in purchase orders.
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>
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>