From 89e52bbd9dadaa53ebd11c4bf3a979ed911dc66e Mon Sep 17 00:00:00 2001 From: Dave Date: Sat, 24 Oct 2026 14:10:40 +0200 Subject: [PATCH] feat(orders): AI fills order lines from the matched quote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The assistant now prices the order's lines from the quote they are paired with, as purple drafts a person then reviews. - order-assistant-core.ts: isOrderLineWritable + buildOrderLineFillPatch. Only a pending line with no cost of its own, no person-set pricing source and no reviewed fill may be written. Supplier / SKU / matched_price_list_id only when the line has none; selling_price only when the line has none; markup + markup_percent written together. pricing_source becomes "ai_quote". A customer price that differs from the quoted price by more than 5% becomes a doubt. - order-assistant-lines.ts (new): the shared, cycle-free helpers — idFilter, aiQuotePairs, writeAiPairs, clearAiPairs and fillOrderFromQuote. It lives in its own module so orders-service.ts can use it without an import cycle, since order-assistant-service.ts already imports setOrderQuote. - The fill re-reads every line right before writing it, so a cost a person typed in the meantime wins. It stays quiet until the migration has added order_items.ai_fill — PB rejects a projection that names a column the collection does not have. - setOrderQuote writes the AI's line pairs before pairOrderLines runs, so the AI's pairing wins over word matching, then fills the lines. A failure there is logged and never fails the link. - The tick gains a second pass that fills the lines of orders already linked to a quote. Only orders the fill actually wrote to count towards the budget, so a skipped order cannot block the run. - The run route fills after the match and reports the counts. PocketBase filters have no IN operator, so every id list is an OR-chain. fetchQuoteItemsForQuotesAdmin no longer tries an IN filter first — that was a guaranteed 400 on every call. Co-Authored-By: Claude Opus 5.5 --- .../orders/[id]/assistant/run/route.test.ts | 59 +++- .../api/v1/orders/[id]/assistant/run/route.ts | 24 +- src/lib/server/order-assistant-core.test.ts | 285 ++++++++++++++++- src/lib/server/order-assistant-core.ts | 116 ++++++- src/lib/server/order-assistant-lines.test.ts | 289 +++++++++++++++++ src/lib/server/order-assistant-lines.ts | 301 ++++++++++++++++++ .../server/order-assistant-service.test.ts | 190 ++++++++++- src/lib/server/order-assistant-service.ts | 148 ++++++--- .../server/order-quote-link-service.test.ts | 53 +-- src/lib/server/order-quote-link-service.ts | 15 +- src/lib/server/orders-service.ts | 56 ++++ src/lib/server/quotes-service.ts | 14 +- 12 files changed, 1459 insertions(+), 91 deletions(-) create mode 100644 src/lib/server/order-assistant-lines.test.ts create mode 100644 src/lib/server/order-assistant-lines.ts diff --git a/src/app/api/v1/orders/[id]/assistant/run/route.test.ts b/src/app/api/v1/orders/[id]/assistant/run/route.test.ts index 54287d4..05947e7 100644 --- a/src/app/api/v1/orders/[id]/assistant/run/route.test.ts +++ b/src/app/api/v1/orders/[id]/assistant/run/route.test.ts @@ -2,12 +2,19 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { NextRequest } from "next/server"; const matchOrder = vi.fn(); +const fillOrderFromQuote = vi.fn(); vi.mock("@/lib/server/order-assistant-service", () => ({ matchOrder: (...a: unknown[]) => matchOrder(...a), + fillOrderFromQuote: (...a: unknown[]) => fillOrderFromQuote(...a), AI_DISABLED_ERROR: "AI is disabled in settings", NOT_SET_UP_ERROR: "Order assistant is not set up yet", })); +const getAiSettings = vi.fn(); +vi.mock("@/lib/server/ai-settings-service", () => ({ + getAiSettings: (...a: unknown[]) => getAiSettings(...a), +})); + const getBrowserSessionUser = vi.fn(); vi.mock("@/lib/server/browser-session", () => ({ getBrowserSessionUser: (...a: unknown[]) => getBrowserSessionUser(...a), @@ -26,6 +33,11 @@ const ctx = { params: Promise.resolve({ id: "o1" }) }; beforeEach(() => { vi.clearAllMocks(); getBrowserSessionUser.mockResolvedValue({ id: "u1", user_type: null, name: "Sam" }); + getAiSettings.mockResolvedValue({ + order_assistant_enabled: true, + order_assistant_fill_lines: true, + }); + fillOrderFromQuote.mockResolvedValue({ filled: 2, skipped: 1 }); }); describe("POST /api/v1/orders/[id]/assistant/run", () => { @@ -44,7 +56,7 @@ describe("POST /api/v1/orders/[id]/assistant/run", () => { const res = await POST(makeReq(), ctx); expect(matchOrder).toHaveBeenCalledWith("o1", { force: true }); expect(res.status).toBe(200); - expect(await res.json()).toEqual(result); + expect(await res.json()).toEqual({ ...result, filled: 2, skipped: 1 }); }); it("409s when the AI is disabled", async () => { @@ -76,7 +88,50 @@ describe("POST /api/v1/orders/[id]/assistant/run", () => { matchOrder.mockResolvedValue(result); const res = await POST(makeReq(), ctx); expect(res.status).toBe(200); - expect(await res.json()).toEqual(result); + expect(await res.json()).toEqual({ ...result, filled: 2, skipped: 1 }); + }); + + it("does not fill when the fill switch is off", async () => { + getAiSettings.mockResolvedValue({ + order_assistant_enabled: true, + order_assistant_fill_lines: false, + }); + matchOrder.mockResolvedValue({ + orderId: "o1", + outcome: "linked", + quoteId: "q1", + error: "", + }); + const res = await POST(makeReq(), ctx); + expect(fillOrderFromQuote).not.toHaveBeenCalled(); + expect(await res.json()).toEqual({ + orderId: "o1", + outcome: "linked", + quoteId: "q1", + error: "", + filled: 0, + skipped: 0, + }); + }); + + it("reports the match even when the fill fails", async () => { + fillOrderFromQuote.mockRejectedValue(new Error("pb down")); + matchOrder.mockResolvedValue({ + orderId: "o1", + outcome: "linked", + quoteId: "q1", + error: "", + }); + const res = await POST(makeReq(), ctx); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + orderId: "o1", + outcome: "linked", + quoteId: "q1", + error: "", + filled: 0, + skipped: 0, + }); }); it("500s when matchOrder throws unexpectedly", async () => { diff --git a/src/app/api/v1/orders/[id]/assistant/run/route.ts b/src/app/api/v1/orders/[id]/assistant/run/route.ts index bc44c4e..f1810eb 100644 --- a/src/app/api/v1/orders/[id]/assistant/run/route.ts +++ b/src/app/api/v1/orders/[id]/assistant/run/route.ts @@ -1,7 +1,9 @@ import { NextRequest, NextResponse } from "next/server"; import { getBrowserSessionUser } from "@/lib/server/browser-session"; +import { getAiSettings } from "@/lib/server/ai-settings-service"; import { AI_DISABLED_ERROR, + fillOrderFromQuote, matchOrder, NOT_SET_UP_ERROR, } from "@/lib/server/order-assistant-service"; @@ -35,7 +37,27 @@ export async function POST( if (result.error === AI_DISABLED_ERROR || result.error === NOT_SET_UP_ERROR) { return NextResponse.json({ error: result.error }, { status: 409 }); } - return NextResponse.json(result); + + // Fill the order's lines from the quote they are paired with. A + // failure here is logged and reported in the counts — it never + // undoes the match. + let filled = 0; + let skipped = 0; + try { + const settings = await getAiSettings(); + if (settings.order_assistant_enabled && settings.order_assistant_fill_lines) { + const fill = await fillOrderFromQuote(id); + filled = fill.filled; + skipped = fill.skipped; + } + } catch (err) { + console.error( + `[orders/${id}/assistant/run] fill failed:`, + err instanceof Error ? err.message : err, + ); + } + + return NextResponse.json({ ...result, filled, skipped }); } catch (err) { const message = err instanceof Error ? err.message : "Assistant run failed"; const status = (err as Error & { status?: number }).status || 500; diff --git a/src/lib/server/order-assistant-core.test.ts b/src/lib/server/order-assistant-core.test.ts index b8b4ffd..762f5f8 100644 --- a/src/lib/server/order-assistant-core.test.ts +++ b/src/lib/server/order-assistant-core.test.ts @@ -1,15 +1,17 @@ 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 } from "@/lib/types"; +import type { OrderAiQuoteMatch, OrderItem, QuoteItem } from "@/lib/types"; const NOW = new Date("2026-10-24T12:00:00.000Z"); @@ -336,3 +338,284 @@ describe("confidenceToScore", () => { 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("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"); + }); +}); diff --git a/src/lib/server/order-assistant-core.ts b/src/lib/server/order-assistant-core.ts index bb2c27f..243855f 100644 --- a/src/lib/server/order-assistant-core.ts +++ b/src/lib/server/order-assistant-core.ts @@ -4,7 +4,7 @@ * function of its arguments, so it is cheap to test exhaustively. */ import { createHash } from "node:crypto"; -import type { OrderAiQuoteMatch, OrderItem } from "@/lib/types"; +import type { OrderAiQuoteMatch, OrderItem, OrderItemAiFill, QuoteItem } from "@/lib/types"; import { ERROR_RETRY_MS } from "@/lib/server/quote-assistant-core"; /** @@ -191,3 +191,117 @@ export function confidenceToScore(c: "high" | "medium" | "low" | null): number { return 0; } } + +// ────────────────────────────────────────────────────────────────────── +// Filling order lines from the matched quote (Task 10) +// ────────────────────────────────────────────────────────────────────── + +/** Pricing sources that mean "a person decided this" — never overwritten. */ +const PERSON_PRICING_SOURCES: readonly string[] = ["manual", "quote"]; + +/** + * May the assistant write a fill onto this line? + * + * Only a line that is still `pending`, carries no cost of its own, was + * not priced by a person, and whose AI fill nobody has reviewed yet. + * The customer's own selling price is left alone — the assistant only + * fills a price where there is none. + */ +export function isOrderLineWritable(line: OrderItem): boolean { + if (line.item_status !== "pending") return false; + if (Number(line.cost_price) > 0) return false; + if (PERSON_PRICING_SOURCES.includes(line.pricing_source ?? "")) return false; + if (line.ai_fill?.reviewed_at) return false; + return true; +} + +const round2 = (n: number) => Math.round(n * 100) / 100; + +const money = (n: number) => `R${n.toFixed(2)}`; + +/** + * The patch that fills one order line from its quote line. + * + * `null` when the line may not be written, or when the quote line has + * no cost to fill from. Otherwise: + * - `cost_price` always comes from the quote line, + * - `supplier` / `sku` / `matched_price_list_id` only when the line + * has none of its own (a person's choice always wins), + * - `selling_price` only when the line has none, + * - `markup` + `markup_percent` are written together from the + * resulting cost/selling pair, + * - `pricing_source` becomes "ai_quote" so the line shows up in the + * purple "waiting for review" state, + * - `ai_fill` records what the assistant did. + * + * A customer price that differs from the quoted price by more than 5% + * becomes a doubt — the operator has to look at it. + */ +export function buildOrderLineFillPatch( + line: OrderItem, + quoteLine: QuoteItem, + ctx: { quoteNumber: string; reason: string; model: string; now: Date }, +): (Record & { ai_fill: OrderItemAiFill }) | null { + if (!isOrderLineWritable(line)) return null; + + const cost = Number(quoteLine.cost_price) || 0; + if (cost <= 0) return null; + + const customerPrice = Number(line.selling_price) || 0; + const quotedPrice = Number(quoteLine.selling_price) || 0; + + const doubts: string[] = []; + if (customerPrice > 0 && quotedPrice > 0) { + if (Math.abs(customerPrice - quotedPrice) / customerPrice > 0.05) { + doubts.push(`customer price ${money(customerPrice)}, quoted ${money(quotedPrice)}`); + } + } + + // The resulting selling price: the customer's own price when there is + // one, otherwise the quoted price. + const selling = customerPrice > 0 ? customerPrice : quotedPrice; + const markup = cost > 0 && selling > 0 ? round2(((selling - cost) / cost) * 100) : 0; + + const supplier = (line.supplier ?? "").trim() || (quoteLine.supplier ?? ""); + const sku = (line.sku ?? "").trim() || (quoteLine.sku ?? ""); + const priceListId = (line.matched_price_list_id ?? "").trim() || (quoteLine.matched_price_list_id ?? ""); + + const patch: Record = { + cost_price: cost, + markup, + markup_percent: markup, + pricing_source: "ai_quote", + }; + if (!(line.supplier ?? "").trim() && (quoteLine.supplier ?? "")) { + patch.supplier = quoteLine.supplier; + } + if (!(line.sku ?? "").trim() && (quoteLine.sku ?? "")) { + patch.sku = quoteLine.sku; + } + if (!(line.matched_price_list_id ?? "").trim() && (quoteLine.matched_price_list_id ?? "")) { + patch.matched_price_list_id = quoteLine.matched_price_list_id; + } + if (customerPrice <= 0 && quotedPrice > 0) { + patch.selling_price = quotedPrice; + } + + const aiFill: OrderItemAiFill = { + status: "filled", + source: "quote", + quote_item_id: quoteLine.id, + quote_id: quoteLine.quote ?? "", + quote_number: ctx.quoteNumber, + cost_price: cost, + selling_price: selling, + markup, + supplier_id: supplier, + sku, + reason: ctx.reason, + doubts, + model: ctx.model, + attempted_at: ctx.now.toISOString(), + }; + patch.ai_fill = aiFill; + + return patch as Record & { ai_fill: OrderItemAiFill }; +} diff --git a/src/lib/server/order-assistant-lines.test.ts b/src/lib/server/order-assistant-lines.test.ts new file mode 100644 index 0000000..dd0389e --- /dev/null +++ b/src/lib/server/order-assistant-lines.test.ts @@ -0,0 +1,289 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/server/pocketbase-admin", () => ({ + getAdminPocketBase: vi.fn(), +})); + +vi.mock("@/lib/server/quotes-service", () => ({ + fetchQuoteItemsForQuotesAdmin: vi.fn(), +})); + +import { + fillOrderFromQuote, + idFilter, + resetOrderFillFieldCheck, + writeAiPairs, +} from "@/lib/server/order-assistant-lines"; +import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; +import { fetchQuoteItemsForQuotesAdmin } from "@/lib/server/quotes-service"; + +const ORDERS = "orders"; +const ORDER_ITEMS = "order_items"; +const QUOTES = "quotes"; + +const ORDER = { + id: "ord1", + quote: "q1", + ai_quote_match: { + quote_number: "Q-2026-10-014", + model: "test-model", + lines: [{ order_line_id: "ol1", quote_line_id: "ql1", reason: "the AI said so" }], + }, +}; + +const ORDER_LINE = { + id: "ol1", + order: "ord1", + quote_item: "ql1", + item_status: "pending", + cost_price: 0, + selling_price: 0, + markup: 0, + supplier: "", + sku: "", + matched_price_list_id: "", + pricing_source: "", + ai_fill: null, +}; + +const QUOTE_LINE = { + id: "ql1", + quote: "q1", + description: "M16 x 60 bolt", + cost_price: 18.2, + selling_price: 18.2, + supplier: "sup1", + sku: "56020510", + matched_price_list_id: "pl1", +}; + +function makePb(overrides: Record> = {}) { + const base: Record> = { + [ORDERS]: { + getOne: vi.fn().mockResolvedValue({ ...ORDER }), + getList: vi.fn().mockResolvedValue({ items: [] }), + getFullList: vi.fn().mockResolvedValue([ + { id: "q1", quote_number: "Q-2026-10-014" }, + ]), + update: vi.fn().mockResolvedValue({}), + }, + [ORDER_ITEMS]: { + getOne: vi.fn().mockResolvedValue({ ...ORDER_LINE }), + getList: vi.fn().mockResolvedValue({ items: [] }), + getFullList: vi.fn().mockResolvedValue([{ ...ORDER_LINE }]), + update: vi.fn().mockResolvedValue({}), + }, + [QUOTES]: { + getFullList: vi.fn().mockResolvedValue([ + { id: "q1", quote_number: "Q-2026-10-014" }, + ]), + }, + }; + const merged: Record> = {}; + for (const name of new Set([...Object.keys(base), ...Object.keys(overrides)])) { + merged[name] = { ...(base[name] ?? {}), ...(overrides[name] ?? {}) }; + } + return { + autoCancellation: vi.fn(), + // The fill checks that the live PB has the two columns it reads and + // writes before it projects them. + collections: { + getOne: vi.fn().mockImplementation(async (name: string) => ({ + fields: + name === "order_items" + ? [{ name: "ai_fill" }, { name: "quote_item" }] + : [{ name: "ai_quote_match" }, { name: "quote" }], + })), + }, + collection: (name: string) => { + const c = merged[name]; + if (!c) throw new Error(`unexpected collection: ${name}`); + return c; + }, + }; +} + +function mockAdminPb(pb: ReturnType) { + vi.mocked(getAdminPocketBase).mockResolvedValue( + pb as unknown as Awaited>, + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + resetOrderFillFieldCheck(); + vi.mocked(fetchQuoteItemsForQuotesAdmin).mockResolvedValue([ + QUOTE_LINE, + ] as never); +}); + +describe("idFilter", () => { + it("builds an OR-chain — PocketBase has no IN operator", () => { + expect(idFilter(["a", "b"])).toBe('id = "a" || id = "b"'); + expect(idFilter([])).toBe(""); + }); + + it("escapes quotes in the id", () => { + expect(idFilter(['a"b'])).toBe('id = "a\\"b"'); + }); +}); + +describe("writeAiPairs", () => { + it("writes the pairing onto a line with no quote_item and returns the ids", async () => { + const pb = makePb({ + [ORDER_ITEMS]: { + getFullList: vi.fn().mockResolvedValue([{ ...ORDER_LINE, quote_item: "" }]), + update: vi.fn().mockResolvedValue({}), + }, + }); + const update = pb.collection(ORDER_ITEMS).update as ReturnType; + const written = await writeAiPairs(pb as never, "ord1", [ + { order_line_id: "ol1", quote_line_id: "ql1", quote_id: "q1", same_item: "sure", reason: "" }, + ]); + expect(update).toHaveBeenCalledWith("ol1", { quote_item: "ql1" }); + expect(written).toEqual(["ol1"]); + }); + + it("leaves a line a person already paired alone", async () => { + const pb = makePb({ + [ORDER_ITEMS]: { + getFullList: vi.fn().mockResolvedValue([{ ...ORDER_LINE, quote_item: "ql-other" }]), + update: vi.fn().mockResolvedValue({}), + }, + }); + const update = pb.collection(ORDER_ITEMS).update as ReturnType; + const written = await writeAiPairs(pb as never, "ord1", [ + { order_line_id: "ol1", quote_line_id: "ql1", quote_id: "q1", same_item: "sure", reason: "" }, + ]); + expect(update).not.toHaveBeenCalled(); + expect(written).toEqual([]); + }); +}); + +describe("fillOrderFromQuote", () => { + it("fills the line from its quote line", async () => { + const pb = makePb(); + mockAdminPb(pb); + + const res = await fillOrderFromQuote("ord1"); + + expect(res).toEqual({ filled: 1, skipped: 0 }); + const update = pb.collection(ORDER_ITEMS).update as ReturnType; + expect(update).toHaveBeenCalledTimes(1); + const patch = update.mock.calls[0][1] as { + cost_price: number; + supplier: string; + sku: string; + pricing_source: string; + ai_fill: { + status: string; + quote_item_id: string; + quote_id: string; + quote_number: string; + reason: string; + model: string; + }; + }; + expect(patch.cost_price).toBe(18.2); + expect(patch.supplier).toBe("sup1"); + expect(patch.sku).toBe("56020510"); + expect(patch.pricing_source).toBe("ai_quote"); + expect(patch.ai_fill.status).toBe("filled"); + expect(patch.ai_fill.quote_item_id).toBe("ql1"); + expect(patch.ai_fill.quote_id).toBe("q1"); + expect(patch.ai_fill.quote_number).toBe("Q-2026-10-014"); + expect(patch.ai_fill.reason).toBe("the AI said so"); + expect(patch.ai_fill.model).toBe("test-model"); + }); + + it("skips a line whose re-read shows a cost the person typed", async () => { + const pb = makePb({ + [ORDER_ITEMS]: { + // The candidate list still says "no cost"… + getFullList: vi.fn().mockResolvedValue([{ ...ORDER_LINE }]), + // …but the re-read right before the write shows a cost. + getOne: vi.fn().mockResolvedValue({ ...ORDER_LINE, cost_price: 12.8 }), + update: vi.fn().mockResolvedValue({}), + }, + }); + mockAdminPb(pb); + + const res = await fillOrderFromQuote("ord1"); + + expect(res).toEqual({ filled: 0, skipped: 1 }); + expect(pb.collection(ORDER_ITEMS).update).not.toHaveBeenCalled(); + }); + + it("skips a line that is no longer pending", async () => { + const pb = makePb({ + [ORDER_ITEMS]: { + getFullList: vi.fn().mockResolvedValue([{ ...ORDER_LINE }]), + getOne: vi.fn().mockResolvedValue({ ...ORDER_LINE, item_status: "po_generated" }), + update: vi.fn().mockResolvedValue({}), + }, + }); + mockAdminPb(pb); + + const res = await fillOrderFromQuote("ord1"); + expect(res).toEqual({ filled: 0, skipped: 1 }); + }); + + it("skips a line whose quote line has no cost", async () => { + vi.mocked(fetchQuoteItemsForQuotesAdmin).mockResolvedValue([ + { ...QUOTE_LINE, cost_price: 0 }, + ] as never); + const pb = makePb(); + mockAdminPb(pb); + + const res = await fillOrderFromQuote("ord1"); + expect(res).toEqual({ filled: 0, skipped: 1 }); + }); + + it("does nothing when the order has no quote", async () => { + const pb = makePb({ + [ORDERS]: { + getOne: vi.fn().mockResolvedValue({ id: "ord1", quote: "" }), + getList: vi.fn().mockResolvedValue({ items: [] }), + getFullList: vi.fn().mockResolvedValue([]), + update: vi.fn().mockResolvedValue({}), + }, + }); + mockAdminPb(pb); + + const res = await fillOrderFromQuote("ord1"); + expect(res).toEqual({ filled: 0, skipped: 0 }); + expect(pb.collection(ORDER_ITEMS).getFullList).not.toHaveBeenCalled(); + }); + + it("does nothing when the live PB has no ai_fill column yet", async () => { + // The migration has not run: PB would reject a projection that + // names a column the collection does not have, so the fill has to + // stay quiet instead of throwing on every linked order. + const pb = makePb(); + pb.collections.getOne = vi.fn().mockResolvedValue({ + fields: [{ name: "quote" }], + }) as never; + mockAdminPb(pb); + + const res = await fillOrderFromQuote("ord1"); + + expect(res).toEqual({ filled: 0, skipped: 0 }); + expect(pb.collection(ORDER_ITEMS).getFullList).not.toHaveBeenCalled(); + expect(pb.collection(ORDER_ITEMS).update).not.toHaveBeenCalled(); + }); + + it("never uses an IN filter (PocketBase has no IN operator)", async () => { + const pb = makePb(); + mockAdminPb(pb); + + await fillOrderFromQuote("ord1"); + + const filters = [ORDERS, ORDER_ITEMS, QUOTES].flatMap((name) => + (pb.collection(name).getFullList as ReturnType).mock.calls.map( + (c) => (c[0] as { filter?: string })?.filter ?? "", + ), + ); + expect(filters.length).toBeGreaterThan(0); + for (const f of filters) expect(f).not.toMatch(/\bIN\s*\(/); + }); +}); diff --git a/src/lib/server/order-assistant-lines.ts b/src/lib/server/order-assistant-lines.ts new file mode 100644 index 0000000..dcf0f14 --- /dev/null +++ b/src/lib/server/order-assistant-lines.ts @@ -0,0 +1,301 @@ +/** + * Shared, cycle-free helpers for the AI order assistant. + * + * These live in their own module because `orders-service.ts` needs two + * of them (write the AI's line pairs, run the fill after a person links + * a quote) while `order-assistant-service.ts` already imports + * `setOrderQuote` from `orders-service.ts`. A static import back would + * be a cycle, so both sides import from here instead. + * + * Nothing in this module imports `orders-service.ts`. + */ +import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; +import { fetchQuoteItemsForQuotesAdmin } from "@/lib/server/quotes-service"; +import { escapeForPbString } from "@/lib/server/price-list-match"; +import { buildOrderLineFillPatch, isOrderLineWritable } from "@/lib/server/order-assistant-core"; +import type { OrderAiLinePair, OrderItem, QuoteItem } from "@/lib/types"; + +export type AdminPb = Awaited>; + +/** How many ids go into one OR-chain before the filter gets too long. */ +export const ID_CHUNK = 50; + +/** + * PocketBase filters have NO `IN` operator — `id IN ("a","b")` is a + * 400. An id list is an OR-chain instead: + * + * id = "a" || id = "b" + * + * Chunked by the caller when the list is long enough to push the filter + * past PB's URL length cap. + */ +export function idFilter(ids: string[]): string { + return ids.map((id) => `id = "${escapeForPbString(id)}"`).join(" || "); +} + +function errMessage(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + +/** + * The AI's line pairs for an order, as `writeAiPairs` wants them. + * Pairs without a quote line are dropped — there is nothing to write. + */ +export function aiQuotePairs( + match: { lines?: OrderAiLinePair[] } | null | undefined, +): OrderAiLinePair[] { + return (match?.lines ?? []).filter((l) => !!l?.quote_line_id); +} + +/** + * Write the AI's pairing onto the order lines that have no pairing yet. + * A `quote_item` a person set is never overwritten. + * + * Returns the ids of the lines it wrote, so the caller can undo them + * when the link they were preparing for is refused. + */ +export async function writeAiPairs( + pb: AdminPb, + orderId: string, + aiLines: OrderAiLinePair[], +): Promise { + const written: string[] = []; + if (aiLines.length === 0) return written; + const current = await pb.collection("order_items").getFullList<{ + id: string; + quote_item: string; + }>({ + filter: `order = "${escapeForPbString(orderId)}"`, + fields: "id,quote_item", + batch: 500, + }); + const byId = new Map(current.map((c) => [c.id, c.quote_item ?? ""])); + for (const pair of aiLines) { + if (!pair.quote_line_id) continue; + if (byId.get(pair.order_line_id)) continue; + try { + await pb.collection("order_items").update(pair.order_line_id, { + quote_item: pair.quote_line_id, + }); + byId.set(pair.order_line_id, pair.quote_line_id); + written.push(pair.order_line_id); + } catch (e) { + console.error( + `[order-assistant] could not write the AI pairing onto line ${pair.order_line_id}:`, + errMessage(e), + ); + } + } + return written; +} + +/** Undo `writeAiPairs` when the link it prepared for is refused. */ +export async function clearAiPairs(pb: AdminPb, lineIds: string[]): Promise { + for (const id of lineIds) { + try { + await pb.collection("order_items").update(id, { quote_item: "" }); + } catch (e) { + console.error(`[order-assistant] could not undo the AI pairing on line ${id}:`, errMessage(e)); + } + } +} + +/** The fields the fill needs off an order line. */ +const ORDER_ITEM_FILL_FIELDS = [ + "id", + "order", + "quote_item", + "item_status", + "cost_price", + "selling_price", + "markup", + "supplier", + "sku", + "matched_price_list_id", + "pricing_source", + "ai_fill", +].join(","); + +/** The fields the fill needs off a quote line. */ +const QUOTE_ITEM_FILL_FIELDS = [ + "id", + "quote", + "description", + "cost_price", + "selling_price", + "supplier", + "sku", + "matched_price_list_id", +].join(","); + +export interface OrderAssistantFillDeps { + now?: () => Date; +} + +let fillFieldsConfirmed = false; + +/** Test hook: forget the cached schema check. */ +export function resetOrderFillFieldCheck(): void { + fillFieldsConfirmed = false; +} + +/** + * Does the live PB have the two columns the fill reads and writes? + * + * The fill projects `orders.ai_quote_match` and `order_items.ai_fill`. + * Both are added by the order-assistant migration, and PocketBase + * rejects a projection that names a column the collection does not + * have — so before the migration runs, the fill has to stay quiet + * rather than throw on every linked order. + * + * The answer is cached for the life of the process, like the quote + * assistant's own field check. + */ +async function hasOrderFillFields(pb: AdminPb): Promise { + if (fillFieldsConfirmed) return true; + const wanted: Array<[string, string]> = [ + ["orders", "ai_quote_match"], + ["order_items", "ai_fill"], + ]; + const collections = await Promise.all( + wanted.map(([name]) => + pb.collections.getOne(name) as Promise<{ + fields?: Array<{ name: string }>; + schema?: Array<{ name: string }>; + }>, + ), + ); + const missing = wanted.some(([, field], i) => { + const col = collections[i]; + const present = col?.fields ?? col?.schema ?? []; + return !present.some((f) => f.name === field); + }); + if (missing) return false; + fillFieldsConfirmed = true; + return true; +} + +/** + * Fill the order's lines from the quote they are linked to. + * + * No AI call: the pairing is already on the lines (`quote_item`), this + * only copies the quote's cost / supplier / SKU onto the lines that may + * still be written. A line is re-read immediately before its write, so + * a person who typed a cost in the meantime wins. + * + * Returns how many lines were filled and how many were skipped. + */ +export async function fillOrderFromQuote( + orderId: string, + deps: OrderAssistantFillDeps = {}, +): Promise<{ filled: number; skipped: number }> { + const pb = await getAdminPocketBase(); + pb.autoCancellation(false); + const now = (deps.now ?? (() => new Date()))(); + + let filled = 0; + let skipped = 0; + + // The columns this reads and writes only exist after the migration. + if (!(await hasOrderFillFields(pb))) { + return { filled, skipped }; + } + + const order = await pb.collection("orders").getOne<{ + id: string; + quote: string; + ai_quote_match?: { + quote_number?: string; + model?: string; + lines?: Array<{ order_line_id: string; reason: string }>; + } | null; + }>(orderId, { fields: "id,quote,ai_quote_match" }); + + const quoteId = order.quote || ""; + if (!quoteId) return { filled, skipped }; + + const candidates = await pb.collection("order_items").getFullList({ + filter: `order = "${escapeForPbString(orderId)}" && quote_item != ""`, + fields: ORDER_ITEM_FILL_FIELDS, + sort: "created", + batch: 500, + }); + if (candidates.length === 0) return { filled, skipped }; + + const quoteRows = await pb.collection("quotes").getFullList<{ id: string; quote_number: string }>({ + filter: idFilter([quoteId]), + fields: "id,quote_number", + batch: 10, + }); + const quoteNumber = + quoteRows[0]?.quote_number || order.ai_quote_match?.quote_number || ""; + + const quoteItems = await fetchQuoteItemsForQuotesAdmin( + pb, + [quoteId], + QUOTE_ITEM_FILL_FIELDS, + ); + const quoteLines = new Map(); + for (const q of quoteItems) quoteLines.set(q.id, q); + + const aiReasons = new Map(); + for (const l of order.ai_quote_match?.lines ?? []) { + if (l?.order_line_id) aiReasons.set(l.order_line_id, l.reason || ""); + } + + for (const candidate of candidates) { + // Re-read the line right before writing it. A person may have typed + // a cost since we listed the candidates — their value wins. + let fresh: OrderItem; + try { + fresh = await pb.collection("order_items").getOne(candidate.id, { + fields: ORDER_ITEM_FILL_FIELDS, + }); + } catch (e) { + console.error( + `[order-assistant] could not re-read line ${candidate.id} before filling:`, + errMessage(e), + ); + skipped += 1; + continue; + } + + if (!fresh.quote_item) { + skipped += 1; + continue; + } + if (!isOrderLineWritable(fresh)) { + skipped += 1; + continue; + } + const quoteLine = quoteLines.get(fresh.quote_item); + if (!quoteLine) { + skipped += 1; + continue; + } + const reason = + aiReasons.get(fresh.id) || "Paired with quote line by description"; + const patch = buildOrderLineFillPatch(fresh, quoteLine, { + quoteNumber, + reason, + model: order.ai_quote_match?.model ?? "", + now, + }); + if (!patch) { + skipped += 1; + continue; + } + try { + await pb.collection("order_items").update(fresh.id, patch); + filled += 1; + } catch (e) { + console.error( + `[order-assistant] could not fill line ${fresh.id} from quote ${quoteId}:`, + errMessage(e), + ); + skipped += 1; + } + } + + return { filled, skipped }; +} diff --git a/src/lib/server/order-assistant-service.test.ts b/src/lib/server/order-assistant-service.test.ts index a756db9..09a85e7 100644 --- a/src/lib/server/order-assistant-service.test.ts +++ b/src/lib/server/order-assistant-service.test.ts @@ -26,6 +26,7 @@ import { matchOrder, runOrderAssistantTick, resetAiQuoteMatchFieldCheck, + resetOrderFillFieldCheck, } from "@/lib/server/order-assistant-service"; import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; import { getAiSettings, recordOrderAssistantRun } from "@/lib/server/ai-settings-service"; @@ -84,6 +85,7 @@ const QUOTE_ITEMS = [ quote: "q1", description: "M16 x 60 bolt", quantity: 200, + cost_price: 30, selling_price: 340, sku: "56020510", supplier: "sup1", @@ -93,6 +95,7 @@ const QUOTE_ITEMS = [ quote: "q1", description: "M16 nut", quantity: 200, + cost_price: 10, selling_price: 45, sku: "", supplier: "sup1", @@ -113,10 +116,13 @@ function makePb(overrides: Record = {}) { update: vi.fn().mockResolvedValue({}), }, [ORDER_ITEMS]: { + getOne: vi.fn().mockResolvedValue({ ...ORDER_LINES[0] }), getFullList: vi.fn().mockResolvedValue(ORDER_LINES.map((l) => ({ ...l }))), update: vi.fn().mockResolvedValue({}), }, [QUOTES]: { + getOne: vi.fn().mockResolvedValue({ id: "q1", quote_number: "Q-2026-10-014" }), + getList: vi.fn().mockResolvedValue({ items: [] }), getFullList: vi.fn().mockResolvedValue([ { id: "q1", quote_number: "Q-2026-10-014", company: "co1", created: "2026-10-01T00:00:00.000Z" }, ]), @@ -132,9 +138,14 @@ function makePb(overrides: Record = {}) { return { autoCancellation: vi.fn(), collections: { - getOne: vi.fn().mockResolvedValue({ - fields: [{ name: "ai_quote_match" }, { name: "quote" }], - }), + // Per-collection field lists, so a service that checks for a + // column on a collection it did not get a mock for is caught. + getOne: vi.fn().mockImplementation(async (name: string) => ({ + fields: + name === "order_items" + ? [{ name: "ai_fill" }, { name: "quote_item" }] + : [{ name: "ai_quote_match" }, { name: "quote" }], + })), }, collection: (name: string) => { const c = merged[name]; @@ -199,6 +210,7 @@ function baseSettings(over: Record = {}) { beforeEach(() => { vi.clearAllMocks(); resetAiQuoteMatchFieldCheck(); + resetOrderFillFieldCheck(); vi.mocked(getAiSettings).mockResolvedValue(baseSettings() as never); vi.mocked(recordOrderAssistantRun).mockResolvedValue(undefined); vi.mocked(fetchQuoteItemsForQuotesAdmin).mockResolvedValue(QUOTE_ITEMS as never); @@ -599,3 +611,175 @@ describe("runOrderAssistantTick", () => { }); }); }); + +describe("matchOrder — filling the lines", () => { + it("fills the lines from the quote after a successful auto-link", async () => { + // The order starts unlinked. `link` links it, exactly as + // `setOrderQuote` does on real PB — the fill re-reads the order + // to learn which quote it is linked to, so the mock has to be + // stateful rather than frozen at `quote: ""`. + const order = { ...ORDER, quote: "" }; + const pb = makePb({ + [ORDERS]: { + getOne: vi.fn().mockImplementation(async () => ({ ...order })), + }, + }); + mockAdminPb(pb); + const link = vi.fn().mockImplementation(async () => { + order.quote = "q1"; + return { order: {}, previous: { quote: "" } }; + }); + + await matchOrder("ord1", { + deps: { + link, + match: vi.fn().mockResolvedValue(aiDecision()), + suggest: vi.fn().mockResolvedValue(SUGGESTION), + }, + }); + + // The fill looks for lines that are paired to a quote line. + const itemFilters = ( + pb.collection(ORDER_ITEMS).getFullList as ReturnType + ).mock.calls.map((c) => (c[0] as { filter?: string })?.filter ?? ""); + expect(itemFilters.some((f) => f.includes('quote_item != ""'))).toBe(true); + }); + + it("does not fill when the fill switch is off", async () => { + vi.mocked(getAiSettings).mockResolvedValue( + baseSettings({ order_assistant_fill_lines: false }) as never, + ); + const pb = makePb(); + mockAdminPb(pb); + const link = vi.fn().mockResolvedValue({ order: {}, previous: { quote: "" } }); + + await matchOrder("ord1", { + deps: { + link, + match: vi.fn().mockResolvedValue(aiDecision()), + suggest: vi.fn().mockResolvedValue(SUGGESTION), + }, + }); + + const itemFilters = ( + pb.collection(ORDER_ITEMS).getFullList as ReturnType + ).mock.calls.map((c) => (c[0] as { filter?: string })?.filter ?? ""); + expect(itemFilters.some((f) => f.includes('quote_item != ""'))).toBe(false); + }); +}); + +describe("runOrderAssistantTick — the fill pass", () => { + function fillPassPb() { + return makePb({ + [ORDERS]: { + getOne: vi.fn().mockResolvedValue({ ...ORDER, quote: "q1" }), + getList: vi.fn().mockResolvedValue({ items: [] }), + getFullList: vi.fn().mockResolvedValue([ + { id: "q1", quote_number: "Q-2026-10-014" }, + ]), + update: vi.fn().mockResolvedValue({}), + }, + [ORDER_ITEMS]: { + getOne: vi.fn().mockResolvedValue({ ...ORDER_LINES[0], quote_item: "ql1" }), + getList: vi.fn().mockResolvedValue({ + items: [ + { + id: "ol1", + order: "ord1", + quote_item: "ql1", + item_status: "pending", + ai_fill: null, + }, + ], + }), + getFullList: vi.fn().mockResolvedValue([ + { ...ORDER_LINES[0], quote_item: "ql1" }, + ]), + update: vi.fn().mockResolvedValue({}), + }, + }); + } + + it("fills the lines of a linked order in a second pass", async () => { + const pb = fillPassPb(); + mockAdminPb(pb); + + const res = await runOrderAssistantTick({ + suggest: vi.fn().mockResolvedValue(SUGGESTION), + match: vi.fn().mockResolvedValue(aiDecision()), + link: vi.fn(), + }); + + expect(res.ran).toBe(true); + expect(res.summary?.lines_filled).toBe(1); + const itemUpdate = pb.collection(ORDER_ITEMS).update as ReturnType; + const patch = itemUpdate.mock.calls[0][1] as { + cost_price: number; + pricing_source: string; + ai_fill: { status: string }; + }; + expect(patch.cost_price).toBe(30); + expect(patch.pricing_source).toBe("ai_quote"); + expect(patch.ai_fill.status).toBe("filled"); + }); + + it("does not fill when the fill switch is off", async () => { + vi.mocked(getAiSettings).mockResolvedValue( + baseSettings({ order_assistant_fill_lines: false }) as never, + ); + const pb = fillPassPb(); + mockAdminPb(pb); + + const res = await runOrderAssistantTick({ + suggest: vi.fn().mockResolvedValue(SUGGESTION), + match: vi.fn().mockResolvedValue(aiDecision()), + link: vi.fn(), + }); + + expect(res.summary?.lines_filled).toBe(0); + const itemUpdate = pb.collection(ORDER_ITEMS).update as ReturnType; + expect(itemUpdate).not.toHaveBeenCalled(); + }); + + it("never uses an IN filter in the fill pass (PocketBase has no IN operator)", async () => { + const pb = fillPassPb(); + mockAdminPb(pb); + + await runOrderAssistantTick({ + suggest: vi.fn().mockResolvedValue(SUGGESTION), + match: vi.fn().mockResolvedValue(aiDecision()), + link: vi.fn(), + }); + + const filters = [ORDERS, ORDER_ITEMS, QUOTES].flatMap((name) => [ + ...(pb.collection(name).getFullList as ReturnType).mock.calls.map( + (c) => (c[0] as { filter?: string })?.filter ?? "", + ), + ...(pb.collection(name).getList as ReturnType).mock.calls.map( + (c) => (c[2] as { filter?: string })?.filter ?? "", + ), + ]); + expect(filters.length).toBeGreaterThan(0); + for (const f of filters) expect(f).not.toMatch(/\bIN\s*\(/); + }); + + it("only looks at lines that are paired, still pending and carry no cost", async () => { + // A line that already carries a cost (a filled line) must not be + // re-read on every tick — it would crowd out the lines that still + // need filling. + const pb = fillPassPb(); + mockAdminPb(pb); + + await runOrderAssistantTick({ + suggest: vi.fn().mockResolvedValue(SUGGESTION), + match: vi.fn().mockResolvedValue(aiDecision()), + link: vi.fn(), + }); + + const itemList = pb.collection(ORDER_ITEMS).getList as ReturnType; + const filter = (itemList.mock.calls[0][2] as { filter?: string })?.filter ?? ""; + expect(filter).toContain('quote_item != ""'); + expect(filter).toContain('item_status = "pending"'); + expect(filter).toContain("cost_price = 0"); + }); +}); diff --git a/src/lib/server/order-assistant-service.ts b/src/lib/server/order-assistant-service.ts index dd127cf..ba20540 100644 --- a/src/lib/server/order-assistant-service.ts +++ b/src/lib/server/order-assistant-service.ts @@ -27,6 +27,13 @@ import { type SuggestQuoteResult, } from "@/lib/server/order-quote-link-service"; import { matchOrderWithAi, type MatchInput } from "@/lib/server/order-assistant-ai"; +import { + clearAiPairs, + fillOrderFromQuote, + idFilter, + ID_CHUNK, + writeAiPairs, +} from "@/lib/server/order-assistant-lines"; import { runWithAuditActor } from "@/lib/server/audit-context"; import { AiDisabledError, @@ -53,9 +60,10 @@ import type { } from "@/lib/types"; /** Orders the AI may look at per tick. */ -export const TICK_MAX_ORDERS = 10; -/** How many unlinked orders one run looks through to find TICK_MAX_ORDERS to work on. */ +export const TICK_MAX_ORDERS = 10;/** How many unlinked orders one run looks through to find TICK_MAX_ORDERS to work on. */ export const TICK_CANDIDATE_CAP = 200; +/** How many linked orders one run fills the lines of. */ +export const TICK_MAX_FILL_ORDERS = 10; /** Stop starting new orders after this long; leftovers go to the next run. */ export const TICK_TIME_BUDGET_MS = 90_000; /** A started run with no newer summary is treated as in progress this long. */ @@ -72,6 +80,13 @@ const MAX_SUMMARY_ERRORS = 20; const MAX_SUMMARY_ERROR_CHARS = 300; /** `MatchOrderResult.error` when the global AI switch is off (409). */ +// Re-exported so callers have one import site for the assistant: +// `fillOrderFromQuote` lives in `order-assistant-lines.ts` so that +// `orders-service.ts` can use it without an import cycle. +export { fillOrderFromQuote }; +export type { OrderAssistantFillDeps } from "@/lib/server/order-assistant-lines"; +export { resetOrderFillFieldCheck } from "@/lib/server/order-assistant-lines"; + export const AI_DISABLED_ERROR = "AI is disabled in settings"; /** `MatchOrderResult.error` when the live PB has no `orders.ai_quote_match` yet (409). */ export const NOT_SET_UP_ERROR = @@ -171,11 +186,9 @@ async function loadOrderLines(pb: AdminPb, orderId: string): Promise `id = "${escapeForPbString(id)}"`).join(" || "); -} + * chunked so it stays under PB's filter-length limit. `idFilter` and + * `ID_CHUNK` live in `order-assistant-lines.ts` so `orders-service.ts` + * can share them without an import cycle. */ async function loadShortlistQuotes( pb: AdminPb, @@ -501,6 +514,18 @@ export async function matchOrder( potential_quote_confidence: 0, ai_quote_match: { ...baseMatch, auto_linked: true } satisfies OrderAiQuoteMatch, }); + + // The order is linked — fill its lines from the quote. The lines are + // already paired, so this is a price copy, not an AI call. + if (settings.order_assistant_fill_lines) { + try { + await runWithAuditActor(actor, () => fillOrderFromQuote(orderId, { now: nowFn })); + } catch (e) { + // A failed fill never undoes the link. + console.error(`[order-assistant] fill after auto-link failed for ${orderId}:`, errMessage(e)); + } + } + result.outcome = "linked"; result.quoteId = chosenQuoteId; return result; @@ -546,51 +571,73 @@ async function writeSuggestion( } /** - * Write the AI's pairing onto the order lines that have no pairing yet. - * A `quote_item` a person set is never overwritten. + * Fill the lines of orders that are already linked to a quote but whose + * lines have no AI fill yet. + * + * The candidate lines are found with one query on `order_items` (a + * paired line that is still pending and carries no cost of its own), + * and the orders behind them are filled one at a time. Only orders the + * fill actually wrote to count towards `TICK_MAX_FILL_ORDERS` — an + * order with nothing to fill is skipped and does not eat the budget, + * so the same old orders cannot block the run. + * + * `cost_price = 0` in the filter is what keeps already-filled lines out + * of the candidate set: a filled line carries the quote's cost, so it + * would otherwise be re-read on every tick and crowd out the lines + * that still need filling. */ -async function writeAiPairs( +async function fillLinkedOrders( pb: AdminPb, - orderId: string, - aiLines: OrderAiLinePair[], -): Promise { - const written: string[] = []; - if (aiLines.length === 0) return written; - const current = await pb.collection("order_items").getFullList<{ + summary: OrderAssistantRunSummary, + nowFn: () => Date, + startMs: number, +): Promise { + const candidates = await pb.collection("order_items").getList<{ id: string; + order: string; quote_item: string; - }>({ - filter: `order = "${escapeForPbString(orderId)}"`, - fields: "id,quote_item", - batch: 500, + item_status: string; + }>(1, TICK_CANDIDATE_CAP, { + // A paired line that is still pending and carries no cost yet. + // `cost_price = 0` mirrors the quote assistant's open-line filter — + // an optional number field reads as 0 until something is written. + filter: `quote_item != "" && item_status = "pending" && cost_price = 0`, + // Newest first: a buyer who added lines to a linked order should + // see them filled promptly, and stale lines that can never be + // filled drift to the back instead of blocking the run. + sort: "-created", + fields: "id,order,quote_item,item_status", }); - const byId = new Map(current.map((c) => [c.id, c.quote_item ?? ""])); - for (const pair of aiLines) { - if (!pair.quote_line_id) continue; - if (byId.get(pair.order_line_id)) continue; - try { - await pb.collection("order_items").update(pair.order_line_id, { - quote_item: pair.quote_line_id, - }); - byId.set(pair.order_line_id, pair.quote_line_id); - written.push(pair.order_line_id); - } catch (e) { - console.error( - `[order-assistant] could not write the AI pairing onto line ${pair.order_line_id}:`, - errMessage(e), - ); - } - } - return written; -} -/** Undo `writeAiPairs` when the link it prepared for is refused. */ -async function clearAiPairs(pb: AdminPb, lineIds: string[]): Promise { - for (const id of lineIds) { + const seen = new Set(); + let ordersFilled = 0; + for (const row of candidates.items) { + if (ordersFilled >= TICK_MAX_FILL_ORDERS) break; + if (nowFn().getTime() - startMs >= TICK_TIME_BUDGET_MS) break; + const orderId = row.order || ""; + if (!orderId || seen.has(orderId)) continue; + seen.add(orderId); + + // Only orders that are actually linked to a quote can be filled. + let linked = false; try { - await pb.collection("order_items").update(id, { quote_item: "" }); + const order = await pb.collection("orders").getOne<{ quote?: string }>(orderId, { + fields: "id,quote", + }); + linked = !!order.quote; + } catch { + linked = false; + } + if (!linked) continue; + + try { + const r = await fillOrderFromQuote(orderId, { now: nowFn }); + if (r.filled > 0) { + ordersFilled += 1; + summary.lines_filled += r.filled; + } } catch (e) { - console.error(`[order-assistant] could not undo the AI pairing on line ${id}:`, errMessage(e)); + summary.errors.push(`${orderId}: fill failed: ${errMessage(e)}`); } } } @@ -681,6 +728,19 @@ export async function runOrderAssistantTick( summary.errors.push(`${row.id}: ${errMessage(e)}`); } } + + // Second pass: fill the lines of orders that are already linked to a + // quote but whose lines have no AI fill yet — an order the buyer + // added lines to after the match, or a link a person made by hand. + // Like the match pass, orders the fill does not touch do not use up + // the budget. + if (settings.order_assistant_fill_lines) { + try { + await fillLinkedOrders(pb, summary, nowFn, startMs); + } catch (e) { + summary.errors.push(`fill pass failed: ${errMessage(e)}`); + } + } } catch (e) { summary.errors.push(`tick failed: ${errMessage(e)}`); } diff --git a/src/lib/server/order-quote-link-service.test.ts b/src/lib/server/order-quote-link-service.test.ts index a148dd8..76de895 100644 --- a/src/lib/server/order-quote-link-service.test.ts +++ b/src/lib/server/order-quote-link-service.test.ts @@ -226,14 +226,17 @@ describe("suggestQuoteForOrder", () => { })); const quotesGetList = vi.fn().mockResolvedValue({ items: quoteRows }); - // Simulate PB rejecting the first attempt (the `quote IN (...)` - // syntax) the way a too-long filter expression would 400 in real - // PB, then succeeding on every chunked retry. The un-fixed code - // issued a single unchunked OR-chain call with no fallback, so - // this same mock makes suggestQuoteForOrder reject outright - // against that code — it only resolves once the fetch is chunked. - const quoteItemsGetFullList = vi.fn().mockImplementation(() => { - if (quoteItemsGetFullList.mock.calls.length === 1) { + // PB rejects a filter that embeds too many ids, the way a real + // oversized filter expression 400s. Reject on the SHAPE of the + // filter (one chunk's worth of ids or fewer), not on a call index: + // the fetch must go straight to chunked OR-chains, so the very + // first call already has to be a chunk. The un-fixed code issued a + // single unchunked OR-chain with no fallback, so this same mock + // makes suggestQuoteForOrder reject outright against that code. + const CHUNK = 80; + const quoteItemsGetFullList = vi.fn().mockImplementation((opts: { filter: string }) => { + const idsInFilter = (opts.filter.match(/quote = "/g) || []).length; + if (idsInFilter > CHUNK) { return Promise.reject(new Error("PB 400: filter expression too long")); } return Promise.resolve([]); @@ -251,27 +254,33 @@ describe("suggestQuoteForOrder", () => { }), ); - // Must resolve, not reject — the un-fixed code has no fallback and - // propagates the simulated PB rejection straight out of - // suggestQuoteForOrder. + // Must resolve, not reject — an unchunked fetch propagates the + // simulated PB rejection straight out of suggestQuoteForOrder. await expect(suggestQuoteForOrder("ord1")).resolves.toBeDefined(); - // First call is the failed IN(...) attempt; every call after it is - // a chunked OR-chain retry, so there must be more than one. + // Every call is a chunked OR-chain fetch, and there must be more + // than one (100 ids cannot fit in one 80-id chunk). expect(quoteItemsGetFullList.mock.calls.length).toBeGreaterThan(1); - const chunkCalls = quoteItemsGetFullList.mock.calls.slice(1); - let idsAcrossChunks = 0; - for (const [opts] of chunkCalls) { + // The matcher may widen its window and run a second pass, so the + // same ids can be fetched twice. What must hold: no single call + // embeds more than a chunk's worth of ids, no chunk repeats an id, + // and every candidate id is covered. + const seenIds = new Set(); + for (const [opts] of quoteItemsGetFullList.mock.calls) { const filter = (opts as { filter: string }).filter; - const idsInThisChunk = (filter.match(/quote = "/g) || []).length; - // No single retry re-embeds every candidate id in one filter — + const ids = [...filter.matchAll(/quote = "([^"]+)"/g)].map((m) => m[1]); + // No single call re-embeds every candidate id in one filter — // that's exactly the oversized-filter bug this test pins. - expect(idsInThisChunk).toBeLessThanOrEqual(80); - idsAcrossChunks += idsInThisChunk; + expect(ids.length).toBeLessThanOrEqual(CHUNK); + // No id duplicated inside a chunk. + expect(new Set(ids).size).toBe(ids.length); + // PocketBase has no IN operator, so an id list is an OR-chain. + expect(filter).not.toMatch(/\bIN\s*\(/); + for (const id of ids) seenIds.add(id); } - // No ids lost or duplicated across chunks. - expect(idsAcrossChunks).toBe(CANDIDATE_COUNT); + // No ids lost. + expect(seenIds.size).toBe(CANDIDATE_COUNT); }); it("records no_match and still stamps quote_matching_attempted", async () => { diff --git a/src/lib/server/order-quote-link-service.ts b/src/lib/server/order-quote-link-service.ts index 5817ebf..26edae1 100644 --- a/src/lib/server/order-quote-link-service.ts +++ b/src/lib/server/order-quote-link-service.ts @@ -221,11 +221,10 @@ async function fuzzyPass( // A naive `quote = "x" || quote = "y" || ...` OR-chain over // FUZZY_CANDIDATE_CAP (200) ids blows PB's filter length limit // (HTTP 400) well before hitting the cap — `fetchQuoteItemsForQuotesAdmin` - // tries PB's `IN (...)` syntax first and falls back to chunked - // OR-chains, so a company with a large quote history doesn't break - // the matcher outright. Reused (not reimplemented) from - // `quotes-service.ts`, which already has this exact chunking logic - // for its duplicate-quote scan. + // chunks the ids into OR-chains of its own, so a company with a large + // quote history doesn't break the matcher outright. Reused (not + // reimplemented) from `quotes-service.ts`, which already has this + // exact chunking logic for its duplicate-quote scan. const quoteIds = candidates.items.map((q) => q.id); const quoteItems = await fetchQuoteItemsForQuotesAdmin(pb, quoteIds, MATCHER_QUOTE_ITEM_FIELDS); @@ -504,9 +503,9 @@ export async function pairOrderLines( batch: 1000, }), // Reuses the same helper the fuzzy pass uses for quote_items — it - // already handles PB's IN(...) filter and the chunked-OR-chain - // fallback, so there is one code path in this file that knows how - // to fetch quote_items rather than two. + // already chunks the ids into OR-chains so PB's filter-length + // limit is never hit, so there is one code path in this file + // that knows how to fetch quote_items rather than two. fetchQuoteItemsForQuotesAdmin(pb, [quoteId], PAIR_QUOTE_ITEM_FIELDS), ]); diff --git a/src/lib/server/orders-service.ts b/src/lib/server/orders-service.ts index 55671fd..2dac58e 100644 --- a/src/lib/server/orders-service.ts +++ b/src/lib/server/orders-service.ts @@ -1,6 +1,13 @@ import { after } from "next/server"; import { getAdminPocketBase } from "./pocketbase-admin"; import { pairOrderLines, suggestQuoteForOrder } from "./order-quote-link-service"; +import { + aiQuotePairs, + fillOrderFromQuote, + writeAiPairs, +} from "./order-assistant-lines"; +import { getAiSettings } from "./ai-settings-service"; +import { runWithAuditActor } from "./audit-context"; import { suggestDeliveryNoteForOrder } from "./order-delivery-note-link-service"; import { suggestOrderNumberDuplicate } from "./order-number-duplicate-link-service"; import { suggestOrderDuplicate } from "./order-order-duplicate-link-service"; @@ -4296,6 +4303,26 @@ export async function setOrderQuote( // Capture the previous quote value for the response. const previous: { quote: string } = { quote: existing.quote || "" }; + // ── AI order assistant: a person accepted the AI's suggestion ── + // + // When the order carries an AI verdict for THIS quote, the AI already + // paired the order lines to the quote lines. Those pairs go on BEFORE + // `pairOrderLines` runs, because the word-based pairing never + // overwrites a `quote_item` that is already set — so the AI's pairing + // wins on every line it paired, and the description matcher only + // fills the lines the AI left alone. A `quote_item` a person set is + // never overwritten. + if (existing.ai_quote_match?.quote_id === quoteId) { + try { + await writeAiPairs(pb, orderId, aiQuotePairs(existing.ai_quote_match)); + } catch (err) { + console.error( + `[setOrderQuote] could not write the AI line pairs for order ${orderId}:`, + err instanceof Error ? err.message : err, + ); + } + } + // Idempotent no-op when the order already points at this quote. // Don't write `orders.quote`, don't stamp — just return the order // as-is. Pairing still runs on this path: it's safe to repeat @@ -4313,6 +4340,7 @@ export async function setOrderQuote( if (previous.quote === quoteId) { const fresh = await pb.collection(ORDER_COLLECTION).getOne(orderId); await pairOrderLinesSafely(orderId, quoteId); + await fillOrderLinesFromQuoteSafely(orderId); return { order: fresh, previous }; } @@ -4335,9 +4363,37 @@ ${stamp}` : stamp; // doubtful ones are surfaced in the UI for confirmation instead. await pairOrderLinesSafely(orderId, quoteId); + // The AI order assistant fills the order's lines from the quote they + // are now paired with. Only when the operator left both switches on. + await fillOrderLinesFromQuoteSafely(orderId); + return { order: updated, previous }; } +/** + * The AI order assistant's fill hook: copy the quote's cost / supplier / + * SKU onto the order lines that may still be written. + * + * Runs only when the order assistant and the fill switch are both on. + * A failure is logged and swallowed — the link the operator just made + * must never fail because the fill could not be written. + */ +async function fillOrderLinesFromQuoteSafely(orderId: string): Promise { + try { + const settings = await getAiSettings(); + if (!settings.order_assistant_enabled || !settings.order_assistant_fill_lines) return; + await runWithAuditActor( + { name: "AI order assistant", source: "agent", ignoreRequestUser: true }, + () => fillOrderFromQuote(orderId), + ); + } catch (err) { + console.error( + `[setOrderQuote] AI fill failed for order ${orderId}:`, + err instanceof Error ? err.message : err, + ); + } +} + /** * Run `pairOrderLines`, never letting it fail the caller. * diff --git a/src/lib/server/quotes-service.ts b/src/lib/server/quotes-service.ts index e66dee5..4073d8b 100644 --- a/src/lib/server/quotes-service.ts +++ b/src/lib/server/quotes-service.ts @@ -3533,15 +3533,11 @@ export async function fetchQuoteItemsForQuotesAdmin( if (quoteIds.length === 0) return []; const escape = (id: string) => id.replace(/"/g, '\\"'); const safeIds = quoteIds.map(escape); - try { - return await pb.collection(QUOTE_ITEMS_COLLECTION).getFullList({ - filter: `quote IN (${safeIds.map((id) => `"${id}"`).join(",")})`, - fields, - batch: 5000, - }); - } catch { - // PB rejected the IN filter — fall back to chunked OR-chains. - } + // PocketBase filters have NO `IN` operator — `quote IN ("a","b")` is a + // 400. An id list is an OR-chain, chunked so the filter stays under + // PB's URL length cap. (The old code tried `IN` first and fell back + // to exactly this on the resulting 400, so every call paid for a + // guaranteed failing round-trip.) const CHUNK = 80; const chunks: string[][] = []; for (let i = 0; i < safeIds.length; i += CHUNK) {