docs(collection-confirm): AI checks design spec

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-17 01:00:49 +02:00
parent 78ba560096
commit 3624d87801

View file

@ -0,0 +1,397 @@
# Collection Confirm AI checks: read the driver's invoice photo and tick each line
Date: 2026-10-17
Status: design, awaiting approval
## Why
On `/collection-confirm` an operator checks every collected line against
the supplier invoice photo the driver took. They:
- type the invoice number and date into the confirm box;
- fix SKUs, costs and quantities inline;
- then confirm the line to ready-for-invoicing.
All of that is done by eye. The photos are clear, readable supplier tax
invoices and delivery notes. The built-in AI (MiniMax-M3 via the Anthropic
SDK, already reading photos in the quote intake) can read them.
A look at the live queue on 2026-10-17 shows what a reviewer misses today:
- NSS tax invoice 5028256 covers two POs (PO202610003CEM and
PO202610009CAS), but the photo was attached to only one line.
- The invoice lists M8x25 S/S × 40, which the driver signed for, but that
line is still `assigned`, not `collected`.
- Prices are "per 100" on that invoice.
- The order line's quantity fields disagree with what was collected.
- A Bestobell invoice says "ordered 5, to follow 0" while our line
ordered 8 and collected 5.
- The driver uploaded the same photo seven times, sideways.
The owner wants the AI to do the hard work first: read the document, fill
what it can, and tick each check. A person still opens the card, sees
green, reviews it and presses Confirm.
## The rule above all others
**The AI is an optional layer on top of today's page.** With the AI off,
not run, failed, or ignored, Collection Confirm must work exactly as it
does now:
- inline SKU, cost and quantity edits;
- the invoice number and date typed into the confirm box;
- Confirm, Book to stock and Clear.
The AI only:
- pre-fills empty fields, always overwritable by hand;
- adds ticks and a findings box;
- never blocks a confirm.
A human edit always wins, and a re-run never overwrites it.
## Goals
1. For each driver photo on a collected line, the AI reads the document:
- document type (tax invoice / delivery note / not a document);
- document number and date;
- supplier name;
- every PO reference printed on it;
- every line: supplier code, description, quantity, unit price
normalised to per-unit ("per 100" etc.), and line total.
2. It pairs the document lines with our PO lines for every PO the
document names. This includes PO lines that are not on the page yet
(not marked collected).
3. For each collected line on the card it shows four ticks: **SKU,
Qty, Desc, Price**. Green = matches. Amber = close, have a look.
Red = doesn't match or missing. Grey = can't check (e.g. no price
on a delivery note).
4. It fills the line's SKU from the document's supplier code when the
line's SKU is empty, and marks it "filled by AI".
5. It pre-fills the confirm box with the document number and date, so
"everything is typed in already".
6. An **AI findings** box per card lists what it noticed in plain
sentences, e.g.:
- "Invoice 5028256 also covers PO202610003CEM."
- "M8x25 × 40 is on the signed invoice but not marked collected."
- "Supplier says 0 to follow."
7. A **POS** column shows whether each line's item is already in the
POS (`stock_items`), with an **Add to POS** button per line. There
is also a **Confirm and add to POS** button on the card. These are
manual features that work without the AI.
8. A **Check with AI** button per card runs or re-runs the read on
demand.
9. Settings → AI gets a **Collection checks** section: on/off and an
editable prompt with reset-to-default.
## Non-goals (this round)
- Moving surplus quantities to stock, or sending short quantities back
to the collection pool. That's the next round.
- Auto-confirming anything. Every confirm is a person's click.
- Marking lines collected (e.g. the M8x25 case). The AI only reports
it in findings.
- Changing quote or order flows outside Collection Confirm.
- Booking stock quantities into the POS. **Add to POS creates the
catalogue item only**, with a quantity of 0.
## Anti-goals: it fails if…
- The page stops working, or works differently, with the AI off.
- The AI overwrites a SKU, cost or quantity a person entered.
- A wrong pairing shows green. If the AI isn't sure a document line is
the same item, the Desc tick is amber or red, never green.
- The same photo is read seven times (token burn), or an unchanged
photo is re-read on every run.
- An invoice price "per 100" is compared as if it were per unit.
## How it works
### 1. Reading a document (once per distinct file)
- **Sources:**
- customer lines: `order_items.order_item_attachements`;
- stock lines: the parent `stock_purchases.additional_attachments`,
exactly as `listCollectedItems` already surfaces them.
- **Dedupe:** hash each image's bytes (sha256). Identical uploads share
one read.
- **Images only:** jpeg/png/webp/gif, as in `quotes-ai-processor.ts`.
Send a downscaled copy using PB thumbs (e.g. `1600x0`). PDFs are
skipped with a finding, "PDF not read yet".
- **The AI call:** one vision call per file through the existing
`getActiveAiConfig()` + Anthropic SDK pattern, forced tool
`submit_document_read`. It returns:
- `doc_type`: `tax_invoice` | `delivery_note` | `other`;
- `document_no`, `date` (ISO), `supplier_name`;
- `po_refs[]`;
- `lines[]` of `{ code, description, quantity, unit, unit_price_per_unit, line_total, price_basis_note }`;
- `to_follow` hints;
- `notes[]`.
- **What the default prompt must cover:**
- normalise "Net price per 100" to per-unit;
- handle rotated photos;
- never guess unreadable numbers (null instead).
- **Stored in** a new `collection_doc_reads` collection (see Data).
`other` documents, i.e. photos of goods rather than paperwork, are
stored too, so they aren't re-read.
### 2. Pairing and checks (per document)
- **Gathering lines.** Resolve each `po_ref` to `purchase_orders.po_number`
or `stock_purchases.purchase_number`, reusing `normalizePoReference`
from `stock-purchase-invoice-matching-service.ts`. Gather all lines on
those POs, any status. If no ref resolves, use the PO of the line the
photo is attached to.
- **Pairing call.** One text-only AI call (tool `submit_line_pairs`). It
gets:
- the document lines;
- our PO lines (description, PO description, SKU, ordered, collected,
cost, status).
For each document line it returns:
- `po_line_id | null`;
- `same_item`: `sure` | `likely` | `different`;
- a short `reason`.
Our lines must be from the supplied list, the same guard as the quote
assistant.
- **Ticks:** computed in code, not by the AI, for each collected line
paired to a document line.
| Tick | Green | Amber | Red | Grey |
|---|---|---|---|---|
| SKU | the line's SKU equals the document code (normalised), or the AI filled it from the code | the line has a different SKU from the document code | — | the document has no code |
| Qty | document qty = `qty_collected` | equal to collected but collected ≠ ordered (partial or surplus; noted) | document qty ≠ `qty_collected` | — |
| Desc | `same_item = sure` | `likely` | `different` | — |
| Price | document per-unit price = `cost_price` within R0.01 | — | differs | delivery note or no price |
A collected line with no paired document line gets all four red, plus
a finding.
- **Findings** (card level), built in code from the read and the
pairing:
- document covers other POs;
- document lines with no PO line;
- PO lines on the document that are not marked collected;
- collected lines not on the document;
- supplier name differs from the PO's supplier;
- "to follow" quantities;
- quantity fields disagreeing (`quantity` vs `order_qty`).
### 3. What gets written
- **On each collected line:** `ai_check` (JSON). It holds:
- the read id, document number, date and type;
- the paired document line and the AI's `same_item` + reason;
- `sku_filled_value` (the SKU the AI wrote, if any);
- line-level findings;
- `checked_at` and `model`.
- **Ticks are not stored.** They are computed from `ai_check` plus the
row's live values (SKU, `qty_collected`, `cost_price`) by a pure,
client-safe module, `src/lib/collection-ai-ticks.ts`, used by both the
page and the server. A person's inline edit therefore updates the ticks
immediately, with no re-run. The robot icon shows on the SKU only
while the row's SKU still equals `sku_filled_value`.
- **Card-level findings** (document covers other POs, lines not marked
collected, supplier mismatch, to-follow) are stored once on the
`collection_doc_reads` row as `findings` (json). `listCollectedItems`
attaches the findings of every document linked to a card's lines to
that card. A card is keyed by its existing `poNumber` grouping, so the
check route's body is `{ po_number }`.
- **SKU fill:** only when the line's own SKU is empty. Customer lines
write `order_items.sku` via `updateOrderItemSku`. Stock lines write
`stock_purchase_items.sku` via `updateStockPurchaseItem({ sku })`, the
same path as the page's inline pencil (`/api/v1/stock-purchase-items/[id]/sku`).
This keeps audit and notes the same as for a manual edit.
- **"SKU" means the supplier's code throughout** (SKU tick, AI fill,
In POS, Add to POS). In this business the POS already stores supplier
codes as SKUs (e.g. NSS items `SM293306030`, `AMC93118100`), and
`order_items.sku` is what "Check pricing" matches against the price
list. (My call, from the live data.)
- On stock rows, `ConfirmRow.sku` today shows the linked
`stock_item.sku`. The SKU tick compares the document code with the
line's `stock_purchase_items.sku`, falling back to that catalogue SKU.
- **Which quantity is "ordered":** `order_qty` (the PO quantity; on
today's NSS invoice, the M20 line has `order_qty` 4 and `quantity`
40, and the PO total is 4 × R14.51), falling back to `quantity` when
`order_qty` is 0. A disagreement between the two is a finding.
- **Never written by the AI:** `cost_price`, `qty_collected`,
`item_status`, invoice details. The confirm box is only pre-filled
client-side from `ai_check`. The existing confirm flow writes the
invoice details when a person confirms.
- **Audit:** every write runs inside `runWithAuditActor({ name: "AI
collection check", source: "agent", ignoreRequestUser: true })`.
### 4. When it runs
- **Automatically:** an n8n "WFDASH Collection check tick" calls
`POST /api/v1/collection-confirm/ai/tick` (agent auth) every 5
minutes. It reads unread files on currently collected lines, then
pairs and checks. It is bounded per run, with a time budget like the
quote assistant's (90 s, at most 10 files). It does nothing unless
both the global AI and Collection checks are on.
- **On demand:** a **Check with AI** button per card. It calls
`POST /api/v1/collection-confirm/ai/check` (browser session, body
`{ po_number }`) and re-pairs that card. It re-reads a file only if no
read exists for its hash.
- **Re-checks:** a re-run refreshes ticks and findings. It never
overwrites a SKU a person set.
### 5. The page
- Each card gets a document chip:
`Tax invoice 5028256 · 16 Oct 2026 · read from driver photo`.
Clicking it shows the extracted lines next to the photo.
- The items table gets four tick columns plus a POS column.
- Each tick has a tooltip showing what was compared: our value vs
document value. The description tooltip shows both wordings.
- Values filled by the AI carry the robot icon.
- **AI findings** box under the table, shown only when there are
findings.
- **Confirm for invoicing** opens the existing dialog, pre-filled with
the document number and date when `ai_check` has them. Otherwise it
behaves as today.
- **Confirm and add to POS:** confirm, then add every not-in-POS line.
- **Lines with no AI check:** they look exactly as today, with no ticks
and no findings.
- Colours use the existing `success`/`warn`/`destructive` tokens. The
robot icon and AI accents reuse the `ai` token from the quote
assistant.
### 6. Add to POS (manual feature)
- **"In POS"** means a `stock_items` row exists whose normalised `sku`
equals the line's SKU. Without a SKU, or with no match, the column
shows "Not in POS" and the button.
- **Add to POS** creates a `stock_items` row with:
- `name` and `description` from the line description;
- `sku` from the line;
- `supplier` from the line or PO;
- `cost_price` from the line, with selling price and markup from the
existing default-markup rule (`applyDefaultMarkupToPatch`);
- `unit` from the document when known;
- `current_qty` 0, `is_active` true.
- Extract today's create logic from
`src/app/api/v1/stock/items/create/route.ts` into a new service
function, `createStockItem` in `stock-items-service.ts`. Both the
existing agent route and a new browser-session route
`POST /api/v1/collection-confirm/add-to-pos` use it. The existing
route's request body keeps its camelCase names (`costPrice`,
`sellingPrice`, `markup`); the service takes DB-shaped input.
- **Stock lines** are already tied to a catalogue item, so their POS
column always shows "In POS".
### 7. Settings → AI: "Collection checks"
- **On/off:** off by default; the global AI switch must also be on.
- **Prompt:** an editable textarea for the document-reading prompt,
with reset-to-default. Max 50000; never `max: 0`.
- **Last run:** files read, cards checked.
## Data (manual PB steps via a migration script)
- **New collection `collection_doc_reads`**, created by a
`pb:migrate:collection-ai` script:
- `source_collection`, `source_id`, `filename`
- `content_hash` (unique index)
- `status` (text): `read` | `failed` | `skipped`
- `doc_type`, `document_no`, `doc_date`, `supplier_name`
- `po_refs` (json), `lines` (json), `notes` (json)
- `model`, `error`, `read_at`
API rules: list/view for authenticated users; writes admin only.
- **New JSON fields:** `order_items.ai_check` and
`stock_purchase_items.ai_check`.
- **New `ai_settings` fields:**
- `collection_ai_enabled` (bool)
- `collection_ai_prompt` (text, max 50000)
- `collection_ai_last_run_at` (text)
- `collection_ai_last_run_summary` (json)
- **Guard:** like the quote assistant, the service refuses to run until
`ai_check` exists. PB silently drops unknown fields.
- **Audit noise:** add `ai_check` to `IGNORED_FIELDS` in
`pocketbase/pb_hooks/audit_log_utils.js`, otherwise every re-run logs
a large JSON diff. The AI's SKU fill is still audited, because it is
an ordinary `sku` change. Redeploying the hook is a manual step on the
live server. Until then the only cost is noisy history.
- **n8n tick workflow:** created inactive by the controller after the
build, as for the quote assistant. The owner activates it after deploy.
## Where the code goes
- `src/lib/server/collection-ai-read.ts`: vision read and prompt default.
- `src/lib/server/collection-ai-pair.ts`: pairing call.
- `src/lib/collection-ai-ticks.ts`: pure, client-safe tick logic,
heavily unit-tested.
- `src/lib/server/collection-ai-findings.ts`: pure document-level
finding logic, unit-tested.
- `src/lib/server/collection-ai-service.ts`: orchestration, tick, card
check, writes.
- Routes: `/api/v1/collection-confirm/ai/tick`,
`/api/v1/collection-confirm/ai/check`,
`/api/v1/collection-confirm/add-to-pos`.
- `listCollectedItems` passes `ai_check` through, plus a per-card doc
summary.
- UI:
- extend `items-table.tsx` (tick and POS columns);
- extend `po-card.tsx` (doc chip, findings box, buttons);
- extend `confirm-invoice-dialog.tsx` (pre-fill);
- settings section in `src/app/(app)/settings/ai/`.
- MCP: `collection_confirm.ai_check` (write, needs approval in Giellie).
- Tests use today's NSS and Bestobell invoices as fixtures, as
transcribed text, not live data. Include:
- per-100 normalisation;
- a two-PO document;
- a document line not marked collected;
- a delivery note with no prices;
- duplicate photos (one read);
- a manual SKU never overwritten;
- AI off means unchanged page behaviour.
## Decisions
Decided by the owner:
- **What the AI does:**
- extracts the photo;
- fills SKUs it finds;
- checks quantity, description and price with green ticks;
- writes the document number;
- shows a findings note box.
- **What the person does:**
- reviews the card and confirms;
- fixes non-green lines by hand, or confirms partial quantities as-is.
- **POS:** an Add to POS option per line, and confirm-and-add for the
card.
- **Manual first:** everything must still work manually with the AI off.
- **Description tick:** green means the AI is sure it's the same item,
even when worded differently. The reviewer sees both wordings.
- **Deferred:** surplus to stock and shorts to the collection pool.
My calls (owner to check while reading):
- Three colours plus grey (amber = close or informational).
- The AI fills only the SKU, and only when empty.
- Cost, quantity and invoice details are never written by the AI. The
confirm box is pre-filled and a person confirms.
- One document can drive several POs' cards, found through the PO
numbers printed on it.
- Lines on the document but not marked collected are a finding only.
- PDFs aren't read in this round.
- "In POS" is a SKU match only.
- "SKU" means the supplier's code everywhere, as the POS already uses
supplier codes.
- "Ordered" is `order_qty`, falling back to `quantity`.
- Ticks are computed live from the stored pairing plus current row
values, so manual edits update them without a re-run.
- Card findings live on the document read.
- `ai_check` is added to the audit hook's ignored fields.
- Add to POS creates the catalogue item with zero stock.
- Runs automatically through an n8n tick plus a per-card button, both
behind a Settings switch.
## Left to the builder
- Exact prompt wording, thumb size, and per-run caps.
- Tooltip and chip layout within existing card styles.
- Whether pairing runs per document or per card. The contract above
must hold either way.