import { describe, expect, it } from "vitest"; import { buildOrderLineFillPatch, confidenceToScore, decideOutcome, isOrderEligible, isOrderLineWritable, isRealOrderLine, orderLinesHash, PLACEHOLDER_LINE_PATTERNS, type AiOrderMatchDecision, type OrderItemLite, } from "@/lib/server/order-assistant-core"; import type { OrderAiQuoteMatch, OrderItem, QuoteItem } from "@/lib/types"; const NOW = new Date("2026-10-24T12:00:00.000Z"); function line(over: Partial = {}): OrderItemLite { return { id: "ol1", description: "M16 bolt 60mm", quantity: 10, item_status: "pending", ...over, }; } describe("PLACEHOLDER_LINE_PATTERNS", () => { it("covers the two known placeholders", () => { expect(PLACEHOLDER_LINE_PATTERNS.some((p) => p.test("See attached"))).toBe(true); expect(PLACEHOLDER_LINE_PATTERNS.some((p) => p.test("Buyer to add items"))).toBe(true); }); }); describe("isRealOrderLine", () => { it("is false for an empty description", () => { expect(isRealOrderLine({ description: "" })).toBe(false); expect(isRealOrderLine({ description: " " })).toBe(false); expect(isRealOrderLine({})).toBe(false); }); it("is false for a placeholder line", () => { expect(isRealOrderLine({ description: "See attached" })).toBe(false); expect(isRealOrderLine({ description: "buyer to add items" })).toBe(false); }); it("is false for a cancelled line", () => { expect(isRealOrderLine({ description: "M16 bolt", item_status: "cancelled" })).toBe(false); }); it("is true for a normal pending line", () => { expect(isRealOrderLine({ description: "M16 bolt 60mm", item_status: "pending" })).toBe(true); }); }); describe("orderLinesHash", () => { it("is stable over line order", () => { const a = orderLinesHash([ { id: "ol1", description: "Bolt", quantity: 10 }, { id: "ol2", description: "Nut", quantity: 5 }, ]); const b = orderLinesHash([ { id: "ol2", description: "Nut", quantity: 5 }, { id: "ol1", description: "Bolt", quantity: 10 }, ]); expect(a).toBe(b); }); it("changes when a quantity changes", () => { const a = orderLinesHash([{ id: "ol1", description: "Bolt", quantity: 10 }]); const b = orderLinesHash([{ id: "ol1", description: "Bolt", quantity: 11 }]); expect(a).not.toBe(b); }); it("changes when a line is added", () => { const a = orderLinesHash([{ id: "ol1", description: "Bolt", quantity: 10 }]); const b = orderLinesHash([ { id: "ol1", description: "Bolt", quantity: 10 }, { id: "ol2", description: "Nut", quantity: 5 }, ]); expect(a).not.toBe(b); }); it("changes when a description changes", () => { const a = orderLinesHash([{ id: "ol1", description: "Bolt", quantity: 10 }]); const b = orderLinesHash([{ id: "ol1", description: "Nut", quantity: 10 }]); expect(a).not.toBe(b); }); }); describe("isOrderEligible", () => { it("is false when the order already has a quote", () => { expect( isOrderEligible({ quote: "q1" }, [line()], { now: NOW }), ).toBe(false); }); it("is false when there is no real line", () => { expect( isOrderEligible({}, [line({ description: "See attached" })], { now: NOW }), ).toBe(false); expect(isOrderEligible({}, [], { now: NOW })).toBe(false); }); it("is false when no real line is pending", () => { expect( isOrderEligible({}, [line({ item_status: "po_generated" })], { now: NOW }), ).toBe(false); }); it("is true when a real line is pending", () => { expect( isOrderEligible({}, [line({ item_status: "pending" })], { now: NOW }), ).toBe(true); }); it("ignores non-pending lines when deciding", () => { expect( isOrderEligible( {}, [line({ id: "ol1", item_status: "po_generated" }), line({ id: "ol2" })], { now: NOW }, ), ).toBe(true); }); it("is false when the same lines were already matched", () => { const lines = [line()]; const hash = orderLinesHash(lines); const ai_quote_match: OrderAiQuoteMatch = { status: "suggested", quote_id: "q1", quote_number: "Q-1", confidence: "medium", reason: "", doubts: [], lines: [], auto_linked: false, lines_hash: hash, model: "m", run_at: NOW.toISOString(), }; expect(isOrderEligible({ ai_quote_match }, lines, { now: NOW })).toBe(false); }); it("is true for the same lines when forced", () => { const lines = [line()]; const hash = orderLinesHash(lines); const ai_quote_match: OrderAiQuoteMatch = { status: "suggested", quote_id: "q1", quote_number: "Q-1", confidence: "medium", reason: "", doubts: [], lines: [], auto_linked: false, lines_hash: hash, model: "m", run_at: NOW.toISOString(), }; expect(isOrderEligible({ ai_quote_match }, lines, { now: NOW, force: true })).toBe(true); }); it("is true when the lines changed since the last run", () => { const ai_quote_match: OrderAiQuoteMatch = { status: "suggested", quote_id: "q1", quote_number: "Q-1", confidence: "medium", reason: "", doubts: [], lines: [], auto_linked: false, lines_hash: "stale-hash", model: "m", run_at: NOW.toISOString(), }; expect(isOrderEligible({ ai_quote_match }, [line()], { now: NOW })).toBe(true); }); it("is false within the error cooldown", () => { const ai_quote_match: OrderAiQuoteMatch = { status: "error", quote_id: null, quote_number: "", confidence: null, reason: "", doubts: [], lines: [], auto_linked: false, lines_hash: "stale-hash", model: "m", run_at: new Date(NOW.getTime() - 30 * 60 * 1000).toISOString(), error: "boom", }; expect(isOrderEligible({ ai_quote_match }, [line()], { now: NOW })).toBe(false); }); it("is true after the error cooldown", () => { const ai_quote_match: OrderAiQuoteMatch = { status: "error", quote_id: null, quote_number: "", confidence: null, reason: "", doubts: [], lines: [], auto_linked: false, lines_hash: "stale-hash", model: "m", run_at: new Date(NOW.getTime() - 61 * 60 * 1000).toISOString(), error: "boom", }; expect(isOrderEligible({ ai_quote_match }, [line()], { now: NOW })).toBe(true); }); }); describe("decideOutcome", () => { const QUOTE_LINE_TO_QUOTE = new Map([ ["ql1", "q1"], ["ql2", "q1"], ]); function decision(over: Partial = {}): AiOrderMatchDecision { return { quote_id: "q1", confidence: "high", reason: "same items", doubts: [], lines: [ { order_line_id: "ol1", quote_line_id: "ql1", same_item: "sure", reason: "same" }, { order_line_id: "ol2", quote_line_id: "ql2", same_item: "sure", reason: "same" }, ], ...over, }; } const BASE = { realLineIds: ["ol1", "ol2"], quoteLineToQuote: QUOTE_LINE_TO_QUOTE, orderCompany: "c1", quoteCompany: "c1", autoLinkEnabled: true, }; it("auto-links when everything is sure on one quote", () => { expect(decideOutcome({ decision: decision(), ...BASE })).toBe("auto_link"); }); it("suggests when one line is only likely", () => { const d = decision({ lines: [ { order_line_id: "ol1", quote_line_id: "ql1", same_item: "sure", reason: "" }, { order_line_id: "ol2", quote_line_id: "ql2", same_item: "likely", reason: "" }, ], }); expect(decideOutcome({ decision: d, ...BASE })).toBe("suggest"); }); it("suggests when one line is unpaired", () => { const d = decision({ lines: [ { order_line_id: "ol1", quote_line_id: "ql1", same_item: "sure", reason: "" }, { order_line_id: "ol2", quote_line_id: null, same_item: null, reason: "" }, ], }); expect(decideOutcome({ decision: d, ...BASE })).toBe("suggest"); }); it("suggests when a pair points at another quote's line", () => { const d = decision({ lines: [ { order_line_id: "ol1", quote_line_id: "ql1", same_item: "sure", reason: "" }, { order_line_id: "ol2", quote_line_id: "qlOther", same_item: "sure", reason: "" }, ], }); expect(decideOutcome({ decision: d, ...BASE })).toBe("suggest"); }); it("suggests when the company differs", () => { expect( decideOutcome({ decision: decision(), ...BASE, quoteCompany: "c2" }), ).toBe("suggest"); }); it("suggests when the quote has no company", () => { expect( decideOutcome({ decision: decision(), ...BASE, quoteCompany: null }), ).toBe("suggest"); }); it("suggests when auto-link is switched off", () => { expect( decideOutcome({ decision: decision(), ...BASE, autoLinkEnabled: false }), ).toBe("suggest"); }); it("suggests when the confidence is medium", () => { expect( decideOutcome({ decision: decision({ confidence: "medium" }), ...BASE }), ).toBe("suggest"); }); it("is no_match when the AI chose no quote", () => { expect( decideOutcome({ decision: decision({ quote_id: null }), ...BASE }), ).toBe("no_match"); }); it("is no_match when no pair lands on the chosen quote", () => { const d = decision({ lines: [ { order_line_id: "ol1", quote_line_id: "qlOther", same_item: "sure", reason: "" }, { order_line_id: "ol2", quote_line_id: "qlOther", same_item: "sure", reason: "" }, ], }); expect(decideOutcome({ decision: d, ...BASE })).toBe("no_match"); }); it("ignores pairs for lines that are not real order lines", () => { const d = decision({ lines: [ { order_line_id: "ol1", quote_line_id: "ql1", same_item: "sure", reason: "" }, { order_line_id: "ol2", quote_line_id: "ql2", same_item: "sure", reason: "" }, { order_line_id: "olPlaceholder", quote_line_id: "ql1", same_item: "unsure", reason: "" }, ], }); expect(decideOutcome({ decision: d, ...BASE })).toBe("auto_link"); }); }); describe("confidenceToScore", () => { it("maps the three levels", () => { expect(confidenceToScore("high")).toBe(0.9); expect(confidenceToScore("medium")).toBe(0.7); expect(confidenceToScore("low")).toBe(0.5); expect(confidenceToScore(null)).toBe(0); }); }); // ────────────────────────────────────────────────────────────────────── // Fill patch — Task 10 // ────────────────────────────────────────────────────────────────────── function fillLine(over: Partial = {}): OrderItem { return { id: "ol1", order: "o1", description: "M16 bolt 60mm", po_description: "", quantity: 100, item_status: "pending", cost_price: 0, selling_price: 0, markup: 0, supplier: "", sku: "", matched_price_list_id: "", pricing_source: "", ai_fill: null, ...over, } as OrderItem; } function fillQuoteLine(over: Partial = {}): QuoteItem { return { id: "ql1", quote: "q1", description: "M16 x 60 bolt", quantity: 200, cost_price: 18.2, selling_price: 18.2, markup: 0, supplier: "sup1", sku: "56020510", matched_price_list_id: "pl1", ...over, } as QuoteItem; } const FILL_CTX = { quoteNumber: "Q-2026-10-014", reason: "same item", model: "test-model", now: new Date("2026-10-24T12:00:00.000Z"), }; describe("isOrderLineWritable", () => { it("is true for a plain pending line with no cost", () => { expect(isOrderLineWritable(fillLine())).toBe(true); }); it("is false when the line is not pending", () => { expect(isOrderLineWritable(fillLine({ item_status: "po_generated" }))).toBe(false); expect(isOrderLineWritable(fillLine({ item_status: "processed" }))).toBe(false); }); it("is false when the line already has a cost", () => { expect(isOrderLineWritable(fillLine({ cost_price: 12.8 }))).toBe(false); expect(isOrderLineWritable(fillLine({ cost_price: 0.01 }))).toBe(false); }); it("is false when the pricing source is manual or quote", () => { expect(isOrderLineWritable(fillLine({ pricing_source: "manual" }))).toBe(false); expect(isOrderLineWritable(fillLine({ pricing_source: "quote" }))).toBe(false); }); it("is false when the AI fill was already reviewed", () => { expect( isOrderLineWritable( fillLine({ ai_fill: { status: "filled", source: "quote", quote_item_id: "ql1", quote_id: "q1", quote_number: "Q-1", cost_price: 1, selling_price: 2, markup: 100, supplier_id: "", sku: "", reason: "", doubts: [], model: "", attempted_at: "", reviewed_at: "2026-10-24T10:00:00.000Z", }, }), ), ).toBe(false); }); it("is true for an unreviewed AI fill with no cost", () => { expect( isOrderLineWritable( fillLine({ pricing_source: "ai_quote", ai_fill: { status: "filled", source: "quote", quote_item_id: "ql1", quote_id: "q1", quote_number: "Q-1", cost_price: 0, selling_price: 0, markup: 0, supplier_id: "", sku: "", reason: "", doubts: [], model: "", attempted_at: "", }, }), ), ).toBe(true); }); }); describe("buildOrderLineFillPatch", () => { it("returns null when the line is not writable", () => { expect( buildOrderLineFillPatch(fillLine({ item_status: "po_generated" }), fillQuoteLine(), FILL_CTX), ).toBeNull(); expect( buildOrderLineFillPatch(fillLine({ cost_price: 12.8 }), fillQuoteLine(), FILL_CTX), ).toBeNull(); expect( buildOrderLineFillPatch(fillLine({ pricing_source: "manual" }), fillQuoteLine(), FILL_CTX), ).toBeNull(); }); it("returns null when the quote line has no cost", () => { expect( buildOrderLineFillPatch(fillLine(), fillQuoteLine({ cost_price: 0 }), FILL_CTX), ).toBeNull(); }); it("fills the cost, supplier, sku and price-list id from the quote line", () => { const patch = buildOrderLineFillPatch(fillLine(), fillQuoteLine(), FILL_CTX); expect(patch).not.toBeNull(); expect(patch?.cost_price).toBe(18.2); expect(patch?.supplier).toBe("sup1"); expect(patch?.sku).toBe("56020510"); expect(patch?.matched_price_list_id).toBe("pl1"); expect(patch?.pricing_source).toBe("ai_quote"); }); it("never overwrites a supplier the line already has", () => { const patch = buildOrderLineFillPatch( fillLine({ supplier: "sup-other" }), fillQuoteLine(), FILL_CTX, ); expect(patch?.supplier).toBeUndefined(); }); it("never overwrites a SKU the line already has", () => { const patch = buildOrderLineFillPatch(fillLine({ sku: "MINE" }), fillQuoteLine(), FILL_CTX); expect(patch?.sku).toBeUndefined(); }); it("never overwrites a matched_price_list_id the line already has", () => { const patch = buildOrderLineFillPatch( fillLine({ matched_price_list_id: "pl-other" }), fillQuoteLine(), FILL_CTX, ); expect(patch?.matched_price_list_id).toBeUndefined(); }); it("never writes a selling price when the line has one", () => { const patch = buildOrderLineFillPatch( fillLine({ selling_price: 25 }), fillQuoteLine(), FILL_CTX, ); expect(patch?.selling_price).toBeUndefined(); }); it("writes the selling price when the line has none", () => { const patch = buildOrderLineFillPatch(fillLine(), fillQuoteLine(), FILL_CTX); expect(patch?.selling_price).toBe(18.2); }); it("writes markup and markup_percent together, from the resulting pair", () => { const patch = buildOrderLineFillPatch(fillLine(), fillQuoteLine(), FILL_CTX); expect(patch?.markup).toBe(0); expect(patch?.markup_percent).toBe(0); const withMarkup = buildOrderLineFillPatch( fillLine(), fillQuoteLine({ cost_price: 10, selling_price: 25 }), FILL_CTX, ); expect(withMarkup?.markup).toBe(150); expect(withMarkup?.markup_percent).toBe(150); }); it("computes the markup from the customer's own selling price when it keeps it", () => { const patch = buildOrderLineFillPatch( fillLine({ selling_price: 30 }), fillQuoteLine({ cost_price: 20, selling_price: 25 }), FILL_CTX, ); expect(patch?.selling_price).toBeUndefined(); expect(patch?.markup).toBe(50); expect(patch?.markup_percent).toBe(50); }); it("clamps a loss-making markup at 0, like every other order-line write", () => { // The customer's price is below the quote's cost. The margin chip // shows the real margin from cost + selling; the stored markup // follows the app's convention of never going negative. const patch = buildOrderLineFillPatch( fillLine({ selling_price: 8 }), fillQuoteLine({ cost_price: 10, selling_price: 12 }), FILL_CTX, ); expect(patch?.markup).toBe(0); expect(patch?.markup_percent).toBe(0); }); it("adds the price doubt when the customer price differs by more than 5%", () => { const patch = buildOrderLineFillPatch( fillLine({ selling_price: 16.64 }), fillQuoteLine({ cost_price: 10, selling_price: 18.2 }), FILL_CTX, ); const fill = patch?.ai_fill as { doubts: string[] }; expect(fill.doubts).toEqual(["customer price R16.64, quoted R18.20"]); }); it("does not add the price doubt within 5%", () => { const patch = buildOrderLineFillPatch( fillLine({ selling_price: 16.64 }), fillQuoteLine({ cost_price: 10, selling_price: 17.0 }), FILL_CTX, ); const fill = patch?.ai_fill as { doubts: string[] }; expect(fill.doubts).toEqual([]); }); it("adds no doubt when the line has no price of its own", () => { const patch = buildOrderLineFillPatch(fillLine(), fillQuoteLine(), FILL_CTX); const fill = patch?.ai_fill as { doubts: string[] }; expect(fill.doubts).toEqual([]); }); it("writes the ai_fill audit blob", () => { const patch = buildOrderLineFillPatch(fillLine(), fillQuoteLine(), FILL_CTX); const fill = patch?.ai_fill as { status: string; source: string; 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; }; expect(fill.status).toBe("filled"); expect(fill.source).toBe("quote"); expect(fill.quote_item_id).toBe("ql1"); expect(fill.quote_id).toBe("q1"); expect(fill.quote_number).toBe("Q-2026-10-014"); expect(fill.cost_price).toBe(18.2); expect(fill.selling_price).toBe(18.2); expect(fill.supplier_id).toBe("sup1"); expect(fill.sku).toBe("56020510"); expect(fill.reason).toBe("same item"); expect(fill.model).toBe("test-model"); expect(fill.attempted_at).toBe("2026-10-24T12:00:00.000Z"); }); it("records the resulting supplier and sku in the audit blob", () => { const patch = buildOrderLineFillPatch( fillLine({ supplier: "sup-other", sku: "MINE" }), fillQuoteLine(), FILL_CTX, ); const fill = patch?.ai_fill as { supplier_id: string; sku: string }; expect(fill.supplier_id).toBe("sup-other"); expect(fill.sku).toBe("MINE"); }); });