# AI Order Assistant 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:** Fix the rule-based order↔quote matcher, then let MiniMax match each new order to its quote (auto-linking when sure) and pre-fill order lines from the matched quote lines as purple drafts. **Architecture:** Phase 0 changes the pure scorer in `order-quote-match.ts` and splits `suggestQuoteForOrder` into a read-only `computeQuoteSuggestion` plus the write, so a clean-up script can dry-run. Phase 1 adds `order-assistant-{core,ai,prompt,service}.ts`, mirroring the quote assistant (pure core, one forced-tool AI call, orchestration with tick + run-one) and reusing the fixed matcher as the shortlist. Phase 2 adds the line fill and the purple review flow on the order Process page. **Tech Stack:** Next.js (read `node_modules/next/dist/docs/` before touching routes), TypeScript, PocketBase (admin client `getAdminPocketBase`), Anthropic SDK against MiniMax (`getActiveAiConfig`), Vitest. **Spec:** `docs/superpowers/specs/2026-10-24-ai-order-assistant-design.md` ## Global Constraints - The AI never moves a line past `pending`, never creates POs, never sends anything, never changes order status. - `orders.quote` is written only through `setOrderQuote` (`src/lib/server/orders-service.ts`), inside `runWithAuditActor({ name: "AI order assistant" }, …)`. - Auto-link only when: setting `order_assistant_auto_link` on, AI `confidence === "high"`, every real order line paired with `same_item === "sure"`, all pairs on lines of the chosen quote, quote company = order company. Otherwise suggest. - `potential_quote_confidence` mapping: high 0.9, medium 0.7, low 0.5. - `quote_match_method` values: `ai_auto`, `ai_suggest`, `ai_no_match` (text field, no select change). - Order line fill: `pricing_source = "ai_quote"`. Never overwrite a line with `cost_price > 0`, a person-set supplier, `pricing_source` `manual`/`quote`, or status ≠ `pending`. The customer's `selling_price` is never overwritten; >5% difference from the quote's selling price becomes a doubt. - Pending review (purple): `ai_fill.status === "filled" && !ai_fill.reviewed_at && pricing_source === "ai_quote"`. - Tick: at most 10 orders per run, 30 order lines per AI call, shortlist of 5 quotes, `ERROR_RETRY_MS` (1 h) after an error, default interval 10 min (min 5). - New JSON/text PB fields get an explicit `max`/`maxSize` (text `max: 0` caps at 5000). - New AI-written fields (`ai_quote_match`, `ai_fill`) go in `IGNORED_FIELDS` in `pocketbase/pb_hooks/audit_log_utils.js`. - New server logic lives in `src/lib/server/*-service.ts` behind `/api/v1/*`; do not extend `src/lib/pb-helpers.ts`. If a page you touch reads the same data via `pb-helpers`, migrate that call in the same commit. - Spec phase 2b (POS fallback for lines with no quote line) is **deferred**: no setting, no code in this plan. - Never run MCP write tools or scripts with `--apply` against live PB during development; tests use mocks. ## Review Focus - Order whose only lines are placeholders ("(see attached file - buyer to add items)") or cancelled → not eligible, no AI call, nothing written. (Task 4) - A shortlisted quote already hard-linked to another order → dropped from the shortlist; if `setOrderQuote` still 409s, the result falls back to a suggestion, never an error. (Tasks 2, 6) - A person types a cost on the Process page after the AI read the line but before it wrote → line re-read right before the write and skipped. (Task 10) - AI returns an id it wasn't sent, or pairs two order lines to one quote line → unknown ids dropped, second claimant unpaired. (Task 5) - Buyer adds lines to an order after an AI suggestion → `lines_hash` changes, the order is eligible again; an already-linked order is never re-matched but its new lines still get filled. (Tasks 4, 10) --- ## Phase 0 — fix the rule-based matcher ### Task 1: A quote needs a matching line to score **Files:** - Modify: `src/lib/server/order-quote-match.ts` (`scoreOrderVsQuote`) - Test: `src/lib/server/order-quote-match.test.ts` **Interfaces:** - Produces: `scoreOrderVsQuote` unchanged signature; `confidence === 0` whenever no order line pairs with a quote line. `company`/`customer` points still reported in the breakdown. - [ ] **Step 1: Write the failing tests** in a new `describe("scoreOrderVsQuote — needs a paired line")`: ```ts it("scores 0 when company and customer match but no line pairs (270555-MOB vs Q002997)", () => { const r = scoreOrderVsQuote( { companyId: "co", customerId: "cu", items: [{ description: 'Funnel 150mm PVC round 2 piece Flexible ("6") Round', selling_price: 16.64, quantity: 1 }] }, { company: "co", customer: "cu", items: [{ description: "22mm endurance +", selling_price: 1655.76, quantity: 1 }] }, ); expect(r.company).toBe(RULE_POINTS.company); expect(r.customer).toBe(RULE_POINTS.customer); expect(r.confidence).toBe(0); }); it("scores 0 against a quote with no lines", () => { const r = scoreOrderVsQuote( { companyId: "co", customerId: "cu", items: [{ description: "M16 bolt", selling_price: 3, quantity: 10 }] }, { company: "co", customer: "cu", items: [] }, ); expect(r.confidence).toBe(0); }); it("still scores above threshold when one line pairs", () => { const r = scoreOrderVsQuote( { companyId: "co", customerId: "cu", items: [{ description: "Flat washer M16 zinc", selling_price: 1, quantity: 100 }] }, { company: "co", customer: "cu", items: [{ description: "Flat washer M16 zinc plated", selling_price: 1, quantity: 100 }] }, ); expect(r.confidence).toBeGreaterThanOrEqual(MATCH_CONFIDENCE_THRESHOLD); }); ``` - [ ] **Step 2: Run** `npx vitest run src/lib/server/order-quote-match.test.ts` — Expected: first two FAIL (0.5 / 1 instead of 0). - [ ] **Step 3: Implement** — in `scoreOrderVsQuote`, when `pairs.length === 0` return the breakdown with `confidence: 0` (keep `raw` and the header fields as computed). Update the block comment above `RULE_POINTS` with one line: "No paired line → confidence 0; header signals alone never suggest a quote." - [ ] **Step 4: Run** `npx vitest run src/lib/server/order-quote-match.test.ts src/lib/server/order-quote-link-service.test.ts` — Expected: PASS. If an existing test relied on header-only confidence, change its fixture to include a pairing line and say so in the commit message. - [ ] **Step 5: Commit** `fix(orders): never suggest a quote that shares no line with the order` ### Task 2: Read-only suggestion + stale-suggestion clean-up script **Files:** - Modify: `src/lib/server/order-quote-link-service.ts` - Create: `scripts/2026-10-24-rematch-quote-suggestions.ts` - Test: `src/lib/server/order-quote-link-service.test.ts` **Interfaces:** - Produces: - `computeQuoteSuggestion(orderId: string): Promise` — everything `suggestQuoteForOrder` does except `writeSuggestion`. Candidates whose quote is already `orders.quote` of a **different** order are dropped (one `getFullList` on `orders` with `quote` in the candidate ids, fields `id,quote`). - `suggestQuoteForOrder(orderId)` = `computeQuoteSuggestion` + `writeSuggestion` (behaviour otherwise unchanged). - `alternatives` stays `scored.slice(1, 6)` so best + alternatives gives up to 6; Task 6 takes the first 5. - [ ] **Step 1: Write failing tests** - `computeQuoteSuggestion` never calls `orders.update` (assert the mock's `update` not called) and returns the same `best` that `suggestQuoteForOrder` writes. - A candidate quote that another order already links to is not returned as `best` or in `alternatives`. - [ ] **Step 2: Run** `npx vitest run src/lib/server/order-quote-link-service.test.ts` — Expected: FAIL (`computeQuoteSuggestion` not exported). - [ ] **Step 3: Implement** the split and the linked-elsewhere filter (chunk ids with the same `IN`/OR fallback approach as `fetchQuoteItemsForQuotesAdmin`, or simply 50 ids per OR-chain). - [ ] **Step 4: Write the script** following `scripts/2026-10-22-repair-short-splits.ts` (`import "./_env"`, dry run unless `--apply`, `runWithAuditActor({ id: "claude-quote-rematch", name: "Claude (quote suggestion clean-up)" }, …)`): - Select orders `potential_quote != "" && quote = ""`. - Dry run: `computeQuoteSuggestion` per order; print `order_number`, old quote → new quote/none, and a summary: count per old `potential_quote` (expect the 16×/11× repeats to disappear), cleared, changed, unchanged. - `--apply`: `suggestQuoteForOrder` per order, sequential. - [ ] **Step 5: Run** tests + `npx tsc --noEmit` — Expected: PASS, no type errors. Do **not** run the script against live PB; the owner runs the dry run. - [ ] **Step 6: Commit** `feat(orders): read-only quote suggestion and stale-suggestion clean-up script` --- ## Phase 1 — AI match ### Task 3: Fields, settings and audit ignore list **Files:** - Create: `scripts/migrations/2026-10-24-add-order-assistant-fields.ts` (copy the shape of `2026-10-16-add-quote-assistant-fields.ts`) - Modify: `package.json` (script `pb:migrate:order-assistant`), `src/lib/types.ts`, `src/lib/server/ai-settings-service.ts`, `pocketbase/pb_hooks/audit_log_utils.js` - Test: `src/lib/server/ai-settings-service.test.ts` **Interfaces:** - Produces (types in `src/lib/types.ts`): - `OrderAiQuoteMatch { status: "matched" | "suggested" | "no_match" | "error"; quote_id: string | null; quote_number: string; confidence: "high" | "medium" | "low" | null; reason: string; doubts: string[]; lines: OrderAiLinePair[]; auto_linked: boolean; lines_hash: string; model: string; run_at: string; error?: string }` - `OrderAiLinePair { order_line_id: string; quote_line_id: string | null; quote_id: string | null; same_item: "sure" | "likely" | "unsure" | null; reason: string }` - `OrderItemAiFill { status: "filled" | "skipped" | "error"; source: "quote"; quote_item_id: string; quote_id: string; quote_number: string; cost_price: number; selling_price: number; markup: number; supplier_id: string; sku: string; reason: string; doubts: string[]; model: string; attempted_at: string; reviewed_at?: string; reviewed_by?: string; accepted_unchanged?: boolean; supplier_changed?: boolean }` - `Order.ai_quote_match?: OrderAiQuoteMatch | null`, `OrderItem.ai_fill?: OrderItemAiFill | null`, `OrderAssistantRunSummary { started_at; finished_at; orders_seen; linked; suggested; no_match; lines_filled; errors: string[] }`. - `AiSettings` + `AiSettingsUpdate`: `order_assistant_enabled` (false), `order_assistant_interval_minutes` (10, 5–1440), `order_assistant_auto_link` (true), `order_assistant_fill_lines` (true), `order_assistant_prompt` (""), `order_assistant_last_run_at` (""), `order_assistant_last_run_summary` (null). - `recordOrderAssistantRun(p: { last_run_at?: string; summary?: OrderAssistantRunSummary }): Promise` (mirror `recordQuoteAssistantRun`). - Migration fields: `orders.ai_quote_match` json (maxSize 65536), `order_items.ai_fill` json (maxSize 16384), the seven `ai_settings` fields (prompt text max 50000, last_run_at text max 40, summary json). - [ ] **Step 1: Write failing tests** in `ai-settings-service.test.ts`: defaults above when the row has none; interval < 5 rejected with `order_assistant_interval_minutes must be between 5 and 1440`; `order_assistant_auto_link` round-trips `false`. - [ ] **Step 2: Run** `npx vitest run src/lib/server/ai-settings-service.test.ts` — Expected: FAIL. - [ ] **Step 3: Implement** types, settings normalise/update, `recordOrderAssistantRun`, migration (idempotent, skips existing fields), `package.json` script, and add `ai_quote_match: 1, ai_fill: 1` to `IGNORED_FIELDS`. - [ ] **Step 4: Run** tests + `npx tsc --noEmit` — Expected: PASS. - [ ] **Step 5: Commit** `feat(orders): fields and settings for the AI order assistant` ### Task 4: Pure core — eligibility, outcome, fingerprint **Files:** - Create: `src/lib/server/order-assistant-core.ts` - Test: `src/lib/server/order-assistant-core.test.ts` **Interfaces:** - Consumes: types from Task 3; `ERROR_RETRY_MS` from `quote-assistant-core`. - Produces: - `PLACEHOLDER_LINE_PATTERNS: RegExp[]` — at least `/see attached/i`, `/buyer to add items/i`. - `isRealOrderLine(line: { description?: string; item_status?: string }): boolean` — false for empty description, a placeholder, or `item_status === "cancelled"`. - `orderLinesHash(lines: Array<{ id: string; description?: string; quantity?: number }>): string` — stable over line order (sort by id), changes when a line is added or a description/qty changes. - `type OrderItemLite = Pick` (exported). - `isOrderEligible(order: { quote?: string; ai_quote_match?: OrderAiQuoteMatch | null }, lines: OrderItemLite[], opts: { now: Date; force?: boolean }): boolean` — no `quote`; ≥1 real line; ≥1 real line `pending`; not (`ai_quote_match.lines_hash === orderLinesHash(real lines)` unless `force`); not `status === "error"` within `ERROR_RETRY_MS` of `run_at`. - `decideOutcome(args: { decision: AiOrderMatchDecision; realLineIds: string[]; quoteLineToQuote: Map; orderCompany: string; quoteCompany: string | null; autoLinkEnabled: boolean }): "auto_link" | "suggest" | "no_match"` — implements the Global Constraints auto-link rule; `quote_id === null` → `no_match`; low confidence with no pair on the chosen quote → `no_match`. - `confidenceToScore(c: "high" | "medium" | "low" | null): number` — 0.9 / 0.7 / 0.5 / 0. - `AiOrderMatchDecision` (defined here, used by Task 5): `{ quote_id: string | null; confidence: "high" | "medium" | "low"; reason: string; doubts: string[]; lines: Array<{ order_line_id: string; quote_line_id: string | null; same_item: "sure" | "likely" | "unsure" | null; reason: string }> }`. - [ ] **Step 1: Write failing tests** — table-driven for `decideOutcome`: all sure + high → `auto_link`; one line `likely` → `suggest`; one line unpaired → `suggest`; one pair on another quote's line → `suggest`; company differs → `suggest`; `autoLinkEnabled: false` → `suggest`; `quote_id: null` → `no_match`. `isOrderEligible`: placeholder-only order → false; all real lines `po_generated` → false; same hash → false, `force` → true; error 30 min ago → false, 61 min ago → true. `orderLinesHash` same for shuffled input, different after a qty change. - [ ] **Step 2: Run** `npx vitest run src/lib/server/order-assistant-core.test.ts` — Expected: FAIL (module missing). - [ ] **Step 3: Implement** the functions above (hash: sha1 of the sorted `id|description|quantity` lines via `node:crypto`). - [ ] **Step 4: Run** — Expected: PASS. - [ ] **Step 5: Commit** `feat(orders): order assistant eligibility and auto-link rule` ### Task 5: The AI call **Files:** - Create: `src/lib/server/order-assistant-prompt.ts`, `src/lib/server/order-assistant-ai.ts` - Test: `src/lib/server/order-assistant-ai.test.ts` **Interfaces:** - Consumes: `AiOrderMatchDecision` (Task 4); `getActiveAiConfig`, `newAnthropic` as used in `collection-ai-pair.ts`. - Produces: - `DEFAULT_ORDER_ASSISTANT_PROMPT: string` — must say: a quote match needs matching items, not just the same customer; quantities may differ (part orders); prices may differ slightly; a line may have no quote line; answer only with ids that were given. - `SUBMIT_ORDER_QUOTE_MATCH_TOOL` — forced tool `submit_order_quote_match`, schema = `AiOrderMatchDecision`. - `MatchInput { order: { id; order_number; customer_name; lines: Array<{ id; description; po_description; quantity; sku; selling_price }> }; quotes: Array<{ id; quote_number; created; lines: Array<{ id; description; quantity; selling_price; sku; supplier_name }> }> }` - `buildMatchUserText(input: MatchInput): string` - `parseOrderQuoteMatch(raw: unknown, ids: { orderLineIds: Set; quoteIds: Set; quoteLineIds: Set }): AiOrderMatchDecision` — unknown quote id → `quote_id: null`; unknown line ids dropped; a quote line claimed twice → second order line gets `quote_line_id: null`; order lines the AI skipped come back unpaired; bad confidence → `"low"`. - `matchOrderWithAi(input: MatchInput, deps?: { client?: Pick; prompt?: string }): Promise<{ decision: AiOrderMatchDecision; model: string }>` — throws on `max_tokens` cut-off or missing tool call (same messages as `pairDocumentLines`). - [ ] **Step 1: Write failing tests** for `parseOrderQuoteMatch` (each rule above) and `matchOrderWithAi` with a fake client returning a tool_use block (happy path, missing tool call throws, `stop_reason: "max_tokens"` throws). Assert `buildMatchUserText` contains every order line id and quote line id and never the quote's `email_body`. - [ ] **Step 2: Run** `npx vitest run src/lib/server/order-assistant-ai.test.ts` — Expected: FAIL. - [ ] **Step 3: Implement.** `max_tokens: 4096`; prompt = `deps.prompt || DEFAULT_ORDER_ASSISTANT_PROMPT`. - [ ] **Step 4: Run** — Expected: PASS. - [ ] **Step 5: Commit** `feat(orders): AI call that matches an order to a quote` ### Task 6: Orchestration — match one order, and the tick **Files:** - Create: `src/lib/server/order-assistant-service.ts` - Test: `src/lib/server/order-assistant-service.test.ts` **Interfaces:** - Consumes: `computeQuoteSuggestion` (Task 2), Task 4 core, `matchOrderWithAi` (Task 5), `setOrderQuote` (`orders-service.ts`), `fetchQuoteItemsForQuotesAdmin`, `runWithAuditActor`, settings + `recordOrderAssistantRun` (Task 3). - Produces: - `MatchOrderResult { orderId: string; outcome: "linked" | "suggested" | "no_match" | "skipped" | "error"; quoteId: string | null; error: string }` - `OrderAssistantDeps { match?: typeof matchOrderWithAi; suggest?: typeof computeQuoteSuggestion; link?: typeof setOrderQuote; now?: () => Date }` - `matchOrder(orderId: string, opts?: { force?: boolean; deps?: OrderAssistantDeps }): Promise` - `runOrderAssistantTick(deps?: OrderAssistantDeps): Promise<{ ran: boolean; reason?: "disabled" | "not_due" | "running"; summary?: OrderAssistantRunSummary }>` — same due/lease/stamp logic as `runAssistantTick` (copy the constants `TICK_LEASE_MS`, `TICK_TIME_BUDGET_MS`), `TICK_MAX_ORDERS = 10`, oldest eligible first among orders with `quote = ""` created in the last 30 days. - `NOT_SET_UP_ERROR` when `orders.ai_quote_match` is missing on the live PB (same field check as `hasAiFillField`). `matchOrder` flow: load order + lines → `isOrderEligible` (else `skipped`) → shortlist = `best` + `alternatives`, first 5 → empty shortlist writes `ai_quote_match.status = "no_match"`, `quote_match_method = "ai_no_match"` with no AI call → load quote lines (+ supplier names) → `matchOrderWithAi` with real lines only, batches of 30 lines (same shortlist, decisions merged; overall confidence = lowest batch) → `decideOutcome` → - `auto_link`: `runWithAuditActor({ name: "AI order assistant" }, () => setOrderQuote({ orderId, quoteId, actor }))`; then write each AI pair onto `order_items.quote_item` where it is still empty; `quote_match_method = "ai_auto"`, `auto_linked: true`. If `setOrderQuote` throws 409, fall through to suggest. - `suggest`: `potential_quote`, `potential_quote_confidence = confidenceToScore(...)`, `quote_match_method = "ai_suggest"`; no `quote_item` writes. - always: `ai_quote_match` with `lines_hash`, `model`, `run_at`; AI errors → `status: "error"`, `outcome: "error"`, never thrown. - [ ] **Step 1: Write failing tests** with fake deps + the `makePb` mock pattern from `order-quote-link-service.test.ts`: auto-link calls `link` once and writes `quote_item` only on empty lines; 409 from `link` → `suggested`; medium confidence → `potential_quote` written, `link` not called; empty shortlist → `match` not called; AI throws → `ai_quote_match.status === "error"`; placeholder-only order → `skipped`, nothing written; tick returns `disabled` when either switch is off and stops at 10 orders. - [ ] **Step 2: Run** `npx vitest run src/lib/server/order-assistant-service.test.ts` — Expected: FAIL. - [ ] **Step 3: Implement.** - [ ] **Step 4: Run** the file + `npx tsc --noEmit` — Expected: PASS. - [ ] **Step 5: Commit** `feat(orders): AI order assistant matches orders to quotes` ### Task 7: Routes, MCP tool, n8n runbook **Files:** - Create: `src/app/api/v1/orders/assistant/tick/route.ts` (+ `route.test.ts`), `src/app/api/v1/orders/[id]/assistant/run/route.ts` (+ `route.test.ts`), `docs/order-assistant.md` - Modify: `src/app/api/mcp/route.ts`, `AGENTS.md` **Interfaces:** - Consumes: `runOrderAssistantTick`, `matchOrder` (Task 6). - Produces: - `POST /api/v1/orders/assistant/tick` — `assertAgentAuth`, returns the tick result (copy `quotes/assistant/tick/route.ts`). - `POST /api/v1/orders/{id}/assistant/run` — `getBrowserSessionUser(req)` (401 without), `matchOrder(id, { force: true })`; 409 for `NOT_SET_UP_ERROR` / AI disabled, like `quotes/[id]/assistant/run`. Later Task 10 adds the fill to this route. - MCP `orders.assistant_match` `{ orderId: string }` → `matchOrder(orderId, { force: true })`, with a `TOOL_HINTS` entry. Not in `READ_TOOLS` (ordinary write, approval card in Giellie). - [ ] **Step 1: Write failing route tests** (copy the quote-assistant route tests): 401 without auth on each route; tick passes through `{ ran: false, reason: "disabled" }`; run returns 409 with the not-set-up message. - [ ] **Step 2: Run** `npx vitest run src/app/api/v1/orders/assistant src/app/api/v1/orders/\[id\]/assistant` — Expected: FAIL. - [ ] **Step 3: Implement** routes and the MCP tool; read the relevant guide in `node_modules/next/dist/docs/` for route handler params first. - [ ] **Step 4: Write `docs/order-assistant.md`** (structure of `docs/quote-assistant.md`): what it does, auto-link rule in plain words, n8n workflow "WFDASH Order assistant tick" (Schedule 5 min → `POST https://wfdash.apphub.co.za/api/v1/orders/assistant/tick`, same Header Auth credential as the quote assistant tick, retry off, timeout 120000 ms, inactive until deployed), and the live steps in order: migrate → `IGNORED_FIELDS` hook publish → deploy → clean-up script dry run then `--apply` → n8n → switch on. Add a short `# AI order assistant` section to `AGENTS.md` like the quote assistant one. - [ ] **Step 5: Run** tests — Expected: PASS. - [ ] **Step 6: Commit** `feat(orders): order assistant routes, MCP tool and runbook` ### Task 8: Order page — show the AI result, "Match with AI" **Files:** - Modify: `src/app/(app)/orders/[id]/page.tsx` (the quote match card, ~lines 1255–1520) - Create: `src/components/orders/ai-quote-match-panel.tsx` (+ test if the repo tests components in that folder) **Interfaces:** - Consumes: `Order.ai_quote_match` (Task 3), `POST /api/v1/orders/{id}/assistant/run` (Task 7), `AiFillMarker` styling (`bg-ai/10`, lucide `Bot`). - Produces: `` — shows "Linked by AI" (when `auto_linked` and `order.quote === match.quote_id`) or "AI suggests Q…", the reason, doubts as a list, and a line-pair table (order line → quote line, `same_item`). - [ ] **Step 1:** In the match card: hard-link state with `quote_match_method === "ai_auto"` shows the panel with "Linked by AI" next to the existing Unlink; suggestion state with `ai_suggest` shows the panel above the existing Accept button; all states get a **Match with AI** button calling the run route with the PB token in `Authorization`, toast on 409 text, then reload the order. - [ ] **Step 2:** `npx tsc --noEmit` and `npx vitest run` for any touched tests — Expected: PASS. - [ ] **Step 3: Verify in the browser** (dev server on :4001, `agent-browser`): open an order with a mocked/locally-seeded `ai_quote_match`, check both states render and the button posts to the run route. Screenshot. - [ ] **Step 4: Commit** `feat(orders): show AI quote match on the order page` ### Task 9: Settings → AI "Order assistant" **Files:** - Modify: `src/app/(app)/settings/ai/ai-settings-form.tsx`, `src/app/(app)/settings/ai/settings-form-helpers.ts` (+ test), `src/app/(app)/settings/ai/page.tsx` if it maps fields **Interfaces:** - Consumes: Task 3 settings fields; `DEFAULT_ORDER_ASSISTANT_PROMPT` (Task 5) for "Reset to default". - Produces: section "Order assistant": enabled, run every (minutes), auto-link when sure, fill lines from the quote, prompt with Reset to default, last run summary — same layout as the Quote assistant section. - [ ] **Step 1: Write failing helper test**: form → update payload includes the five editable `order_assistant_*` fields and omits `last_run_*`. - [ ] **Step 2: Run** `npx vitest run src/app/\(app\)/settings/ai` — Expected: FAIL. - [ ] **Step 3: Implement** the helper and the section. - [ ] **Step 4: Run** — Expected: PASS; browser check of Save → reload keeps values. - [ ] **Step 5: Commit** `feat(settings): order assistant settings` --- ## Phase 2 — fill order lines from the quote ### Task 10: Fill patch and `fillOrderFromQuote` **Files:** - Modify: `src/lib/server/order-assistant-core.ts`, `src/lib/server/order-assistant-service.ts`, `src/lib/server/orders-service.ts` (after `pairOrderLinesSafely` in `setOrderQuote`), `src/app/api/v1/orders/[id]/assistant/run/route.ts` - Test: `order-assistant-core.test.ts`, `order-assistant-service.test.ts` **Interfaces:** - Produces: - `isOrderLineWritable(line: OrderItem): boolean` — `item_status === "pending"`, `!(Number(cost_price) > 0)`, `pricing_source` not `manual`/`quote`, `ai_fill?.reviewed_at` empty. - `buildOrderLineFillPatch(line: OrderItem, quoteLine: QuoteItem, ctx: { quoteNumber: string; reason: string; model: string; now: Date }): Record | null` — `null` when not writable or the quote line has no cost; otherwise `cost_price`, `supplier` (only if empty), `sku` (only if empty), `matched_price_list_id` (only if empty), `selling_price` (only if line has none), `markup` + `markup_percent` from the resulting cost/selling, `pricing_source: "ai_quote"`, `ai_fill` (`OrderItemAiFill`, `status: "filled"`). Doubt `customer price R{x}, quoted R{y}` when both prices > 0 and differ by more than 5%. - `fillOrderFromQuote(orderId: string, deps?: OrderAssistantDeps): Promise<{ filled: number; skipped: number }>` — for lines with `quote_item`, **re-read each line right before writing**, write `buildOrderLineFillPatch`. Reason = the AI pair reason from `ai_quote_match.lines` if present, else "Paired with quote line by description". Runs inside `runWithAuditActor({ name: "AI order assistant" }, …)`. No AI call. - When a person links (`setOrderQuote`) to the quote the AI suggested (`ai_quote_match.quote_id === quoteId`), the AI pairs are written onto empty `order_items.quote_item` **before** `pairOrderLines` runs, so the AI pairing wins over word matching; a person-set `quote_item` is never overwritten. - Callers: `matchOrder` after a successful auto-link; `setOrderQuote` after pairing, only when `order_assistant_enabled && order_assistant_fill_lines` (failure logged, never fails the link); the tick for linked orders with writable lines that have a `quote_item` and no `ai_fill`; the run route after `matchOrder`. - [ ] **Step 1: Write failing tests**: patch never overwrites a non-empty supplier or SKU; never writes `selling_price` when the line has one; returns `null` for `po_generated`, for `cost_price: 12.8`, for `pricing_source: "manual"`; adds the 5% doubt for 16.64 vs 18.20 and not for 16.64 vs 17.00; service skips a line whose re-read shows a cost. - [ ] **Step 2: Run** `npx vitest run src/lib/server/order-assistant-core.test.ts src/lib/server/order-assistant-service.test.ts` — Expected: FAIL. - [ ] **Step 3: Implement.** - [ ] **Step 4: Run** the two files plus `src/lib/server/orders-service.test.ts` (or whichever file covers `setOrderQuote`) — Expected: PASS. - [ ] **Step 5: Commit** `feat(orders): AI fills order lines from the matched quote` ### Task 11: Purple review on the order Process page and the Orders board **Files:** - Create: `src/lib/order-assistant-review.ts` (+ test) - Modify: `src/app/(app)/orders/[id]/process/page.tsx` (row class, marker, `saveItem`), Orders board row component (find where order rows render on `/orders`), `src/components/ai-fill-marker.tsx` only if it needs a prop for "from quote" - Test: `src/lib/order-assistant-review.test.ts` **Interfaces:** - Produces: - `isOrderAiFillPending(item: Pick): boolean` (Global Constraints rule). - `nextOrderPricingSourceOnSave(current: string | undefined): string` — keeps `"ai_quote"`, `"quote"`, `"catalogue"`; else `"manual"`. - `buildOrderAiReviewPatch(item, saved: { cost_price: number; selling_price: number; supplier: string | null }, reviewer: { id?: string; name: string }, now: Date): { ai_fill?: OrderItemAiFill }` — same semantics as `buildAiReviewPatch`. - `orderAiRowClass(item): string` → `"bg-ai/10"` when pending. - `getOrderAssistantCounts(orderIds: string[]): Promise>` in `order-assistant-service.ts`, exposed through the existing orders list data path, rendered with `AiBoardBadge`. - [ ] **Step 1: Write failing tests** for the four client helpers (pending only while unreviewed and `ai_quote`; a stale save keeps `ai_quote`; review patch sets `accepted_unchanged` true when cost and selling match to the cent). - [ ] **Step 2: Run** `npx vitest run src/lib/order-assistant-review.test.ts` — Expected: FAIL. - [ ] **Step 3: Implement** the helpers; on the Process page apply `orderAiRowClass`, show `AiFillMarker` with quote number (link to the quote), reason, doubts; route the line save through `nextOrderPricingSourceOnSave` + `buildOrderAiReviewPatch`; the existing "use this" copy sets `pricing_source: "quote"` and therefore ends the purple state. Add the board badge. - [ ] **Step 4: Run** tests + `npx tsc --noEmit` — Expected: PASS. - [ ] **Step 5: Verify in the browser** (:4001, `agent-browser`): a pending line is purple with the marker; saving it clears the purple; the board shows "AI x/y". Screenshot. - [ ] **Step 6: Commit** `feat(orders): purple review for AI-filled order lines` ### Task 12: Full check - [ ] **Step 1: Run** `npx vitest run` and `npx tsc --noEmit` and `npm run build` — Expected: all pass. Report failures with output; do not claim done otherwise. - [ ] **Step 2:** Update `docs/order-assistant.md` with anything that changed while building; confirm the live-steps list matches the code (migration name, `IGNORED_FIELDS`, n8n URL, settings labels). - [ ] **Step 3: Commit** `docs(orders): order assistant runbook final pass`