33 KiB
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 newcollection_doc_readscollection), 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
createStockItemservice.
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_checkon lines;collection_doc_readsrows;- 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_checkand 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-9only. - "Ordered" =
order_qty, falling back toquantitywhenorder_qtyis 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) untilorder_itemshas anai_checkfield. PB drops unknown fields silently. - Settings:
collection_ai_enabledis off by default, and the global AI switch must also be on.collection_ai_prompttext max is 50000, nevermax: 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 extendpb-helpers.ts, and don't migrate existing page calls. - Auth: the tick uses
assertAgentAuth. Browser routes usegetBrowserSessionUser(req), with the client sendingAuthorization: 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
- 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.
- 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. - 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.
- 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.
- AI off, not set up, or the read failed. Rows without
ai_checkrender 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(ConfirmRowfields 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.
- A row without the new fields reads back
-
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_readswith:- text fields
source_collection,source_id,filename,content_hash,status,doc_type,document_no,doc_date,supplier_name,model,error,read_at; - text field
errorwith 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/deletenull.
- text fields
- It adds json
ai_check(maxSize 50000) toorder_itemsandstock_purchase_items. - It adds the four
ai_settingsfields, withcollection_ai_prompttext max 50000. - Add
"ai_check"toIGNORED_FIELDSin the hook utils.
- Model the migration on
-
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: nullgives 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_valueand that value is non-empty; - amber when the row SKU is non-empty and different from the code;
- grey when
doc_line.codeis empty; - red when the row SKU is empty and the code is non-empty (the AI couldn't fill it).
- green when
- Qty tick:
- green when doc qty 40, collected 40 and ordered 40;
- amber when doc qty 5, collected 5,
order_qty8; - red when doc qty 4, collected 3;
- grey when doc qty is null;
- "ordered" uses
quantitywhenorder_qtyis 0.
- Desc tick:
same_item"sure"gives green,"likely"amber,"different"red, and null red. - Price tick:
- doc
unit_price0.2 vscost_price0.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_pricegives grey; - a null
cost_pricegives red.
- doc
allGreen: true only when all four are green.showsAiFilledSku: false after a rep changes the SKU away fromsku_filled_value.
- No pairing:
-
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;supplierNamesMatchfromstock-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; POsPO202610003CEMandPO202610009CAS).- 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 (
supplierNamesMatchis false):"Supplier on the document (<doc>) differs from the PO supplier (<po>)." - A document line with
to_follow: 0where the PO line's ordered is greater than collected:"<description>: supplier shows nothing to follow, but <ordered − collected> are still outstanding." order_qty4 andquantity40 on a paired line:"<description>: order line says 40 but the PO quantity is 4."fellBackToPoset:"No PO number on the document matched; checked against <po>."- Findings come out in the order listed above.
- Two resolved POs:
-
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.tsdoes.- Request shape: the request sends one image block plus a text instruction, with
tool_choice: { type: "tool", name: "submit_document_read" }. The system prompt isDEFAULT_COLLECTION_AI_PROMPTunlesscollection_ai_promptis non-blank. parseDocumentReadon the NSS fixture: a transcribed tool input with 4 lines,unit_price0.95 / 0.33 / 0.2 / 14.51 andpo_refs["PO202610003CEM", "PO202610009CAS"]. It returns them unchanged.line_nois assigned 1..n when missing.- Defensive parsing:
- a bad
doc_typebecomes"other"; - non-numeric quantity or price becomes null;
- a missing
linesbecomes[]; - strings are trimmed;
doc_datethat isn't ISOYYYY-MM-DDbecomes"".
- a bad
- 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".
- Request shape: the request sends one image block plus a text instruction, with
-
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_tokenstoMath.max(aiConfig.maxTokens, 4096). - The default prompt is plain text, with this exact content:
- Build the client like
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 withsku,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_idnot inpoLineIdsbecomespo_line_id: null, same_item: null(Review Focus #4). - Unknown line numbers: a
doc_line_nonot indocLineNosis 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_itembecomes null. - No PO lines: with an empty
poLines,pairDocumentLinesmakes 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".
- Unknown ids: a
-
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_tokens4096. Throw onmax_tokenscut-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(listCollectedItemsattachesai_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.tsdoes) withorder_items,stock_purchase_items,stock_purchases,purchase_orders,collection_doc_reads,stock_itemsandcollections.getOne.checkCollectionCard:- Not set up: returns
COLLECTION_AI_NOT_SET_UP_ERRORwith no writes whenorder_itemslacksai_check. - AI off:
AiDisabledErrorfromreadDocumentreturnsCOLLECTION_AI_DISABLED_ERRORand writes nothing. - Reads are cached (Review Focus #1):
- A file with an existing
collection_doc_readsrow (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.
- A file with an existing
- PDFs: a
.pdfattachment gets astatus: "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
findingsinclude the "covers 2 POs" and "M8x25 … not marked collected" strings from Task 3.
- SKU fill:
- An empty SKU with
same_item"sure" is filled viaupdateOrderItemSku, andsku_filled_valueis recorded. - An existing SKU is never changed (Review Focus #2).
same_item"different" never fills.
- An empty SKU with
- Stock rows fill through
updateStockPurchaseItemwith{ 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
runWithAuditActorwithname"AI collection check"(or"AI collection check (run by <actorName>)"),source: "agent"andignoreRequestUser: true.
runCollectionAiTick:- Returns
disabledunless bothenabledandcollection_ai_enabledare on. - Applies the lease check
runningas in the quote assistant. - Records
last_run_atbefore 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_FILESor the time budget. - Isolates per-card errors into the bounded summary (20 entries of up to 300 characters each).
listCollectedItems:- Attaches
ai_docfrom the read referenced byai_check.read_id, includingfindings. - Sets
in_pos: trueandpos_stock_item_idwhen astock_itemsSKU normalises to the row's SKU. - Stock rows always get
in_pos: true. - Rows without
ai_checkgetai_check: nullandai_doc: null.
- Not set up: returns
-
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 fetchfiles.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_atwith a pairing wins. in_poslookup: chunk distinct SKUs (≤ 50) into OR'dsku ~ "<code>"filters onstock_items, then comparenormaliseCodein the app.
- Default file fetch: the admin PB
-
Step 4: Run the tests and the full suite. Run:
npx vitest runandnpx 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(addcreateStockItem, + 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 forCOLLECTION_AI_NOT_SET_UP_ERROR/COLLECTION_AI_DISABLED_ERROR, otherwise 200 withCardCheckResult.
-
MCP:
collection_confirm.ai_check({ po_number })callscheckCollectionCard(po, { actorName: "MCP" }). It is not inREAD_TOOLS. -
Step 1: Write failing tests.
createStockItem({ name: "M6 nylock", sku: "SM2NUT9B506", cost_price: 0.2 })appliesapplyDefaultMarkupToPatch(selling 0.36, markup 80) and setscurrent_qty0 andis_activetrue.- The existing create route's test (or a new one) still accepts
costPrice/sellingPrice/markupin camelCase. addRowsToPosfor a customer row:- creates the item with
name= description,description=po_descriptionor description,sku,supplier,cost_price, andunitfromai_check.doc_line.unitwhen present; - returns
already_in_poswhen a normalised SKU match exists; - returns
not_foundfor an unknown id.
- creates the item with
- Route tests cover 401, 400 (missing or empty
row_ids, missingpo_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:
ItemsTablegains optional propsonAddToPos?: (rowId: string) => voidandshowAi?: boolean.PoCardgains optionalonCheckWithAi?: () => Promise<void>andonConfirmAndAddToPos?: () => 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: nullrenders no tick icons and no robot icon. The card with noai_docrenders 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 hasai_checkorin_pos. - Ticks: a row with
ai_checkrenders four tick icons whosearia-labels come fromcomputeLineTicks("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
showsAiFilledSkuis true. - POS column: "In POS" (with a link to
/stock/<pos_stock_item_id>) whenin_pos. Otherwise an "Add to POS" button callsonAddToPos(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
onCheckWithAiand 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" anddoc_date"2026-10-16" shows those values pre-filled. With noai_check, the dialog shows today's defaults (date today, number blank).
- No AI data (Review Focus #5): a row with
-
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
handleAddToPoswith 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/mutedtokens withCheckCircle2/AlertTriangle/XCircle/MinusCircle. The robot uses lucideBotwithtext-ai. Surgical edits only.
- Page handlers:
-
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=ongives true, and missing gives false.ca_promptpasses 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.mdcovers:- what the feature does;
- the manual live steps: (1)
npm run pb:migrate:collection-ai, (2) redeploypocketbase/pb_hooks/audit_log_utils.js(ai_checkignored), (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.mdgets 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.