diff --git a/src/lib/server/order-assistant-service.test.ts b/src/lib/server/order-assistant-service.test.ts new file mode 100644 index 0000000..4468c2a --- /dev/null +++ b/src/lib/server/order-assistant-service.test.ts @@ -0,0 +1,496 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/server/pocketbase-admin", () => ({ + getAdminPocketBase: vi.fn(), +})); + +vi.mock("@/lib/server/ai-settings-service", () => ({ + getActiveAiConfig: vi.fn(), + getAiSettings: vi.fn(), + recordOrderAssistantRun: vi.fn(), + clearAiSettingsCache: vi.fn(), + AiDisabledError: class AiDisabledError extends Error { + readonly code = "ai_disabled"; + constructor(message: string) { + super(message); + this.name = "AiDisabledError"; + } + }, +})); + +vi.mock("@/lib/server/quotes-service", () => ({ + fetchQuoteItemsForQuotesAdmin: vi.fn(), +})); + +import { + matchOrder, + runOrderAssistantTick, + resetAiQuoteMatchFieldCheck, +} from "@/lib/server/order-assistant-service"; +import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; +import { getAiSettings, recordOrderAssistantRun } from "@/lib/server/ai-settings-service"; +import { fetchQuoteItemsForQuotesAdmin } from "@/lib/server/quotes-service"; + +const ORDERS = "orders"; +const ORDER_ITEMS = "order_items"; +const QUOTES = "quotes"; +const SUPPLIERS = "suppliers"; + +interface CollectionMock { + getOne?: ReturnType; + getList?: ReturnType; + getFullList?: ReturnType; + update?: ReturnType; +} + +const ORDER = { + id: "ord1", + order_number: "ORD-2026-10-001", + created: "2026-10-20T10:00:00.000Z", + company: "co1", + customer: "cust1", + quote: "", + ai_quote_match: null, +}; + +const ORDER_LINES = [ + { + id: "ol1", + order: "ord1", + description: "M16 bolt 60mm", + po_description: "BOLT M16X60", + quantity: 100, + item_status: "pending", + selling_price: 350, + sku: "", + quote_item: "", + }, + { + id: "ol2", + order: "ord1", + description: "M16 nut", + po_description: "", + quantity: 100, + item_status: "pending", + selling_price: 50, + sku: "", + quote_item: "", + }, +]; + +const QUOTE_ITEMS = [ + { + id: "ql1", + quote: "q1", + description: "M16 x 60 bolt", + quantity: 200, + selling_price: 340, + sku: "56020510", + supplier: "sup1", + }, + { + id: "ql2", + quote: "q1", + description: "M16 nut", + quantity: 200, + selling_price: 45, + sku: "", + supplier: "sup1", + }, +]; + +/** + * The makePb pattern from `order-quote-link-service.test.ts`: sensible + * defaults for every collection, merged per METHOD with the per-test + * overrides so a test only has to declare what it cares about. + */ +function makePb(overrides: Record = {}) { + const defaults: Record = { + [ORDERS]: { + getOne: vi.fn().mockResolvedValue({ ...ORDER }), + getList: vi.fn().mockResolvedValue({ items: [] }), + getFullList: vi.fn().mockResolvedValue([]), + update: vi.fn().mockResolvedValue({}), + }, + [ORDER_ITEMS]: { + getFullList: vi.fn().mockResolvedValue(ORDER_LINES.map((l) => ({ ...l }))), + update: vi.fn().mockResolvedValue({}), + }, + [QUOTES]: { + getFullList: vi.fn().mockResolvedValue([ + { id: "q1", quote_number: "Q-2026-10-014", company: "co1", created: "2026-10-01T00:00:00.000Z" }, + ]), + }, + [SUPPLIERS]: { + getFullList: vi.fn().mockResolvedValue([{ id: "sup1", suppliername: "Afrisam" }]), + }, + }; + const merged: Record = {}; + for (const name of new Set([...Object.keys(defaults), ...Object.keys(overrides)])) { + merged[name] = { ...(defaults[name] ?? {}), ...(overrides[name] ?? {}) }; + } + return { + autoCancellation: vi.fn(), + collections: { + getOne: vi.fn().mockResolvedValue({ + fields: [{ 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>, + ); +} + +const SUGGESTION = { + orderId: "ord1", + best: { + quoteId: "q1", + quoteNumber: "Q-2026-10-014", + quoteCreated: "2026-10-01T00:00:00.000Z", + confidence: 0.8, + method: "company_customer_desc_price" as const, + }, + alternatives: [], + method: "company_customer_desc_price" as const, + window: "initial" as const, + ageGapDays: 3, +}; + +function aiDecision(over: Record = {}) { + return { + decision: { + quote_id: "q1", + confidence: "high" as const, + reason: "same items", + doubts: [], + lines: [ + { order_line_id: "ol1", quote_line_id: "ql1", same_item: "sure" as const, reason: "same" }, + { order_line_id: "ol2", quote_line_id: "ql2", same_item: "sure" as const, reason: "same" }, + ], + ...over, + }, + model: "test-model", + }; +} + +function baseSettings(over: Record = {}) { + return { + enabled: true, + order_assistant_enabled: true, + order_assistant_interval_minutes: 10, + order_assistant_auto_link: true, + order_assistant_fill_lines: true, + order_assistant_prompt: "", + order_assistant_last_run_at: "", + order_assistant_last_run_summary: null, + ...over, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + resetAiQuoteMatchFieldCheck(); + vi.mocked(getAiSettings).mockResolvedValue(baseSettings() as never); + vi.mocked(recordOrderAssistantRun).mockResolvedValue(undefined); + vi.mocked(fetchQuoteItemsForQuotesAdmin).mockResolvedValue(QUOTE_ITEMS as never); +}); + +describe("matchOrder", () => { + it("auto-links when the AI is sure and writes the pairing onto empty lines", async () => { + const pb = makePb(); + mockAdminPb(pb); + const link = vi.fn().mockResolvedValue({ order: {}, previous: { quote: "" } }); + const match = vi.fn().mockResolvedValue(aiDecision()); + const suggest = vi.fn().mockResolvedValue(SUGGESTION); + + const res = await matchOrder("ord1", { deps: { link, match, suggest } }); + + expect(res.outcome).toBe("linked"); + expect(res.quoteId).toBe("q1"); + expect(link).toHaveBeenCalledTimes(1); + expect(link.mock.calls[0][0]).toMatchObject({ orderId: "ord1", quoteId: "q1" }); + + const orderUpdate = pb.collection(ORDERS).update as ReturnType; + expect(orderUpdate).toHaveBeenCalledWith( + "ord1", + expect.objectContaining({ quote_match_method: "ai_auto" }), + ); + + // The AI pairing is written onto the lines. + const itemUpdate = pb.collection(ORDER_ITEMS).update as ReturnType; + expect(itemUpdate).toHaveBeenCalledWith("ol1", { quote_item: "ql1" }); + expect(itemUpdate).toHaveBeenCalledWith("ol2", { quote_item: "ql2" }); + }); + + it("never overwrites a quote_item a person set", async () => { + const pb = makePb({ + [ORDER_ITEMS]: { + getFullList: vi.fn().mockResolvedValue([ + { ...ORDER_LINES[0], quote_item: "ql-person" }, + { ...ORDER_LINES[1] }, + ]), + update: vi.fn().mockResolvedValue({}), + }, + }); + 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 itemUpdate = pb.collection(ORDER_ITEMS).update as ReturnType; + expect(itemUpdate).not.toHaveBeenCalledWith("ol1", expect.anything()); + expect(itemUpdate).toHaveBeenCalledWith("ol2", { quote_item: "ql2" }); + }); + + it("falls back to a suggestion when the link is refused with 409", async () => { + const pb = makePb(); + mockAdminPb(pb); + const err = new Error("locked"); + (err as Error & { status?: number }).status = 409; + const link = vi.fn().mockRejectedValue(err); + + const res = await matchOrder("ord1", { + deps: { + link, + match: vi.fn().mockResolvedValue(aiDecision()), + suggest: vi.fn().mockResolvedValue(SUGGESTION), + }, + }); + + expect(res.outcome).toBe("suggested"); + expect(res.quoteId).toBe("q1"); + const orderUpdate = pb.collection(ORDERS).update as ReturnType; + expect(orderUpdate).toHaveBeenCalledWith( + "ord1", + expect.objectContaining({ + potential_quote: "q1", + potential_quote_confidence: 0.9, + quote_match_method: "ai_suggest", + }), + ); + }); + + it("suggests (and does not link) on medium confidence", async () => { + const pb = makePb(); + mockAdminPb(pb); + const link = vi.fn(); + + const res = await matchOrder("ord1", { + deps: { + link, + match: vi.fn().mockResolvedValue(aiDecision({ confidence: "medium" })), + suggest: vi.fn().mockResolvedValue(SUGGESTION), + }, + }); + + expect(res.outcome).toBe("suggested"); + expect(link).not.toHaveBeenCalled(); + const orderUpdate = pb.collection(ORDERS).update as ReturnType; + expect(orderUpdate).toHaveBeenCalledWith( + "ord1", + expect.objectContaining({ potential_quote_confidence: 0.7, quote_match_method: "ai_suggest" }), + ); + }); + + it("suggests (and does not link) when auto-link is switched off", async () => { + vi.mocked(getAiSettings).mockResolvedValue( + baseSettings({ order_assistant_auto_link: false }) as never, + ); + const pb = makePb(); + mockAdminPb(pb); + const link = vi.fn(); + + const res = await matchOrder("ord1", { + deps: { + link, + match: vi.fn().mockResolvedValue(aiDecision()), + suggest: vi.fn().mockResolvedValue(SUGGESTION), + }, + }); + + expect(res.outcome).toBe("suggested"); + expect(link).not.toHaveBeenCalled(); + }); + + it("does not call the AI when the shortlist is empty", async () => { + const pb = makePb(); + mockAdminPb(pb); + const match = vi.fn(); + + const res = await matchOrder("ord1", { + deps: { + link: vi.fn(), + match, + suggest: vi.fn().mockResolvedValue({ + ...SUGGESTION, + best: null, + alternatives: [], + }), + }, + }); + + expect(res.outcome).toBe("no_match"); + expect(match).not.toHaveBeenCalled(); + const orderUpdate = pb.collection(ORDERS).update as ReturnType; + expect(orderUpdate).toHaveBeenCalledWith( + "ord1", + expect.objectContaining({ quote_match_method: "ai_no_match" }), + ); + }); + + it("records an AI failure on the order and does not throw", async () => { + const pb = makePb(); + mockAdminPb(pb); + + const res = await matchOrder("ord1", { + deps: { + link: vi.fn(), + match: vi.fn().mockRejectedValue(new Error("AI exploded")), + suggest: vi.fn().mockResolvedValue(SUGGESTION), + }, + }); + + expect(res.outcome).toBe("error"); + expect(res.error).toBe("AI exploded"); + const orderUpdate = pb.collection(ORDERS).update as ReturnType; + const patch = orderUpdate.mock.calls[0][1] as { ai_quote_match: { status: string; error: string } }; + expect(patch.ai_quote_match.status).toBe("error"); + expect(patch.ai_quote_match.error).toBe("AI exploded"); + }); + + it("skips a placeholder-only order and writes nothing", async () => { + const pb = makePb({ + [ORDER_ITEMS]: { + getFullList: vi.fn().mockResolvedValue([ + { ...ORDER_LINES[0], description: "See attached" }, + ]), + update: vi.fn().mockResolvedValue({}), + }, + }); + mockAdminPb(pb); + const match = vi.fn(); + + const res = await matchOrder("ord1", { + deps: { link: vi.fn(), match, suggest: vi.fn().mockResolvedValue(SUGGESTION) }, + }); + + expect(res.outcome).toBe("skipped"); + expect(match).not.toHaveBeenCalled(); + expect(pb.collection(ORDERS).update).not.toHaveBeenCalled(); + expect(pb.collection(ORDER_ITEMS).update).not.toHaveBeenCalled(); + }); + + it("skips an order that already has a quote", async () => { + const pb = makePb({ + [ORDERS]: { + getOne: vi.fn().mockResolvedValue({ ...ORDER, quote: "q-other" }), + getList: vi.fn().mockResolvedValue({ items: [] }), + getFullList: vi.fn().mockResolvedValue([]), + update: vi.fn().mockResolvedValue({}), + }, + }); + mockAdminPb(pb); + + const res = await matchOrder("ord1", { + deps: { + link: vi.fn(), + match: vi.fn(), + suggest: vi.fn(), + }, + }); + + expect(res.outcome).toBe("skipped"); + expect(pb.collection(ORDERS).update).not.toHaveBeenCalled(); + }); + + it("reports that the migration has not run yet", async () => { + const pb = makePb(); + pb.collections.getOne = vi.fn().mockResolvedValue({ fields: [{ name: "quote" }] }); + mockAdminPb(pb); + + const res = await matchOrder("ord1", { deps: {} }); + + expect(res.outcome).toBe("error"); + expect(res.error).toContain("pb:migrate:order-assistant"); + }); +}); + +describe("runOrderAssistantTick", () => { + it("returns disabled when the order assistant is off", async () => { + vi.mocked(getAiSettings).mockResolvedValue( + baseSettings({ order_assistant_enabled: false }) as never, + ); + const res = await runOrderAssistantTick(); + expect(res).toEqual({ ran: false, reason: "disabled" }); + }); + + it("returns disabled when the global AI switch is off", async () => { + vi.mocked(getAiSettings).mockResolvedValue(baseSettings({ enabled: false }) as never); + const res = await runOrderAssistantTick(); + expect(res).toEqual({ ran: false, reason: "disabled" }); + }); + + it("returns not_due when the interval has not elapsed", async () => { + vi.mocked(getAiSettings).mockResolvedValue( + baseSettings({ order_assistant_last_run_at: new Date().toISOString() }) as never, + ); + const res = await runOrderAssistantTick(); + expect(res).toEqual({ ran: false, reason: "not_due" }); + }); + + it("stops after 10 orders", async () => { + const pb = makePb({ + [ORDERS]: { + getOne: vi.fn().mockResolvedValue({ ...ORDER }), + // Deliberately ignores perPage — the tick's own cap must stop it. + getList: vi.fn().mockResolvedValue({ + items: Array.from({ length: 25 }, (_, i) => ({ ...ORDER, id: `ord${i}` })), + }), + getFullList: vi.fn().mockResolvedValue([]), + update: vi.fn().mockResolvedValue({}), + }, + }); + 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?.orders_seen).toBe(10); + }); + + it("writes a summary with the run stamp", async () => { + const pb = makePb(); + 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(recordOrderAssistantRun).toHaveBeenCalledWith({ last_run_at: expect.any(String) }); + expect(recordOrderAssistantRun).toHaveBeenCalledWith({ + summary: expect.objectContaining({ orders_seen: expect.any(Number) }), + }); + }); +}); diff --git a/src/lib/server/order-assistant-service.ts b/src/lib/server/order-assistant-service.ts new file mode 100644 index 0000000..7e0e2d1 --- /dev/null +++ b/src/lib/server/order-assistant-service.ts @@ -0,0 +1,650 @@ +/** + * order-assistant-service — matches an order to the quote it came from. + * + * The AI decides; this module decides what to DO with the decision. + * + * Rules (from the design spec): + * - `orders.quote` is only ever written through `setOrderQuote`, inside + * `runWithAuditActor({ name: "AI order assistant" }, …)`, so the audit + * trail shows the AI did it. + * - The AI may hard-link an order only when it is sure: high + * confidence, every real order line paired "sure", every pair on a + * line of the one chosen quote, and the quote belonging to the + * order's company. Anything else is a soft suggestion for a human. + * - `setOrderQuote` refusing (409) is NOT an error — it falls back to + * a suggestion. + * - The AI never changes a line's status, never creates a purchase + * order and never sends anything. + * - An AI failure is recorded on `orders.ai_quote_match` with status + * "error" and is never thrown out of `matchOrder`. + */ +import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; +import { fetchQuoteItemsForQuotesAdmin } from "@/lib/server/quotes-service"; +import { setOrderQuote } from "@/lib/server/orders-service"; +import { + computeQuoteSuggestion, + type QuoteMatchCandidate, + type SuggestQuoteResult, +} from "@/lib/server/order-quote-link-service"; +import { matchOrderWithAi, type MatchInput } from "@/lib/server/order-assistant-ai"; +import { runWithAuditActor } from "@/lib/server/audit-context"; +import { + AiDisabledError, + clearAiSettingsCache, + getAiSettings, + recordOrderAssistantRun, +} from "@/lib/server/ai-settings-service"; +import { + confidenceToScore, + decideOutcome, + isOrderEligible, + isRealOrderLine, + orderLinesHash, + type AiOrderMatchDecision, +} from "@/lib/server/order-assistant-core"; +import { escapeForPbString } from "@/lib/server/price-list-match"; +import type { + Order, + OrderAiLinePair, + OrderAiQuoteMatch, + OrderAssistantRunSummary, + OrderItem, + +} from "@/lib/types"; + +/** Orders the AI may look at per tick. */ +export const TICK_MAX_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. */ +export const TICK_LEASE_MS = 10 * 60_000; +/** Candidate quotes handed to the AI per order. */ +const SHORTLIST_MAX = 5; +/** Order lines per AI call — keeps one call well inside the model's limit. */ +const AI_BATCH_LINES = 30; +/** Only orders created this recently are picked up by the tick. */ +const TICK_ORDER_AGE_DAYS = 30; + +const ACTOR_NAME = "AI order assistant"; +const MAX_SUMMARY_ERRORS = 20; +const MAX_SUMMARY_ERROR_CHARS = 300; + +/** `MatchOrderResult.error` when the global AI switch is off (409). */ +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 = + "Order assistant is not set up yet: run `npm run pb:migrate:order-assistant` against PocketBase first"; + +export interface MatchOrderResult { + orderId: string; + outcome: "linked" | "suggested" | "no_match" | "skipped" | "error"; + quoteId: string | null; + error: string; +} + +export interface OrderAssistantDeps { + match?: typeof matchOrderWithAi; + suggest?: typeof computeQuoteSuggestion; + link?: typeof setOrderQuote; + now?: () => Date; +} + +type AdminPb = Awaited>; + +let aiQuoteMatchFieldConfirmed = false; + +/** + * PocketBase silently drops unknown fields on update. Without the + * `orders.ai_quote_match` field (added by the migration) the AI's + * verdict would land nowhere, so refuse to write until it exists. + * A positive answer is cached for the life of the process. + */ +async function hasAiQuoteMatchField(pb: AdminPb): Promise { + if (aiQuoteMatchFieldConfirmed) return true; + const collection = (await pb.collections.getOne("orders")) as { + fields?: Array<{ name: string }>; + schema?: Array<{ name: string }>; + }; + const fields = collection.fields ?? collection.schema ?? []; + aiQuoteMatchFieldConfirmed = fields.some((f) => f.name === "ai_quote_match"); + return aiQuoteMatchFieldConfirmed; +} + +/** Test hook: forget the cached schema check. */ +export function resetAiQuoteMatchFieldCheck(): void { + aiQuoteMatchFieldConfirmed = false; +} + +function errMessage(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + +function boundRunErrors(errors: string[]): string[] { + return errors + .slice(0, MAX_SUMMARY_ERRORS) + .map((e) => e.slice(0, MAX_SUMMARY_ERROR_CHARS)); +} + +interface OrderLite { + id: string; + order_number: string; + created: string; + company: string; + customer: string; + customer_name: string; + quote: string; + ai_quote_match?: OrderAiQuoteMatch | null; +} + +interface QuoteLite { + id: string; + quote_number: string; + company: string; + created: string; +} + +async function loadOrder(pb: AdminPb, orderId: string): Promise { + const order = await pb.collection("orders").getOne(orderId, { + expand: "customer", + }); + return { + id: order.id, + order_number: order.order_number ?? "", + created: order.created ?? "", + company: order.company ?? "", + customer: order.customer ?? "", + customer_name: (order as unknown as { expand?: { customer?: { customer?: string } } }).expand + ?.customer?.customer ?? "", + quote: order.quote ?? "", + ai_quote_match: order.ai_quote_match ?? null, + }; +} + +async function loadOrderLines(pb: AdminPb, orderId: string): Promise { + return pb.collection("order_items").getFullList({ + filter: `order = "${escapeForPbString(orderId)}"`, + sort: "created", + batch: 500, + }); +} + +async function loadShortlistQuotes( + pb: AdminPb, + quoteIds: string[], +): Promise> { + const out = new Map(); + if (quoteIds.length === 0) return out; + const rows = await pb.collection("quotes").getFullList({ + filter: `id IN (${quoteIds.map((id) => `"${escapeForPbString(id)}"`).join(",")})`, + fields: "id,quote_number,company,created", + batch: 200, + }); + for (const row of rows) out.set(row.id, row); + return out; +} + +async function loadSupplierNames( + pb: AdminPb, + supplierIds: string[], +): Promise> { + const out = new Map(); + const ids = [...new Set(supplierIds.filter(Boolean))]; + if (ids.length === 0) return out; + const rows = await pb.collection("suppliers").getFullList<{ id: string; suppliername: string }>({ + filter: `id IN (${ids.map((id) => `"${escapeForPbString(id)}"`).join(",")})`, + fields: "id,suppliername", + batch: 200, + }); + for (const row of rows) out.set(row.id, row.suppliername ?? ""); + return out; +} + +/** The deterministic shortlist: the best candidate plus its alternatives. */ +function buildShortlist(suggestion: SuggestQuoteResult): QuoteMatchCandidate[] { + const out: QuoteMatchCandidate[] = []; + if (suggestion.best) out.push(suggestion.best); + for (const alt of suggestion.alternatives) out.push(alt); + return out.slice(0, SHORTLIST_MAX); +} + +/** The lowest confidence across the batches wins. */ +function lowestConfidence(a: "high" | "medium" | "low", b: "high" | "medium" | "low") { + const rank: Record = { high: 3, medium: 2, low: 1 }; + return rank[a] <= rank[b] ? a : b; +} + +function batch(items: T[], size: number): T[][] { + const out: T[][] = []; + for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size)); + return out; +} + +/** + * Match one order to a quote. + * + * Returns what happened; never throws for an AI failure — that is + * recorded on the order instead. + */ +export async function matchOrder( + orderId: string, + opts: { force?: boolean; deps?: OrderAssistantDeps } = {}, +): Promise { + const deps = opts.deps ?? {}; + const nowFn = deps.now ?? (() => new Date()); + const matchFn = deps.match ?? matchOrderWithAi; + const suggestFn = deps.suggest ?? computeQuoteSuggestion; + const linkFn = deps.link ?? setOrderQuote; + + const result: MatchOrderResult = { orderId, outcome: "skipped", quoteId: null, error: "" }; + + const pb = await getAdminPocketBase(); + pb.autoCancellation(false); + + if (!(await hasAiQuoteMatchField(pb))) { + result.outcome = "error"; + result.error = NOT_SET_UP_ERROR; + return result; + } + + const order = await loadOrder(pb, orderId); + const lines = await loadOrderLines(pb, orderId); + const now = nowFn(); + const settings = await getAiSettings(); + + // Nothing to decide — a linked order, an order with no real lines, an + // order already looked at on these same lines, or one in an AI-error + // cooldown. Write nothing at all. + if (!isOrderEligible(order, lines, { now, force: opts.force })) { + return result; + } + + const realLines = lines.filter((l) => isRealOrderLine(l)); + const realLineIds = realLines.map((l) => l.id); + const linesHash = orderLinesHash(realLines); + + // 1. The deterministic shortlist decides WHICH quotes the AI may choose + // from. No shortlist, no AI call — the order simply has no candidate. + let suggestion: SuggestQuoteResult; + try { + suggestion = await suggestFn(orderId); + } catch (e) { + result.outcome = "error"; + result.error = `quote shortlist failed: ${errMessage(e)}`; + return result; + } + const shortlist = buildShortlist(suggestion); + + if (shortlist.length === 0) { + await pb.collection("orders").update(orderId, { + quote_match_method: "ai_no_match", + potential_quote: "", + potential_quote_confidence: 0, + ai_quote_match: { + status: "no_match", + quote_id: null, + quote_number: "", + confidence: null, + reason: "No candidate quote shares a line with this order.", + doubts: [], + lines: [], + auto_linked: false, + lines_hash: linesHash, + model: "", + run_at: now.toISOString(), + } satisfies OrderAiQuoteMatch, + }); + result.outcome = "no_match"; + return result; + } + + // 2. Load the candidate quotes and their lines for the AI. + const quoteIds = shortlist.map((c) => c.quoteId); + const quoteRows = await loadShortlistQuotes(pb, quoteIds); + const quoteItems = await fetchQuoteItemsForQuotesAdmin( + pb, + quoteIds, + "id,quote,description,sku,quantity,selling_price,supplier", + ); + const supplierNames = await loadSupplierNames( + pb, + quoteItems.map((q) => q.supplier ?? ""), + ); + + const quotesForAi: MatchInput["quotes"] = quoteIds + .map((id) => { + const row = quoteRows.get(id); + return { + id, + quote_number: row?.quote_number ?? "", + created: row?.created ?? "", + lines: quoteItems + .filter((q) => q.quote === id) + .map((q) => ({ + id: q.id, + description: q.description ?? "", + quantity: Number(q.quantity) || 0, + selling_price: Number(q.selling_price) || 0, + sku: q.sku ?? "", + supplier_name: + supplierNames.get(q.supplier ?? "") ?? "", + })), + }; + }) + .filter((q) => q.lines.length > 0); + + // 3. Ask the AI. One call per 30 order lines; the batches share the + // same shortlist and their answers are merged. + let model = ""; + let decision: AiOrderMatchDecision; + try { + const prompt = (settings.order_assistant_prompt || "").trim() || undefined; + + const mergedLines: AiOrderMatchDecision["lines"] = []; + const chosenQuotes = new Set(); + let overallConfidence: "high" | "medium" | "low" = "high"; + let reason = ""; + const doubts: string[] = []; + + for (const chunk of batch(realLines, AI_BATCH_LINES)) { + const input: MatchInput = { + order: { + id: order.id, + order_number: order.order_number, + customer_name: order.customer_name, + lines: chunk.map((l) => ({ + id: l.id, + description: l.description ?? "", + po_description: l.po_description ?? "", + quantity: Number(l.quantity) || 0, + sku: l.sku ?? "", + selling_price: Number(l.selling_price) || 0, + })), + }, + quotes: quotesForAi, + }; + const out = await matchFn(input, prompt ? { prompt } : {}); + model = out.model || model; + mergedLines.push(...out.decision.lines); + overallConfidence = lowestConfidence(overallConfidence, out.decision.confidence); + if (out.decision.quote_id) chosenQuotes.add(out.decision.quote_id); + if (!reason && out.decision.reason) reason = out.decision.reason; + for (const d of out.decision.doubts) { + if (!doubts.includes(d)) doubts.push(d); + } + } + + if (mergedLines.length === 0) { + result.outcome = "error"; + result.error = "no order lines to match"; + return result; + } + + decision = { + // The batches must agree on the quote. If they don't, we cannot + // claim to know which quote the order came from. + quote_id: chosenQuotes.size === 1 ? [...chosenQuotes][0] : null, + confidence: overallConfidence, + reason, + doubts, + lines: mergedLines, + }; + } catch (e) { + if (e instanceof AiDisabledError) { + // AI switched off: write nothing so the order isn't poisoned. + result.outcome = "error"; + result.error = AI_DISABLED_ERROR; + return result; + } + const message = errMessage(e); + await pb.collection("orders").update(orderId, { + ai_quote_match: { + status: "error", + quote_id: null, + quote_number: "", + confidence: null, + reason: "", + doubts: [], + lines: [], + auto_linked: false, + lines_hash: linesHash, + model, + run_at: now.toISOString(), + error: message, + } satisfies OrderAiQuoteMatch, + }); + result.outcome = "error"; + result.error = message; + return result; + } + + // 4. Decide what to do with the answer. + const quoteLineToQuote = new Map(); + for (const item of quoteItems) quoteLineToQuote.set(item.id, item.quote); + + const chosenQuoteId = decision.quote_id; + const chosenQuote = chosenQuoteId ? quoteRows.get(chosenQuoteId) : undefined; + const outcome = decideOutcome({ + decision, + realLineIds, + quoteLineToQuote, + orderCompany: order.company, + quoteCompany: chosenQuote?.company ?? null, + autoLinkEnabled: settings.order_assistant_auto_link, + }); + + const aiLines: OrderAiLinePair[] = decision.lines + .filter((l) => realLineIds.includes(l.order_line_id)) + .map((l) => ({ + order_line_id: l.order_line_id, + quote_line_id: l.quote_line_id, + quote_id: l.quote_line_id ? quoteLineToQuote.get(l.quote_line_id) ?? null : null, + same_item: l.same_item, + reason: l.reason, + })); + + const baseMatch: OrderAiQuoteMatch = { + status: outcome === "auto_link" ? "matched" : outcome === "suggest" ? "suggested" : "no_match", + quote_id: chosenQuoteId, + quote_number: chosenQuote?.quote_number ?? "", + confidence: decision.confidence, + reason: decision.reason, + doubts: decision.doubts, + lines: aiLines, + auto_linked: false, + lines_hash: linesHash, + model, + run_at: now.toISOString(), + }; + + if (outcome === "auto_link" && chosenQuoteId) { + try { + await runWithAuditActor({ name: ACTOR_NAME, source: "agent", ignoreRequestUser: true }, () => + linkFn({ orderId, quoteId: chosenQuoteId }), + ); + } catch (e) { + const status = (e as Error & { status?: number }).status; + if (status === 409) { + // The link was refused (lines moved on, quote taken). That is + // NOT an error — fall back to a suggestion. + await writeSuggestion(pb, orderId, decision, baseMatch, chosenQuoteId); + result.outcome = "suggested"; + result.quoteId = chosenQuoteId; + return result; + } + result.outcome = "error"; + result.error = `link failed: ${errMessage(e)}`; + return result; + } + + // The link exists. Now write the AI's own pairing onto the lines + // that have no pairing yet — a person's pairing always wins. + await writeAiPairs(pb, orderId, aiLines); + + await pb.collection("orders").update(orderId, { + quote_match_method: "ai_auto", + potential_quote: "", + potential_quote_confidence: 0, + ai_quote_match: { ...baseMatch, auto_linked: true } satisfies OrderAiQuoteMatch, + }); + result.outcome = "linked"; + result.quoteId = chosenQuoteId; + return result; + } + + if (outcome === "suggest" && chosenQuoteId) { + await writeSuggestion(pb, orderId, decision, baseMatch, chosenQuoteId); + result.outcome = "suggested"; + result.quoteId = chosenQuoteId; + return result; + } + + // No plausible quote. + await pb.collection("orders").update(orderId, { + quote_match_method: "ai_no_match", + potential_quote: "", + potential_quote_confidence: 0, + ai_quote_match: baseMatch, + }); + result.outcome = "no_match"; + return result; +} + +/** The auto-link switch — read once per run. */ +async function writeSuggestion( + pb: AdminPb, + orderId: string, + decision: AiOrderMatchDecision, + baseMatch: OrderAiQuoteMatch, + quoteId: string, +): Promise { + await pb.collection("orders").update(orderId, { + potential_quote: quoteId, + potential_quote_confidence: confidenceToScore(decision.confidence), + quote_match_method: "ai_suggest", + ai_quote_match: { ...baseMatch, status: "suggested" } satisfies OrderAiQuoteMatch, + }); +} + +/** + * Write the AI's pairing onto the order lines that have no pairing yet. + * A `quote_item` a person set is never overwritten. + */ +async function writeAiPairs( + pb: AdminPb, + orderId: string, + aiLines: OrderAiLinePair[], +): Promise { + if (aiLines.length === 0) return; + 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); + } catch (e) { + console.error( + `[order-assistant] could not write the AI pairing onto line ${pair.order_line_id}:`, + errMessage(e), + ); + } + } +} + +/** + * Scheduled entry point. Does nothing unless the AI and the order + * assistant are both on and the configured interval has elapsed. Picks + * the oldest eligible orders (no quote yet, created in the last 30 + * days) and matches them; one order failing never stops the others. + */ +export async function runOrderAssistantTick( + deps: OrderAssistantDeps = {}, +): Promise<{ + ran: boolean; + reason?: "disabled" | "not_due" | "running"; + summary?: OrderAssistantRunSummary; +}> { + const nowFn = deps.now ?? (() => new Date()); + + clearAiSettingsCache(); + const settings = await getAiSettings(); + if (!settings.enabled || !settings.order_assistant_enabled) { + return { ran: false, reason: "disabled" }; + } + + const now = nowFn(); + const last = Date.parse(settings.order_assistant_last_run_at || ""); + const intervalMs = settings.order_assistant_interval_minutes * 60_000; + if (!Number.isNaN(last) && now.getTime() - last < intervalMs) { + return { ran: false, reason: "not_due" }; + } + + // In-progress lease: a run stamps last_run_at when it starts and + // writes its summary when it ends. A start newer than the last + // summary means a run is still going; don't overlap it. + const lastFinished = Date.parse( + settings.order_assistant_last_run_summary?.finished_at || "", + ); + const unfinished = Number.isNaN(lastFinished) || lastFinished < last; + if (!Number.isNaN(last) && unfinished && now.getTime() - last < TICK_LEASE_MS) { + return { ran: false, reason: "running" }; + } + + // Overlap guard: stamp the run start before doing any work. + const startedAt = now.toISOString(); + await recordOrderAssistantRun({ last_run_at: startedAt }); + + const summary: OrderAssistantRunSummary = { + started_at: startedAt, + finished_at: startedAt, + orders_seen: 0, + linked: 0, + suggested: 0, + no_match: 0, + lines_filled: 0, + errors: [], + }; + + try { + const pb = await getAdminPocketBase(); + pb.autoCancellation(false); + const cutoff = new Date(now.getTime() - TICK_ORDER_AGE_DAYS * 24 * 60 * 60 * 1000); + const page = await pb.collection("orders").getList(1, TICK_MAX_ORDERS, { + filter: `quote = "" && created >= "${cutoff.toISOString()}"`, + sort: "created", + }); + + const startMs = nowFn().getTime(); + for (const row of page.items) { + if (summary.orders_seen >= TICK_MAX_ORDERS) break; + if (nowFn().getTime() - startMs >= TICK_TIME_BUDGET_MS) break; + summary.orders_seen += 1; + try { + const r = await matchOrder(row.id, { deps }); + if (r.outcome === "linked") summary.linked += 1; + else if (r.outcome === "suggested") summary.suggested += 1; + else if (r.outcome === "no_match") summary.no_match += 1; + else if (r.outcome === "error") summary.errors.push(`${row.id}: ${r.error}`); + } catch (e) { + summary.errors.push(`${row.id}: ${errMessage(e)}`); + } + } + } catch (e) { + summary.errors.push(`tick failed: ${errMessage(e)}`); + } + + summary.errors = boundRunErrors(summary.errors); + summary.finished_at = nowFn().toISOString(); + await recordOrderAssistantRun({ summary }); + return { ran: true, summary }; +}