30 KiB
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.quoteis written only throughsetOrderQuote(src/lib/server/orders-service.ts), insiderunWithAuditActor({ name: "AI order assistant" }, …).- Auto-link only when: setting
order_assistant_auto_linkon, AIconfidence === "high", every real order line paired withsame_item === "sure", all pairs on lines of the chosen quote, quote company = order company. Otherwise suggest. potential_quote_confidencemapping: high 0.9, medium 0.7, low 0.5.quote_match_methodvalues:ai_auto,ai_suggest,ai_no_match(text field, no select change).- Order line fill:
pricing_source = "ai_quote". Never overwrite a line withcost_price > 0, a person-set supplier,pricing_sourcemanual/quote, or status ≠pending. The customer'sselling_priceis 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(textmax: 0caps at 5000). - New AI-written fields (
ai_quote_match,ai_fill) go inIGNORED_FIELDSinpocketbase/pb_hooks/audit_log_utils.js. - New server logic lives in
src/lib/server/*-service.tsbehind/api/v1/*; do not extendsrc/lib/pb-helpers.ts. If a page you touch reads the same data viapb-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
--applyagainst 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
setOrderQuotestill 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_hashchanges, 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:
scoreOrderVsQuoteunchanged signature;confidence === 0whenever no order line pairs with a quote line.company/customerpoints 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, whenpairs.length === 0return the breakdown withconfidence: 0(keeprawand the header fields as computed). Update the block comment aboveRULE_POINTSwith 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>— everythingsuggestQuoteForOrderdoes exceptwriteSuggestion. Candidates whose quote is alreadyorders.quoteof a different order are dropped (onegetFullListonorderswithquotein the candidate ids, fieldsid,quote).suggestQuoteForOrder(orderId)=computeQuoteSuggestion+writeSuggestion(behaviour otherwise unchanged).alternativesstaysscored.slice(1, 6)so best + alternatives gives up to 6; Task 6 takes the first 5.
-
Step 1: Write failing tests
computeQuoteSuggestionnever callsorders.update(assert the mock'supdatenot called) and returns the samebestthatsuggestQuoteForOrderwrites.- A candidate quote that another order already links to is not returned as
bestor inalternatives.
-
Step 2: Run
npx vitest run src/lib/server/order-quote-link-service.test.ts— Expected: FAIL (computeQuoteSuggestionnot exported). -
Step 3: Implement the split and the linked-elsewhere filter (chunk ids with the same
IN/OR fallback approach asfetchQuoteItemsForQuotesAdmin, 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:
computeQuoteSuggestionper order; printorder_number, old quote → new quote/none, and a summary: count per oldpotential_quote(expect the 16×/11× repeats to disappear), cleared, changed, unchanged. --apply:suggestQuoteForOrderper order, sequential.
- Select orders
-
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 of2026-10-16-add-quote-assistant-fields.ts) - Modify:
package.json(scriptpb: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>(mirrorrecordQuoteAssistantRun).
-
Migration fields:
orders.ai_quote_matchjson (maxSize 65536),order_items.ai_filljson (maxSize 16384), the sevenai_settingsfields (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 withorder_assistant_interval_minutes must be between 5 and 1440;order_assistant_auto_linkround-tripsfalse. -
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.jsonscript, and addai_quote_match: 1, ai_fill: 1toIGNORED_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_MSfromquote-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, oritem_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— noquote; ≥1 real line; ≥1 real linepending; not (ai_quote_match.lines_hash === orderLinesHash(real lines)unlessforce); notstatus === "error"withinERROR_RETRY_MSofrun_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 linelikely→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 linespo_generated→ false; same hash → false,force→ true; error 30 min ago → false, 61 min ago → true.orderLinesHashsame 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|quantitylines vianode: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,newAnthropicas used incollection-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 toolsubmit_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): stringparseOrderQuoteMatch(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 getsquote_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 onmax_tokenscut-off or missing tool call (same messages aspairDocumentLines).
-
Step 1: Write failing tests for
parseOrderQuoteMatch(each rule above) andmatchOrderWithAiwith a fake client returning a tool_use block (happy path, missing tool call throws,stop_reason: "max_tokens"throws). AssertbuildMatchUserTextcontains every order line id and quote line id and never the quote'semail_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 asrunAssistantTick(copy the constantsTICK_LEASE_MS,TICK_TIME_BUDGET_MS),TICK_MAX_ORDERS = 10, oldest eligible first among orders withquote = ""created in the last 30 days.NOT_SET_UP_ERRORwhenorders.ai_quote_matchis missing on the live PB (same field check ashasAiFillField).
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 ontoorder_items.quote_itemwhere it is still empty;quote_match_method = "ai_auto",auto_linked: true. IfsetOrderQuotethrows 409, fall through to suggest. -
suggest:potential_quote,potential_quote_confidence = confidenceToScore(...),quote_match_method = "ai_suggest"; noquote_itemwrites. -
always:
ai_quote_matchwithlines_hash,model,run_at; AI errors →status: "error",outcome: "error", never thrown. -
Step 1: Write failing tests with fake deps + the
makePbmock pattern fromorder-quote-link-service.test.ts: auto-link callslinkonce and writesquote_itemonly on empty lines; 409 fromlink→suggested; medium confidence →potential_quotewritten,linknot called; empty shortlist →matchnot called; AI throws →ai_quote_match.status === "error"; placeholder-only order →skipped, nothing written; tick returnsdisabledwhen 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 (copyquotes/assistant/tick/route.ts).POST /api/v1/orders/{id}/assistant/run—getBrowserSessionUser(req)(401 without),matchOrder(id, { force: true }); 409 forNOT_SET_UP_ERROR/ AI disabled, likequotes/[id]/assistant/run. Later Task 10 adds the fill to this route.- MCP
orders.assistant_match{ orderId: string }→matchOrder(orderId, { force: true }), with aTOOL_HINTSentry. Not inREAD_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 ofdocs/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_FIELDShook publish → deploy → clean-up script dry run then--apply→ n8n → switch on. Add a short# AI order assistantsection toAGENTS.mdlike 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),AiFillMarkerstyling (bg-ai/10, lucideBot). -
Produces:
<AiQuoteMatchPanel match={OrderAiQuoteMatch} orderLines={…} quoteLines={…} />— shows "Linked by AI" (whenauto_linkedandorder.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 withai_suggestshows the panel above the existing Accept button; all states get a Match with AI button calling the run route with the PB token inAuthorization, toast on 409 text, then reload the order. -
Step 2:
npx tsc --noEmitandnpx vitest runfor any touched tests — Expected: PASS. -
Step 3: Verify in the browser (dev server on :4001,
agent-browser): open an order with a mocked/locally-seededai_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.tsxif 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 omitslast_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(afterpairOrderLinesSafelyinsetOrderQuote),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_sourcenotmanual/quote,ai_fill?.reviewed_atempty.buildOrderLineFillPatch(line: OrderItem, quoteLine: QuoteItem, ctx: { quoteNumber: string; reason: string; model: string; now: Date }): Record<string, unknown> | null—nullwhen not writable or the quote line has no cost; otherwisecost_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_percentfrom the resulting cost/selling,pricing_source: "ai_quote",ai_fill(OrderItemAiFill,status: "filled"). Doubtcustomer 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 withquote_item, re-read each line right before writing, writebuildOrderLineFillPatch. Reason = the AI pair reason fromai_quote_match.linesif present, else "Paired with quote line by description". Runs insiderunWithAuditActor({ 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 emptyorder_items.quote_itembeforepairOrderLinesruns, so the AI pairing wins over word matching; a person-setquote_itemis never overwritten. - Callers:
matchOrderafter a successful auto-link;setOrderQuoteafter pairing, only whenorder_assistant_enabled && order_assistant_fill_lines(failure logged, never fails the link); the tick for linked orders with writable lines that have aquote_itemand noai_fill; the run route aftermatchOrder.
-
Step 1: Write failing tests: patch never overwrites a non-empty supplier or SKU; never writes
selling_pricewhen the line has one; returnsnullforpo_generated, forcost_price: 12.8, forpricing_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 coverssetOrderQuote) — 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.tsxonly 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 asbuildAiReviewPatch.orderAiRowClass(item): string→"bg-ai/10"when pending.getOrderAssistantCounts(orderIds: string[]): Promise<Record<string, { pending: number; total: number }>>inorder-assistant-service.ts, exposed through the existing orders list data path, rendered withAiBoardBadge.
-
Step 1: Write failing tests for the four client helpers (pending only while unreviewed and
ai_quote; a stale save keepsai_quote; review patch setsaccepted_unchangedtrue 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, showAiFillMarkerwith quote number (link to the quote), reason, doubts; route the line save throughnextOrderPricingSourceOnSave+buildOrderAiReviewPatch; the existing "use this" copy setspricing_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 runandnpx tsc --noEmitandnpm run build— Expected: all pass. Report failures with output; do not claim done otherwise. - Step 2: Update
docs/order-assistant.mdwith 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