docs(collection-confirm): AI checks design spec
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
78ba560096
commit
3624d87801
1 changed files with 397 additions and 0 deletions
|
|
@ -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.
|
||||
Loading…
Reference in a new issue