WFDASH-WEBAPP/docs/superpowers/plans/2026-10-17-collection-confirm-ai-checks.md
Dave 3e37ae482f docs(collection-confirm): AI checks implementation plan
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 01:04:42 +02:00

33 KiB
Raw Blame History

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 <noreply@anthropic.com>.

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:

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<void>, 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:
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:
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<CollectionDocRead, "document_no" | "supplier_name" | "lines">;
  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 \"<description>\" doesn't match any PO line."
    • A collected PO line not paired: "<description> was marked collected but isn't on the document."
    • Supplier differs (supplierNamesMatch is false): "Supplier on the document (<doc>) differs from the PO supplier (<po>)."
    • A document line with to_follow: 0 where the PO line's ordered is greater than collected: "<description>: supplier shows nothing to follow, but <ordered − collected> are still outstanding."
    • order_qty 4 and quantity 40 on a paired line: "<description>: order line says 40 but the PO quantity is 4."
    • fellBackToPo set: "No PO number on the document matched; checked against <po>."
    • 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:
export const SUBMIT_DOCUMENT_READ_TOOL: { name: "submit_document_read"; description: string; input_schema: object };
export type DocumentReadResult = Pick<CollectionDocRead,
  "doc_type" | "document_no" | "doc_date" | "supplier_name" | "po_refs" | "lines" | "notes"> & { model: string };
export function parseDocumentRead(raw: unknown): Omit<DocumentReadResult, "model">;
export async function readDocumentImage(
  image: { base64: string; contentType: string },
  deps?: { client?: Pick<Anthropic, "messages"> },
): Promise<DocumentReadResult>;
  • 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:
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<number>, poLineIds: ReadonlySet<string>): Map<number, PairDecision>;
export async function pairDocumentLines(
  docLines: CollectionDocLine[], poLines: PairPoLine[],
  deps?: { client?: Pick<Anthropic, "messages"> },
): Promise<{ pairs: Map<number, PairDecision>; 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:
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<CardCheckResult>;
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 <actorName>)"), 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 ~ "<code>" 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:
// 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<StockItem>;
// collection-confirm-service.ts
export async function addRowsToPos(rowIds: string[]): Promise<Array<{ row_id: string; status: "created" | "already_in_pos" | "not_found"; stock_item_id: string | null }>>;
  • 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<void> 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-labels 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/<pos_stock_item_id>) 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: "<time> · <files_read> files read · <cards_checked> cards checked", or "Never".
    • Helper text: "Needs the AI switch above to be on too. Reads driver photos on Collection Confirm and ticks each line; it never confirms anything."
  • Step 4: Write the runbook and the AGENTS.md section.

    • docs/collection-ai-checks.md covers:
      • what the feature does;
      • the manual live steps: (1) npm run pb:migrate:collection-ai, (2) redeploy pocketbase/pb_hooks/audit_log_utils.js (ai_check ignored), (3) deploy, (4) activate the n8n "WFDASH Collection check tick", (5) Settings → AI → Collection checks on;
      • what each tick colour means;
      • the guard error.
    • AGENTS.md gets a section of 6–10 lines with:
      • the file map;
      • the "manual first" rule;
      • "AI writes only ai_check, doc reads, and empty SKUs";
      • the manual live steps.
  • Step 5: Run final verification. Run: npx vitest run && npx tsc --noEmit && npm run build, plus eslint on the changed files. Expected: all PASS.

  • Step 6: Commit with message feat(collection-ai): settings section, runbook.