Commit graph

428 commits

Author SHA1 Message Date
DEls
d02feee57f feat(dn-batches): one batch per tick by default
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>
2026-09-17 22:54:32 +02:00
DEls
d4ac4bd6e4 fix(dn-pipeline): key aliases on what actually identifies a product
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>
2026-09-17 22:47:55 +02:00
DEls
ba3dba99ee test(dn-pipeline): use the real slip text, not an invented one
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>
2026-09-17 22:36:48 +02:00
DEls
ba66480adf fix(dn-pipeline): close two wrong-match paths found against live data
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>
2026-09-17 22:32:38 +02:00
DEls
27877fa1bf fix(delivery-notes): a stray page no longer condemns good notes
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>
2026-09-17 21:49:16 +02:00
DEls
5fe618f395 feat(dn-pipeline): classify document type instead of assuming delivery note
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>
2026-09-17 21:45:08 +02:00
DEls
dcde915323 feat(delivery-notes): resolve an ambiguous product line and remember it
Co-Authored-By: MiniMax <noreply@minimax.io>
2026-09-17 21:42:18 +02:00
DEls
525fd38392 feat(dn-pipeline): use the three-outcome matcher and aliases everywhere
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>
2026-09-17 21:40:43 +02:00
DEls
15b8bbdba4 feat(dn-pipeline): add the remembered product alias table
Co-Authored-By: MiniMax <noreply@minimax.io>
2026-09-17 21:34:37 +02:00
DEls
13a6122fa7 feat(dn-pipeline): three-outcome PO item matcher with a dimension gate
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>
2026-09-17 21:33:47 +02:00
DEls
ba6ce2bf2e docs: add the MiniMax handoff brief for DN inbox tasks 6-11
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>
2026-09-17 21:30:16 +02:00
DEls
b4b61083b0 fix(scripts): backfill uploaded_at from real sources, add a dry run
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>
2026-09-17 21:25:33 +02:00
DEls
c32b41506e docs(delivery-notes): record the Task 3 fixture files the plan missed
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>
2026-09-17 21:04:39 +02:00
DEls
305b82bfeb fix(dn-pipeline): a failed note write no longer aborts the batch
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>
2026-09-17 21:04:16 +02:00
DEls
c7b01ab850 refactor(dn-pipeline): enforce note reconciliation without an echo check
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 20:58:51 +02:00
DEls
703cdedc42 fix(dn-pipeline): a PO line with no unit no longer blocks delivery
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>
2026-09-17 20:57:44 +02:00
DEls
e66f29b124 fix(dn-pipeline): convert units before comparing nett to quantity
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>
2026-09-17 20:56:24 +02:00
DEls
06311c6de2 fix(dn-batches): add uploaded_at so ordering works
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>
2026-09-17 20:53:23 +02:00
DEls
b1ce964f39 docs(delivery-notes): add the inbox corrections implementation plan
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>
2026-09-17 20:49:49 +02:00
DEls
1fcd5f0e8e docs(delivery-notes): spec the inbox corrections after the first real run
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>
2026-09-17 20:41:20 +02:00
DEls
a7c116c036 feat(delivery-notes): accept multi-file uploads (up to 50 per request)
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.
2026-09-17 18:46:51 +02:00
DEls
31fbc52c4d chore: 410 the old ocr-result route and archive its workflow export 2026-09-17 15:31:29 +02:00
DEls
49b6547c0c docs(n8n): rewrite for the two-node tick workflow 2026-09-17 15:23:16 +02:00
DEls
6317f5da09 fix(delivery-notes): surface every processing_status in the filter 2026-09-17 15:23:13 +02:00
DEls
ea0299193d fix(dn-batches): heal created/updated fields on existing live collections
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.
2026-09-17 15:17:17 +02:00
DEls
c436422acc fix(dn-batches): include created/updated system fields in collection schema
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.
2026-09-17 15:15:27 +02:00
DEls
826050c48a feat(dn-batches): add re-run, dismiss and note correction actions 2026-09-17 14:54:59 +02:00
DEls
85c1a456d7 feat(delivery-notes): add the batch detail view with note correction 2026-09-17 14:45:47 +02:00
DEls
ddc17b29c1 feat(delivery-notes): add the inbox kanban board 2026-09-17 14:43:18 +02:00
DEls
575352c553 feat(dn-batches): serve rendered document pages 2026-09-17 14:39:43 +02:00
DEls
5e88ebe410 feat(delivery-notes): upload creates an inbox batch, not a delivery note 2026-09-17 14:31:22 +02:00
DEls
ebede76c40 feat(dn-batches): add the tick route with compare-and-set claiming 2026-09-17 14:25:25 +02:00
DEls
540bea8b5d feat(dn-pipeline): orchestrate a batch through extraction, checks and promotion 2026-09-17 14:16:04 +02:00
DEls
3eee3e17e3 feat(dn-pipeline): promote a validated note into delivery_notes 2026-09-17 14:11:33 +02:00
DEls
710146e27c feat(dn-pipeline): extract one page at a time with strict JSON validation 2026-09-17 14:05:12 +02:00
DEls
d4b1425540 feat(dn-pipeline): render document pages and extract per-note page ranges 2026-09-17 14:00:26 +02:00
DEls
bd31f4c634 feat(dn-pipeline): add deterministic per-note and per-batch checks 2026-09-17 13:57:12 +02:00
DEls
5d77ac9047 feat(dn-pipeline): group pages into notes with total page coverage
Every page ends in exactly one note, unassigned, or needs-subsplit, and
the function throws rather than return a segmentation that loses one.
2026-09-17 13:54:30 +02:00
DEls
889f45a6f0 feat(dn-pipeline): add shared pipeline types 2026-09-17 13:52:16 +02:00
DEls
72cdb2fb1b feat(delivery-notes): add dn_batches and dn_batch_notes collections 2026-09-17 13:49:47 +02:00
DEls
667c513de9 build: add poppler-utils and pdf-lib for delivery note page splitting 2026-09-17 13:42:11 +02:00
DEls
aa16422466 Merge fix/delivery-note-units: delivery note unit conversion fix (Phase 1)
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.
2026-09-17 13:05:51 +02:00
DEls
dd0ac47375 docs(delivery-notes): add the phase 2-3 inbox pipeline implementation plan
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>
2026-09-17 12:51:03 +02:00
DEls
6cee861c3e chore: final review polish (trailing newlines, JSDoc, changelog tense) 2026-09-17 12:49:37 +02:00
DEls
8d1b5a6db7 docs(n8n): note the disconnected split branch pending the inbox pipeline 2026-09-17 12:42:44 +02:00
DEls
6e4ed417de chore(delivery-notes): backfill line item units and correct qty_received
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.
2026-09-17 12:22:58 +02:00
DEls
e7882609b1 feat(purchasing): show received quantity in both PO and slip units 2026-09-17 12:19:42 +02:00
DEls
6a9c7403c4 fix(delivery-notes): convert received quantity into the PO line's unit
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.
2026-09-17 12:16:03 +02:00
DEls
401fa51ff9 feat(delivery-notes): build line item rows with unit conversion
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.
2026-09-17 12:11:40 +02:00
DEls
1a30f6a621 feat(delivery-notes): add qty_raw/unit_raw/unit_converted to line items 2026-09-17 12:07:59 +02:00