diff --git a/docs/superpowers/plans/2026-10-17-collection-confirm-ai-checks.md b/docs/superpowers/plans/2026-10-17-collection-confirm-ai-checks.md new file mode 100644 index 0000000..cb6e5d9 --- /dev/null +++ b/docs/superpowers/plans/2026-10-17-collection-confirm-ai-checks.md @@ -0,0 +1,522 @@ +# Collection Confirm AI Checks Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** The built-in AI reads the driver's invoice or delivery-note photo for each Collection Confirm card. It pairs the document lines with our PO lines, fills empty SKUs, and shows per-line SKU/Qty/Desc/Price ticks, a findings box, a pre-filled confirm box and POS buttons. All of this sits on top of today's fully manual page. + +**Architecture:** +- **Two AI calls per document**, through the same Anthropic-SDK/MiniMax pattern as `quotes-ai-processor.ts`: a vision read (cached per file, de-duplicated by content hash, stored in a new `collection_doc_reads` collection), then a text-only pairing call. +- **Code, not the AI, decides the ticks.** A pure, client-safe module computes them from the stored pairing plus live row values, so manual edits update the ticks instantly. +- **Triggers:** a service orchestrates per-card checks and an n8n-driven tick. "Add to POS" is a manual feature built on an extracted `createStockItem` service. + +**Tech Stack:** Next.js (read `node_modules/next/dist/docs/` before writing routes or pages), PocketBase JS SDK, `@anthropic-ai/sdk`, vitest + Testing Library, Tailwind tokens, lucide-react. + +**Spec:** `docs/superpowers/specs/2026-10-17-collection-confirm-ai-checks-design.md` + +## Global Constraints + +- **Manual first:** with the AI off, not run, failed or ignored, Collection Confirm behaves exactly as today: inline SKU/cost/qty edits, the confirm dialog, Confirm, Book to stock, Clear. The AI never blocks a confirm. +- **The AI writes only:** + - `ai_check` on lines; + - `collection_doc_reads` rows; + - an empty line SKU, through the existing services (`updateOrderItemSku` / `updateStockPurchaseItem({ sku })`). +- **The AI never writes** `cost_price`, `qty_collected` / `quantity_received`, `item_status` / `status`, or invoice details. +- **A human-set SKU is never overwritten.** A re-run only refreshes `ai_check` and the document findings. +- **"SKU" means the supplier's code everywhere** (SKU tick, AI fill, In POS, Add to POS). Codes are compared normalised: uppercase, `A-Z0-9` only. +- **"Ordered"** = `order_qty`, falling back to `quantity` when `order_qty` is 0. +- **Price tick:** green when |document per-unit price − `cost_price`| ≤ 0.01. Grey for a delivery note or a null price. +- **Images only** (jpeg/png/webp/gif). PDFs are stored as `status: "skipped"` with note `"PDF not read yet"`. +- **Audit actor:** all writes go inside `runWithAuditActor({ name: "AI collection check", source: "agent", ignoreRequestUser: true }, …)`. +- **Guard:** refuse to run (error `COLLECTION_AI_NOT_SET_UP_ERROR`) until `order_items` has an `ai_check` field. PB drops unknown fields silently. +- **Settings:** `collection_ai_enabled` is off by default, and the global AI switch must also be on. `collection_ai_prompt` text max is 50000, never `max: 0`. +- **Tick limits:** at most 10 files read per run, a 90 s time budget, and a 10-minute in-progress lease (same pattern as `quote-assistant-service.ts`). +- **Data layer:** new code lives in `src/lib/server/*` and `/api/v1/*`. Don't extend `pb-helpers.ts`, and don't migrate existing page calls. +- **Auth:** the tick uses `assertAgentAuth`. Browser routes use `getBrowserSessionUser(req)`, with the client sending `Authorization: Bearer ${pb.authStore.token}`. +- **Never test against live PB or through MCP write tools.** Tests stub PB and the AI. +- **Commits** end with `Co-Authored-By: Claude Opus 5.5 `. + +## Review Focus + +1. **Same photo uploaded several times (today: 7 copies).** It is read once. A file already read is never re-read on later ticks. Tested in Task 6. +2. **A rep sets a SKU, then the AI runs again.** The rep's SKU stays. The robot icon shows only while the SKU equals `sku_filled_value`. Tested in Tasks 2 and 6. +3. **Document price missing, or "per 100" already normalised.** A null price gives a grey price tick (never red), and R0.20 vs 0.2 is green. Tested in Task 2. +4. **The AI returns an unknown PO line id, or pairs two document lines to one PO line.** Unknown ids are ignored, the first pairing wins, and a line with no valid pairing is never green. Tested in Task 5. +5. **AI off, not set up, or the read failed.** Rows without `ai_check` render exactly as today: no tick columns' content, no findings box, no pre-fill. Tested in Task 8. + +--- + +### Task 1: Data model, settings fields, migration, audit ignore + +**Files:** +- Modify: `src/lib/types.ts` (new types) +- Modify: `src/lib/server/collection-confirm-service.ts` (`ConfirmRow` fields only) +- Modify: `src/lib/server/ai-settings-service.ts` (+ `ai-settings-service.test.ts`) +- Create: `scripts/migrations/2026-10-17-add-collection-ai.ts` +- Modify: `package.json` (`"pb:migrate:collection-ai"`) +- Modify: `pocketbase/pb_hooks/audit_log_utils.js` (`IGNORED_FIELDS` += `ai_check`) + +**Interfaces — Produces in `src/lib/types.ts`:** +```ts +export type CollectionDocType = "tax_invoice" | "delivery_note" | "other"; +export interface CollectionDocLine { + line_no: number; code: string; description: string; + quantity: number | null; unit: string; + unit_price: number | null; // per single unit, already normalised from "per 100" etc. + line_total: number | null; to_follow: number | null; price_note: string; +} +export interface CollectionDocRead { + id: string; source_collection: string; source_id: string; filename: string; content_hash: string; + status: "read" | "failed" | "skipped"; doc_type: CollectionDocType | ""; + document_no: string; doc_date: string; supplier_name: string; + po_refs: string[]; lines: CollectionDocLine[]; notes: string[]; findings: string[]; + model: string; error: string; read_at: string; +} +export type SameItem = "sure" | "likely" | "different"; +export interface CollectionAiCheck { + read_id: string; document_no: string; doc_date: string; doc_type: CollectionDocType; + doc_line: CollectionDocLine | null; same_item: SameItem | null; reason: string; + sku_filled_value: string; findings: string[]; checked_at: string; model: string; +} +export interface CollectionAiRunSummary { + started_at: string; finished_at: string; files_read: number; cards_checked: number; errors: string[]; +} +``` + +**Interfaces — Produces on `ConfirmRow`:** `ai_check?: CollectionAiCheck | null`, `ai_doc?: { read_id: string; doc_type: CollectionDocType; document_no: string; doc_date: string; supplier_name: string; findings: string[] } | null`, `in_pos?: boolean | null`, `pos_stock_item_id?: string | null`. + +**Interfaces — Produces on `AiSettings` / `AiSettingsUpdate`:** `collection_ai_enabled: boolean` (default false), `collection_ai_prompt: string` (default "", sliced to 50000), `collection_ai_last_run_at: string`, `collection_ai_last_run_summary: CollectionAiRunSummary | null`. Also `export async function recordCollectionAiRun(fields: { last_run_at?: string; summary?: CollectionAiRunSummary }): Promise`, mirroring `recordQuoteAssistantRun`. + +- [ ] **Step 1: Write failing tests** in `ai-settings-service.test.ts`. + - A row without the new fields reads back `collection_ai_enabled: false`, `collection_ai_prompt: ""`, `collection_ai_last_run_summary: null`. + - `updateAiSettings({ collection_ai_prompt: "x".repeat(60_000) })` sends 50000 characters. + - `recordCollectionAiRun({ last_run_at: "2026-10-17T10:00:00.000Z" })` sends exactly that one key and refreshes the cache. + +- [ ] **Step 2: Run the tests and confirm they fail.** + Run: `npx vitest run src/lib/server/ai-settings-service.test.ts`. Expected: FAIL. + +- [ ] **Step 3: Implement the types, settings, migration script and hook edit.** + - Model the migration on `scripts/migrations/2026-10-16-add-quote-assistant-fields.ts`: idempotent, `import "./_env-loader"`. + - It creates the collection `collection_doc_reads` with: + - text fields `source_collection`, `source_id`, `filename`, `content_hash`, `status`, `doc_type`, `document_no`, `doc_date`, `supplier_name`, `model`, `error`, `read_at`; + - text field `error` with max 2000; + - json fields `po_refs`, `lines`, `notes`, `findings`, each with maxSize 200000; + - a unique index on `content_hash`; + - a non-unique index on (`source_collection`, `source_id`, `filename`); + - list/view rules `@request.auth.id != ""` and create/update/delete `null`. + - It adds json `ai_check` (maxSize 50000) to `order_items` and `stock_purchase_items`. + - It adds the four `ai_settings` fields, with `collection_ai_prompt` text max 50000. + - Add `"ai_check"` to `IGNORED_FIELDS` in the hook utils. + +- [ ] **Step 4: Run the tests and the type check.** + Run: `npx vitest run src/lib/server/ai-settings-service.test.ts && npx tsc --noEmit`. Expected: PASS. + +- [ ] **Step 5: Commit** with message `feat(collection-ai): data model, settings, migration`. + +--- + +### Task 2: Tick logic (pure, client-safe) + +**Files:** +- Create: `src/lib/collection-ai-ticks.ts` +- Create: `src/lib/collection-ai-ticks.test.ts` + +**Interfaces:** +- Consumes: `CollectionAiCheck` (Task 1). +- Produces: +```ts +export type TickState = "green" | "amber" | "red" | "grey"; +export interface LineTicks { sku: TickState; qty: TickState; desc: TickState; price: TickState } +export type TickRow = { sku?: string; qty_collected?: number; cost_price?: number | null; order_qty?: number; quantity?: number }; +export function normaliseCode(code: string | null | undefined): string; // uppercase, A-Z0-9 only +export function computeLineTicks(row: TickRow, check: CollectionAiCheck): LineTicks; +export function allGreen(t: LineTicks): boolean; +export function showsAiFilledSku(row: TickRow, check: CollectionAiCheck | null | undefined): boolean; +``` +- The file imports only types from `@/lib/types`. + +- [ ] **Step 1: Write failing tests** with these exact cases. + - **No pairing:** `doc_line: null` gives all four `"red"`. + - **SKU tick:** + - green when `normaliseCode(row.sku) === normaliseCode(doc_line.code)` (e.g. `"sm293306016"` vs `"SM293306016"`); + - green when `row.sku === check.sku_filled_value` and that value is non-empty; + - amber when the row SKU is non-empty and different from the code; + - grey when `doc_line.code` is empty; + - red when the row SKU is empty and the code is non-empty (the AI couldn't fill it). + - **Qty tick:** + - green when doc qty 40, collected 40 and ordered 40; + - amber when doc qty 5, collected 5, `order_qty` 8; + - red when doc qty 4, collected 3; + - grey when doc qty is null; + - "ordered" uses `quantity` when `order_qty` is 0. + - **Desc tick:** `same_item` `"sure"` gives green, `"likely"` amber, `"different"` red, and null red. + - **Price tick:** + - doc `unit_price` 0.2 vs `cost_price` 0.20 gives green; + - 0.33 vs 0.345 gives red; + - 14.51 vs 14.515 gives green (within 0.01); + - `doc_type` `"delivery_note"` gives grey; + - a null `unit_price` gives grey; + - a null `cost_price` gives red. + - **`allGreen`:** true only when all four are green. + - **`showsAiFilledSku`:** false after a rep changes the SKU away from `sku_filled_value`. + +- [ ] **Step 2: Run and confirm they fail.** + Run: `npx vitest run src/lib/collection-ai-ticks.test.ts`. + +- [ ] **Step 3: Implement the module.** + +- [ ] **Step 4: Run the tests.** Expected: PASS. + +- [ ] **Step 5: Commit** with message `feat(collection-ai): line tick rules`. + +--- + +### Task 3: Document findings (pure) + +**Files:** +- Create: `src/lib/server/collection-ai-findings.ts` +- Create: `src/lib/server/collection-ai-findings.test.ts` + +**Interfaces:** +- Consumes: `CollectionDocRead`, `CollectionDocLine`; `supplierNamesMatch` from `stock-purchase-invoice-matching-service.ts`. +- Produces: +```ts +export type PoLineLite = { + id: string; po_number: string; description: string; collected: boolean; + order_qty: number; quantity: number; qty_collected: number; +}; +export type LinePair = { doc_line_no: number; po_line_id: string | null }; +export function buildDocFindings(input: { + read: Pick; + resolvedPos: Array<{ po_number: string; supplier_name: string }>; + fellBackToPo: string | null; // set when no printed PO ref resolved + poLines: PoLineLite[]; // all lines on resolvedPos, any status + pairs: LinePair[]; +}): string[]; +``` + +- [ ] **Step 1: Write failing tests.** Each case asserts the exact string. Model them on the NSS fixture (document `5028256`; POs `PO202610003CEM` and `PO202610009CAS`). + - Two resolved POs: `"Document 5028256 covers 2 POs: PO202610003CEM, PO202610009CAS."` + - A paired PO line with `collected: false` (M8x25, document qty 40): `"M8x25 stainless steel × 40 is on the document but not marked collected."` + - A document line with no PO line: `"Document line \"\" doesn't match any PO line."` + - A collected PO line not paired: `" was marked collected but isn't on the document."` + - Supplier differs (`supplierNamesMatch` is false): `"Supplier on the document () differs from the PO supplier ()."` + - A document line with `to_follow: 0` where the PO line's ordered is greater than collected: `": supplier shows nothing to follow, but are still outstanding."` + - `order_qty` 4 and `quantity` 40 on a paired line: `": order line says 40 but the PO quantity is 4."` + - `fellBackToPo` set: `"No PO number on the document matched; checked against ."` + - Findings come out in the order listed above. + +- [ ] **Step 2: Run and confirm they fail.** + Run: `npx vitest run src/lib/server/collection-ai-findings.test.ts`. + +- [ ] **Step 3: Implement `buildDocFindings`.** + +- [ ] **Step 4: Run the tests.** Expected: PASS. + +- [ ] **Step 5: Commit** with message `feat(collection-ai): document findings`. + +--- + +### Task 4: Vision read of one document + +**Files:** +- Create: `src/lib/server/collection-ai-prompt.ts` (`DEFAULT_COLLECTION_AI_PROMPT`) +- Create: `src/lib/server/collection-ai-read.ts` +- Create: `src/lib/server/collection-ai-read.test.ts` + +**Interfaces:** +- Consumes: `getActiveAiConfig`, `getAiSettings` (`collection_ai_prompt`). +- Produces: +```ts +export const SUBMIT_DOCUMENT_READ_TOOL: { name: "submit_document_read"; description: string; input_schema: object }; +export type DocumentReadResult = Pick & { model: string }; +export function parseDocumentRead(raw: unknown): Omit; +export async function readDocumentImage( + image: { base64: string; contentType: string }, + deps?: { client?: Pick }, +): Promise; +``` + +- [ ] **Step 1: Write failing tests.** Inject a fake client, as `quote-assistant-ai.test.ts` does. + - **Request shape:** the request sends one image block plus a text instruction, with `tool_choice: { type: "tool", name: "submit_document_read" }`. The system prompt is `DEFAULT_COLLECTION_AI_PROMPT` unless `collection_ai_prompt` is non-blank. + - **`parseDocumentRead` on the NSS fixture:** a transcribed tool input with 4 lines, `unit_price` 0.95 / 0.33 / 0.2 / 14.51 and `po_refs` `["PO202610003CEM", "PO202610009CAS"]`. It returns them unchanged. `line_no` is assigned 1..n when missing. + - **Defensive parsing:** + - a bad `doc_type` becomes `"other"`; + - non-numeric quantity or price becomes null; + - a missing `lines` becomes `[]`; + - strings are trimmed; + - `doc_date` that isn't ISO `YYYY-MM-DD` becomes `""`. + - **Errors:** + - `stop_reason === "max_tokens"` throws `"AI response was cut off (max_tokens)"`; + - no tool block throws `"AI returned no submit_document_read tool call"`. + +- [ ] **Step 2: Run and confirm they fail.** + Run: `npx vitest run src/lib/server/collection-ai-read.test.ts`. + +- [ ] **Step 3: Implement.** + - Build the client like `quote-assistant-ai.ts` (`newAnthropic`). + - Set `max_tokens` to `Math.max(aiConfig.maxTokens, 4096)`. + - The default prompt is plain text, with this exact content: +``` +You read photos of South African supplier documents (tax invoices and delivery notes) that a driver took when collecting goods. Return exactly what is printed via the submit_document_read tool. + +Rules: +1. doc_type: "tax_invoice", "delivery_note", or "other" (a photo of goods, not paperwork). +2. document_no: the invoice or delivery-note number printed as the document's own number (not account or VAT numbers). date: the document date as YYYY-MM-DD. +3. po_refs: every customer order / PO number printed (often under "Customer PO", "Customer Order No" or "Your Ref"). Split values like "PO202610003CEM/PO202610009CAS" into separate entries. +4. lines: one entry per item line. code = the supplier's stock code; description as printed; quantity = quantity supplied; unit as printed. +5. unit_price must be the price for ONE unit. If the document prices per 100 (or per 1000, per box of N), divide accordingly and say so in price_note (e.g. "printed 33.00 per 100"). +6. to_follow: the "to follow" / back-order quantity if printed, else null. +7. Never guess a number you cannot read clearly: use null. The photo may be rotated or partly covered. +``` + +- [ ] **Step 4: Run the tests.** Expected: PASS. + +- [ ] **Step 5: Commit** with message `feat(collection-ai): vision read of driver documents`. + +--- + +### Task 5: Pairing document lines with PO lines + +**Files:** +- Create: `src/lib/server/collection-ai-pair.ts` +- Create: `src/lib/server/collection-ai-pair.test.ts` + +**Interfaces:** +- Consumes: `CollectionDocLine`, `SameItem`, `PoLineLite` (Task 3; extend locally with `sku`, `po_description`, `cost_price`, `status`). +- Produces: +```ts +export type PairPoLine = PoLineLite & { sku: string; po_description: string; cost_price: number | null; status: string }; +export type PairDecision = { doc_line_no: number; po_line_id: string | null; same_item: SameItem | null; reason: string }; +export function parsePairs(raw: unknown, docLineNos: ReadonlySet, poLineIds: ReadonlySet): Map; +export async function pairDocumentLines( + docLines: CollectionDocLine[], poLines: PairPoLine[], + deps?: { client?: Pick }, +): Promise<{ pairs: Map; model: string }>; +``` + +- [ ] **Step 1: Write failing tests.** + - **Unknown ids:** a `po_line_id` not in `poLineIds` becomes `po_line_id: null, same_item: null` (Review Focus #4). + - **Unknown line numbers:** a `doc_line_no` not in `docLineNos` is dropped. + - **Duplicate PO line:** a PO line paired to two document lines keeps the first; the second gets `po_line_id: null`. + - **Bad values:** an invalid `same_item` becomes null. + - **No PO lines:** with an empty `poLines`, `pairDocumentLines` makes no AI call and returns every document line unpaired. + - **Request shape:** the request text lists each PO line's id, description, PO description, SKU, ordered, collected and cost. It uses tool `submit_line_pairs`, and its system prompt says "pair only when it is the same product; wording differs between supplier and customer — judge the item, not the words". + +- [ ] **Step 2: Run and confirm they fail.** + Run: `npx vitest run src/lib/server/collection-ai-pair.test.ts`. + +- [ ] **Step 3: Implement.** Text-only, `max_tokens` 4096. Throw on `max_tokens` cut-off as in Task 4. + +- [ ] **Step 4: Run the tests.** Expected: PASS. + +- [ ] **Step 5: Commit** with message `feat(collection-ai): pair document lines with PO lines`. + +--- + +### Task 6: Collection AI service (card check, tick, list passthrough) + +**Files:** +- Create: `src/lib/server/collection-ai-service.ts` +- Create: `src/lib/server/collection-ai-service.test.ts` +- Modify: `src/lib/server/collection-confirm-service.ts` (`listCollectedItems` attaches `ai_check`, `ai_doc`, `in_pos`, `pos_stock_item_id`) +- Modify: `src/lib/server/collection-confirm-service.test.ts` + +**Interfaces:** +- Consumes: Tasks 1–5; `updateOrderItemSku`, `updateStockPurchaseItem`; `normalizePoReference`; `runWithAuditActor`; `getAiSettings`, `clearAiSettingsCache`, `recordCollectionAiRun`, `AiDisabledError`. +- Produces: +```ts +export const COLLECTION_AI_NOT_SET_UP_ERROR = "Collection checks are not set up yet: run `npm run pb:migrate:collection-ai` against PocketBase first"; +export const COLLECTION_AI_DISABLED_ERROR = "AI is disabled in settings"; +export const TICK_MAX_FILES = 10; export const TICK_TIME_BUDGET_MS = 90_000; export const TICK_LEASE_MS = 10 * 60_000; +export interface CollectionAiDeps { + readDocument?: typeof readDocumentImage; pairLines?: typeof pairDocumentLines; + fetchFile?: (rec: { collection: string; id: string }, filename: string) => Promise<{ base64: string; contentType: string; bytes: Buffer }>; + now?: () => Date; +} +export interface CardCheckResult { po_number: string; files_read: number; lines_checked: number; skus_filled: number; error: string } +export async function checkCollectionCard(poNumber: string, opts?: { actorName?: string; maxFiles?: number; deps?: CollectionAiDeps }): Promise; +export async function runCollectionAiTick(deps?: CollectionAiDeps): Promise<{ ran: boolean; reason?: "disabled" | "running"; summary?: CollectionAiRunSummary }>; +``` + +- [ ] **Step 1: Write failing tests.** Use an in-memory PB stub (as `quote-assistant-service.test.ts` does) with `order_items`, `stock_purchase_items`, `stock_purchases`, `purchase_orders`, `collection_doc_reads`, `stock_items` and `collections.getOne`. + + `checkCollectionCard`: + - **Not set up:** returns `COLLECTION_AI_NOT_SET_UP_ERROR` with no writes when `order_items` lacks `ai_check`. + - **AI off:** `AiDisabledError` from `readDocument` returns `COLLECTION_AI_DISABLED_ERROR` and writes nothing. + - **Reads are cached (Review Focus #1):** + - A file with an existing `collection_doc_reads` row (same source and filename) is not fetched or read again. + - Three identical files (same bytes) produce one read. The other two get rows pointing to the same content via `content_hash`; no second AI call. + - **PDFs:** a `.pdf` attachment gets a `status: "skipped"` row with note `"PDF not read yet"`, and no AI call. + - **NSS fixture (two POs, one photo on PO…003CEM's line):** + - PO references resolve to both POs. + - Every collected line on both POs gets `ai_check`. + - The read's `findings` include the "covers 2 POs" and "M8x25 … not marked collected" strings from Task 3. + - **SKU fill:** + - An empty SKU with `same_item` "sure" is filled via `updateOrderItemSku`, and `sku_filled_value` is recorded. + - An existing SKU is never changed (Review Focus #2). + - `same_item` "different" never fills. + - **Stock rows** fill through `updateStockPurchaseItem` with `{ sku }`. + - **Fallback:** no PO ref resolves, so it falls back to the card's PO, and the finding says so. + - **Audit:** every write runs inside `runWithAuditActor` with `name` `"AI collection check"` (or `"AI collection check (run by )"`), `source: "agent"` and `ignoreRequestUser: true`. + + `runCollectionAiTick`: + - Returns `disabled` unless both `enabled` and `collection_ai_enabled` are on. + - Applies the lease check `running` as in the quote assistant. + - Records `last_run_at` before work. + - Picks only collected lines whose image attachments lack reads or whose rows lack `ai_check`, grouped by PO number, newest first. + - Stops at `TICK_MAX_FILES` or the time budget. + - Isolates per-card errors into the bounded summary (20 entries of up to 300 characters each). + + `listCollectedItems`: + - Attaches `ai_doc` from the read referenced by `ai_check.read_id`, including `findings`. + - Sets `in_pos: true` and `pos_stock_item_id` when a `stock_items` SKU normalises to the row's SKU. + - Stock rows always get `in_pos: true`. + - Rows without `ai_check` get `ai_check: null` and `ai_doc: null`. + +- [ ] **Step 2: Run and confirm they fail.** + Run: `npx vitest run src/lib/server/collection-ai-service.test.ts src/lib/server/collection-confirm-service.test.ts`. + +- [ ] **Step 3: Implement.** + - **Default file fetch:** the admin PB `files.getToken()`, then fetch `files.getURL(record, filename, { token, thumb: "1600x0" })`. Hash the full bytes with sha256 (`node:crypto`). + - **Choosing the read:** when several reads cover one line, the newest `read_at` with a pairing wins. + - **`in_pos` lookup:** chunk distinct SKUs (≤ 50) into OR'd `sku ~ ""` filters on `stock_items`, then compare `normaliseCode` in the app. + +- [ ] **Step 4: Run the tests and the full suite.** + Run: `npx vitest run` and `npx tsc --noEmit`. Expected: PASS. + +- [ ] **Step 5: Commit** with message `feat(collection-ai): card check, tick and list passthrough`. + +--- + +### Task 7: Add to POS service and routes, plus the AI routes and MCP tool + +**Files:** +- Modify: `src/lib/server/stock-items-service.ts` (add `createStockItem`, + test) +- Modify: `src/app/api/v1/stock/items/create/route.ts` (use the service; same request contract) +- Create: `src/app/api/v1/collection-confirm/add-to-pos/route.ts` (+ `route.test.ts`) +- Create: `src/app/api/v1/collection-confirm/ai/tick/route.ts` (+ test) +- Create: `src/app/api/v1/collection-confirm/ai/check/route.ts` (+ test) +- Modify: `src/app/api/mcp/route.ts` (+ list test), `src/lib/server/collection-confirm-service.ts` (`addRowsToPos`) + +**Interfaces:** +- Produces: +```ts +// stock-items-service.ts +export type CreateStockItemInput = { name: string; sku?: string; description?: string; unit?: string; location?: string; + current_qty?: number; min_qty?: number; supplier?: string; notes?: string; category?: string; + cost_price?: number; selling_price?: number; markup?: number }; +export async function createStockItem(input: CreateStockItemInput): Promise; +// collection-confirm-service.ts +export async function addRowsToPos(rowIds: string[]): Promise>; +``` +- **HTTP:** + - `POST /api/v1/collection-confirm/add-to-pos`: browser session, body `{ row_ids: string[] }` (1–100 ids), returns 200 `{ results }`. + - `POST /api/v1/collection-confirm/ai/tick`: agent auth. + - `POST /api/v1/collection-confirm/ai/check`: browser session, body `{ po_number }`. Returns 409 for `COLLECTION_AI_NOT_SET_UP_ERROR` / `COLLECTION_AI_DISABLED_ERROR`, otherwise 200 with `CardCheckResult`. +- **MCP:** `collection_confirm.ai_check({ po_number })` calls `checkCollectionCard(po, { actorName: "MCP" })`. It is not in `READ_TOOLS`. + +- [ ] **Step 1: Write failing tests.** + - `createStockItem({ name: "M6 nylock", sku: "SM2NUT9B506", cost_price: 0.2 })` applies `applyDefaultMarkupToPatch` (selling 0.36, markup 80) and sets `current_qty` 0 and `is_active` true. + - The existing create route's test (or a new one) still accepts `costPrice` / `sellingPrice` / `markup` in camelCase. + - `addRowsToPos` for a customer row: + - creates the item with `name` = description, `description` = `po_description` or description, `sku`, `supplier`, `cost_price`, and `unit` from `ai_check.doc_line.unit` when present; + - returns `already_in_pos` when a normalised SKU match exists; + - returns `not_found` for an unknown id. + - Route tests cover 401, 400 (missing or empty `row_ids`, missing `po_number`), the 409 mapping, and the MCP tool classifying as "write". + +- [ ] **Step 2: Run and confirm they fail.** + Run: `npx vitest run src/lib/server/stock-items-service.test.ts src/app/api/v1/collection-confirm src/app/api/v1/stock src/app/api/mcp src/lib/server/giellie-tool-policy.catalogue.test.ts`. + +- [ ] **Step 3: Implement the service, routes and MCP entry.** + +- [ ] **Step 4: Run the tests.** Expected: PASS. + +- [ ] **Step 5: Commit** with message `feat(collection-ai): add to POS, AI routes and MCP tool`. + +--- + +### Task 8: Collection Confirm page UI + +**Files:** +- Modify: `src/components/collection-confirm/items-table.tsx` (+ test) +- Modify: `src/components/collection-confirm/po-card.tsx` (+ test) +- Modify: `src/components/collection-confirm/confirm-invoice-dialog.tsx` (pre-fill only if needed) +- Modify: `src/app/(app)/collection-confirm/page.tsx` (handlers) +- Create: `src/components/collection-confirm/ai-findings.tsx` (+ test) + +**Interfaces:** +- Consumes: `computeLineTicks`, `showsAiFilledSku` (Task 2); `ConfirmRow.ai_check` / `ai_doc` / `in_pos` (Tasks 1 and 6); the routes from Task 7. +- Produces: + - `ItemsTable` gains optional props `onAddToPos?: (rowId: string) => void` and `showAi?: boolean`. + - `PoCard` gains optional `onCheckWithAi?: () => Promise` and `onConfirmAndAddToPos?: () => void`. + - `AiFindings({ findings }: { findings: string[] })` renders null for an empty list. + +- [ ] **Step 1: Write failing component tests.** + - **No AI data (Review Focus #5):** a row with `ai_check: null` renders no tick icons and no robot icon. The card with no `ai_doc` renders no findings box and no document chip. The table renders its existing columns unchanged; a snapshot of the header cells is equal to before plus the new "AI" and "POS" headers only when some row has `ai_check` or `in_pos`. + - **Ticks:** a row with `ai_check` renders four tick icons whose `aria-label`s come from `computeLineTicks` (`"SKU: matches"`, `"Qty: have a look"`, `"Desc: doesn't match"`, `"Price: can't check"`). Each tooltip shows "ours vs document" values. The Desc tooltip shows both descriptions. + - **Robot icon:** shows next to the SKU only when `showsAiFilledSku` is true. + - **POS column:** "In POS" (with a link to `/stock/`) when `in_pos`. Otherwise an "Add to POS" button calls `onAddToPos(row.id)`. + - **Document chip:** `"Tax invoice 5028256 · 16 Oct 2026 · read from driver photo"`. A delivery note uses `"Delivery note"`. Clicking the chip opens a panel listing the extracted document lines (code, description, qty, per-unit price, price note) beside the existing photo viewer (`invoice-viewer.tsx`). + - **Check with AI** calls `onCheckWithAi` and shows a spinner while pending. + - **Confirm buttons:** "Confirm and add to POS" renders only when at least one customer row is not in POS. + - **Pre-fill:** opening the confirm dialog from a card whose rows have `ai_check.document_no` "5028256" and `doc_date` "2026-10-16" shows those values pre-filled. With no `ai_check`, the dialog shows today's defaults (date today, number blank). + +- [ ] **Step 2: Run and confirm they fail.** + Run: `npx vitest run src/components/collection-confirm`. + +- [ ] **Step 3: Implement.** + - **Page handlers:** + - `handleCheckWithAi(poNumber)` POSTs to `/ai/check`, toasts the result or the 409 error, then reloads the feed. + - `handleAddToPos(rowIds)` POSTs to `/add-to-pos`, toasts `"Added N items to the POS"`, then reloads. + - "Confirm and add to POS" runs the existing confirm flow, then calls `handleAddToPos` with the card's not-in-POS customer rows only after the confirm succeeds. + - **Pre-fill:** the confirm dialog takes its initial values from the first row with `ai_check.document_no`. + - **Styling:** ticks use the `success` / `warn` / `destructive` / `muted` tokens with `CheckCircle2` / `AlertTriangle` / `XCircle` / `MinusCircle`. The robot uses lucide `Bot` with `text-ai`. Surgical edits only. + +- [ ] **Step 4: Run the tests and checks.** + Run: `npx vitest run src/components/collection-confirm && npx tsc --noEmit`, plus eslint on the changed files. Expected: PASS, with no new lint errors. + +- [ ] **Step 5: Commit** with message `feat(collection-ai): ticks, findings and POS buttons on Collection Confirm`. + +--- + +### Task 9: Settings section, runbook, AGENTS.md + +**Files:** +- Modify: `src/app/(app)/settings/ai/settings-form-helpers.ts` (+ test), `ai-settings-form.tsx`, `page.tsx` +- Create: `docs/collection-ai-checks.md` +- Modify: `AGENTS.md` + +**Interfaces:** +- Form fields: `ca_enabled` (checkbox `"on"`), `ca_prompt` → `collection_ai_enabled`, `collection_ai_prompt`. + +- [ ] **Step 1: Write failing parser tests.** + - `ca_enabled=on` gives true, and missing gives false. + - `ca_prompt` passes through untrimmed. + - The existing and `qa_*` fields are unchanged. + +- [ ] **Step 2: Run, confirm they fail, then implement the parser.** + Run: `npx vitest run "src/app/(app)/settings/ai/settings-form-helpers.test.ts"`. Expected: FAIL, then PASS. + +- [ ] **Step 3: Build the "Collection checks" card in the same form as "Quote assistant".** + - On/off switch. + - Prompt textarea with `maxLength={50000}`, "Reset to default" (`DEFAULT_COLLECTION_AI_PROMPT`) and a read-only preview. + - "Last run" line: `"