WFDASH-WEBAPP/docs/superpowers/plans/2026-10-24-ai-order-assistant.md
Dave e0058e02d9 docs(plan): AI order assistant implementation plan
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 11:41:21 +02:00

30 KiB
Raw Blame History

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"):

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<SuggestQuoteResult> — 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<void> (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<OrderItem, "id" | "description" | "quantity" | "item_status"> (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<string, string>; 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<string>; quoteIds: Set<string>; quoteLineIds: Set<string> }): 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<Anthropic, "messages">; 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<MatchOrderResult>
    • 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: <AiQuoteMatchPanel match={OrderAiQuoteMatch} orderLines={…} quoteLines={…} /> — 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<string, unknown> | 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<OrderItem, "ai_fill" | "pricing_source">): 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<Record<string, { pending: number; total: number }>> 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