diff --git a/docs/superpowers/specs/2026-10-17-collection-confirm-ai-checks-design.md b/docs/superpowers/specs/2026-10-17-collection-confirm-ai-checks-design.md new file mode 100644 index 0000000..1413c2a --- /dev/null +++ b/docs/superpowers/specs/2026-10-17-collection-confirm-ai-checks-design.md @@ -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.