diff --git a/src/lib/server/item-intel-service.test.ts b/src/lib/server/item-intel-service.test.ts index 4f077ed..b31afec 100644 --- a/src/lib/server/item-intel-service.test.ts +++ b/src/lib/server/item-intel-service.test.ts @@ -244,6 +244,49 @@ describe("populateItemIntel — anchored stock item", () => { expect(intel.stock_list.filter((r) => r.stock_item_id === "si-anchor")).toHaveLength(1); }); + it("anchors on an AI pick and records match_kind=pos_pick", async () => { + const { pb } = makePb({ + target: TARGET, + stockItems: [FUZZY_ITEM, ANCHORED_ITEM], + suppliers: SUPPLIERS, + }); + vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never); + + const intel = await populateItemIntel("qi-1", "quote_items", { + write: false, + posPick: { + stock_item_id: "si-anchor", + match: "closest", + differs: ["length 65 vs 60"], + reason: "nearest bolt", + model: "m", + }, + }); + + expect(intel.match_kind).toBe("pos_pick"); + expect(intel.pos_pick).toEqual({ + stock_item_id: "si-anchor", + match: "closest", + differs: ["length 65 vs 60"], + reason: "nearest bolt", + model: "m", + }); + expect(intel.stock_list[0].stock_item_id).toBe("si-anchor"); + }); + + it("has no pos_pick when there was no AI pick", async () => { + const { pb } = makePb({ + target: TARGET, + stockItems: [FUZZY_ITEM], + suppliers: SUPPLIERS, + }); + vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never); + + const intel = await populateItemIntel("qi-1", "quote_items", { write: false }); + expect(intel.pos_pick).toBeUndefined(); + expect(intel.match_kind).toBe("fuzzy"); + }); + it("includes the anchored item's purchase history in supplier_history", async () => { const { pb } = makePb({ target: TARGET, diff --git a/src/lib/server/order-assistant-lines.test.ts b/src/lib/server/order-assistant-lines.test.ts index d609a38..c8eb8a2 100644 --- a/src/lib/server/order-assistant-lines.test.ts +++ b/src/lib/server/order-assistant-lines.test.ts @@ -16,6 +16,14 @@ vi.mock("@/lib/server/item-intel-service", async (importOriginal) => ({ populateItemIntel: (...args: unknown[]) => populateIntel(...(args as [])), })); +// The AI pick for the filled lines — stubbed so the test can assert +// what the fill asked for. +const pickPosItems = vi.fn(async () => ({ picks: new Map(), model: "" })); +vi.mock("@/lib/server/pos-match", async (importOriginal) => ({ + ...(await importOriginal()), + pickPosItems: (...args: unknown[]) => pickPosItems(...(args as [])), +})); + import { fillOrderFromQuote, getOrderAssistantCounts, @@ -25,6 +33,7 @@ import { } from "@/lib/server/order-assistant-lines"; import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; import { fetchQuoteItemsForQuotesAdmin } from "@/lib/server/quotes-service"; +import { AiDisabledError } from "@/lib/server/ai-settings-service"; const ORDERS = "orders"; const ORDER_ITEMS = "order_items"; @@ -123,6 +132,8 @@ beforeEach(() => { resetOrderFillFieldCheck(); populateIntel.mockClear(); populateIntel.mockResolvedValue({} as never); + pickPosItems.mockClear(); + pickPosItems.mockResolvedValue({ picks: new Map(), model: "" }); vi.mocked(fetchQuoteItemsForQuotesAdmin).mockResolvedValue([ QUOTE_LINE, ] as never); @@ -329,6 +340,158 @@ describe("fillOrderFromQuote", () => { }); }); + it("anchors on the quote line's own AI pick", async () => { + vi.mocked(fetchQuoteItemsForQuotesAdmin).mockResolvedValue([ + { + ...QUOTE_LINE, + ai_fill: { + status: "filled", + stock_item_id: "stk9", + stock_item_sku: "POS-SKU-1", + reason: "same bolt", + model: "m", + }, + }, + ] as never); + const pb = makePb(); + mockAdminPb(pb); + + await fillOrderFromQuote("ord1"); + + expect(populateIntel).toHaveBeenCalledWith("ol1", "order_items", { + write: true, + anchor: { stockItemId: "stk9" }, + posPick: { + stock_item_id: "stk9", + match: "exact", + differs: [], + reason: "same bolt", + model: "m", + }, + }); + // The quote line already answered — no AI call needed. + expect(pickPosItems).not.toHaveBeenCalled(); + }); + + it("records a closest quote-line pick with what differs", async () => { + vi.mocked(fetchQuoteItemsForQuotesAdmin).mockResolvedValue([ + { + ...QUOTE_LINE, + ai_fill: { + status: "no_match", + stock_item_id: null, + stock_item_sku: "", + reason: "nearest bolt", + model: "m", + closest: { + stock_item_id: "stk8", + name: "HEX BOLT M22X65 GR8.8 ZP", + sku: "BOLT-M22X65", + cost_price: 8, + selling_price: 12, + differs: ["length 65 vs 60"], + }, + }, + }, + ] as never); + const pb = makePb(); + mockAdminPb(pb); + + await fillOrderFromQuote("ord1"); + + expect(populateIntel).toHaveBeenCalledWith("ol1", "order_items", { + write: true, + anchor: { stockItemId: "stk8" }, + posPick: { + stock_item_id: "stk8", + match: "closest", + differs: ["length 65 vs 60"], + reason: "nearest bolt", + model: "m", + }, + }); + }); + + it("runs ONE AI pick for the order when nothing else anchors the line", async () => { + vi.mocked(fetchQuoteItemsForQuotesAdmin).mockResolvedValue([ + { ...QUOTE_LINE, sku: "" }, + ] as never); + pickPosItems.mockResolvedValue({ + model: "test-model", + picks: new Map([ + [ + "ol1", + { + line_id: "ol1", + stock_item_id: "stk7", + match: "closest", + differs: ["length 65 vs 60"], + confidence: "low", + reason: "nearest", + }, + ], + ]), + } as never); + const pb = makePb({ + [ORDER_ITEMS]: { + getFullList: vi.fn().mockResolvedValue([ + { ...ORDER_LINE, description: "BOLT HEX M16X60 GR8.8 ZP", sku: "" }, + ]), + getOne: vi.fn().mockResolvedValue({ + ...ORDER_LINE, + description: "BOLT HEX M16X60 GR8.8 ZP", + sku: "", + }), + update: vi.fn().mockResolvedValue({}), + }, + }); + mockAdminPb(pb); + + await fillOrderFromQuote("ord1"); + + expect(pickPosItems).toHaveBeenCalledTimes(1); + const pickArgs = pickPosItems.mock.calls[0] as unknown as [ + Array<{ id: string; description: string }>, + ]; + const lines = pickArgs[0][0]; + expect(lines.id).toBe("ol1"); + expect(lines.description).toBe("BOLT HEX M16X60 GR8.8 ZP"); + expect(populateIntel).toHaveBeenCalledWith("ol1", "order_items", { + write: true, + anchor: { stockItemId: "stk7" }, + posPick: { + stock_item_id: "stk7", + match: "closest", + differs: ["length 65 vs 60"], + reason: "nearest", + model: "test-model", + }, + }); + }); + + it("falls back to the fuzzy price check when the AI is disabled", async () => { + vi.mocked(fetchQuoteItemsForQuotesAdmin).mockResolvedValue([ + { ...QUOTE_LINE, sku: "" }, + ] as never); + pickPosItems.mockRejectedValue(new AiDisabledError("AI is off")); + const pb = makePb({ + [ORDER_ITEMS]: { + getFullList: vi.fn().mockResolvedValue([{ ...ORDER_LINE, sku: "" }]), + getOne: vi.fn().mockResolvedValue({ ...ORDER_LINE, sku: "" }), + update: vi.fn().mockResolvedValue({}), + }, + }); + mockAdminPb(pb); + + const res = await fillOrderFromQuote("ord1"); + + expect(res).toEqual({ filled: 1, skipped: 0 }); + expect(populateIntel).toHaveBeenCalledWith("ol1", "order_items", { + write: true, + anchor: { sku: "" }, + }); + }); + it("never fails the fill when the item intel step fails", async () => { const pb = makePb(); mockAdminPb(pb); diff --git a/src/lib/server/order-assistant-lines.ts b/src/lib/server/order-assistant-lines.ts index f285b3f..94c8b5b 100644 --- a/src/lib/server/order-assistant-lines.ts +++ b/src/lib/server/order-assistant-lines.ts @@ -14,8 +14,10 @@ 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 { populateItemIntel } from "@/lib/server/item-intel-service"; +import { AiDisabledError } from "@/lib/server/ai-settings-service"; +import { pickPosItems } from "@/lib/server/pos-match"; import { isOrderAiFillPending } from "@/lib/order-assistant-review"; -import type { OrderAiLinePair, OrderItem, QuoteItem } from "@/lib/types"; +import type { ItemIntelPosPick, OrderAiLinePair, OrderItem, QuoteItem } from "@/lib/types"; export type AdminPb = Awaited>; @@ -108,6 +110,8 @@ const ORDER_ITEM_FILL_FIELDS = [ "order", "quote_item", "item_status", + "description", + "notes", "cost_price", "selling_price", "markup", @@ -156,6 +160,48 @@ async function quoteItemFillFields(pb: AdminPb): Promise { return quoteAiFillSupported ? `${QUOTE_ITEM_FILL_FIELDS},ai_fill` : QUOTE_ITEM_FILL_FIELDS; } +/** + * The SKU the filled line ended up with — the order line's own, or the + * POS SKU the quote assistant put on the quote line. + */ +function filledSku(patch: Record, quoteLine: QuoteItem): string { + const aiFill = patch.ai_fill as { sku?: string } | undefined; + return (aiFill?.sku ?? "").trim() || (quoteLine.ai_fill?.stock_item_sku ?? "").trim(); +} + +/** + * The AI's pick for a quote line, when the quote assistant made one. + * + * An exact pick (`ai_fill.stock_item_id`) anchors the order line's + * intel on that POS row. A closest pick (`ai_fill.closest`) is + * recorded too, with what differs — the intel then says "this is the + * nearest item, and this is what is wrong with it". + */ +function quoteLinePick(quoteLine: QuoteItem): ItemIntelPosPick | undefined { + const fill = quoteLine.ai_fill; + if (!fill) return undefined; + if (fill.stock_item_id) { + return { + stock_item_id: fill.stock_item_id, + match: "exact", + differs: [], + reason: fill.reason || "", + model: fill.model || "", + }; + } + const closest = fill.closest; + if (closest?.stock_item_id) { + return { + stock_item_id: closest.stock_item_id, + match: "closest", + differs: closest.differs || [], + reason: fill.reason || "", + model: fill.model || "", + }; + } + return undefined; +} + /** * Compute the Item Intel for an order line the AI just filled from its * quote. @@ -163,26 +209,92 @@ async function quoteItemFillFields(pb: AdminPb): Promise { * Purely additive — a failure here never turns a good fill into a * skip; the line simply has no intel until someone hits Refresh. * - * The anchor is the SKU the line ended up with. When the quote line - * was itself filled by the AI, the POS SKU lives on the quote line's - * `ai_fill` (the quote assistant deliberately keeps it off the line's - * own `sku`), so that is the fallback. + * The anchor is the POS row the AI picked, when there was one: + * + * 1. the quote line's own AI pick (exact, or closest with its + * differences); + * 2. otherwise the SKU the line ended up with. + * + * The pick is stored on the intel blob as `pos_pick`, so the price + * check can say whether this is the exact item or only the closest. */ async function populateIntelAfterFill( lineId: string, patch: Record, quoteLine: QuoteItem, + posPick?: ItemIntelPosPick, ): Promise { - const aiFill = patch.ai_fill as { sku?: string } | undefined; - const sku = - (aiFill?.sku ?? "").trim() || (quoteLine.ai_fill?.stock_item_sku ?? "").trim(); + const sku = filledSku(patch, quoteLine); try { - await populateItemIntel(lineId, "order_items", { write: true, anchor: { sku } }); + await populateItemIntel(lineId, "order_items", { + write: true, + anchor: posPick?.stock_item_id ? { stockItemId: posPick.stock_item_id } : { sku }, + ...(posPick ? { posPick } : {}), + }); } catch (e) { console.error(`[order-assistant] item intel failed for line ${lineId}:`, errMessage(e)); } } +/** One filled order line, and the quote line it came from. */ +interface FilledOrderLine { + lineId: string; + patch: Record; + quoteLine: QuoteItem; + orderLine: OrderItem; +} + +/** + * Let the AI pick a POS item for the filled lines nothing else + * anchors — no quote-line pick and no SKU. + * + * ONE AI call for the whole order. When the AI is switched off the + * lines simply keep the fuzzy price check. + */ +async function pickForOrder( + pb: AdminPb, + filledLines: FilledOrderLine[], +): Promise> { + const out = new Map(); + const needs = filledLines.filter(({ patch, quoteLine, orderLine }) => { + if (quoteLinePick(quoteLine)) return false; + if (filledSku(patch, quoteLine)) return false; + return !!(orderLine.description || quoteLine.description || "").trim(); + }); + if (needs.length === 0) return out; + + try { + const { picks, model } = await pickPosItems( + needs.map(({ lineId, orderLine, quoteLine }) => ({ + id: lineId, + description: orderLine.description || quoteLine.description || "", + quantity: Number(orderLine.quantity) || 0, + sku: (orderLine.sku || "").trim(), + notes: (orderLine.notes || "").trim(), + })), + { pb }, + ); + for (const [lineId, pick] of picks) { + if (!pick.stock_item_id || pick.match === "none") continue; + out.set(lineId, { + stock_item_id: pick.stock_item_id, + match: pick.match, + differs: pick.differs, + reason: pick.reason, + model, + }); + } + } catch (e) { + if (e instanceof AiDisabledError) { + // AI switched off — fall back to the fuzzy price check. + console.warn("[order-assistant] AI is disabled — the filled lines keep the fuzzy price check"); + return out; + } + console.error(`[order-assistant] the POS pick failed: ${errMessage(e)}`); + } + return out; +} + export interface OrderAssistantFillDeps { now?: () => Date; } @@ -306,6 +418,8 @@ export async function fillOrderFromQuote( if (l?.order_line_id) aiReasons.set(l.order_line_id, l.reason || ""); } + const filledLines: FilledOrderLine[] = []; + 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. @@ -351,9 +465,7 @@ export async function fillOrderFromQuote( try { await pb.collection("order_items").update(fresh.id, patch); filled += 1; - // Give the line its price-check intel straight away so the blue - // marker on the Process page has something to show. - await populateIntelAfterFill(fresh.id, patch, quoteLine); + filledLines.push({ lineId: fresh.id, patch, quoteLine, orderLine: fresh }); } catch (e) { console.error( `[order-assistant] could not fill line ${fresh.id} from quote ${quoteId}:`, @@ -363,6 +475,17 @@ export async function fillOrderFromQuote( } } + // ONE AI pick for the order — only for the lines nothing else + // anchors (no quote-line pick, no SKU). + const picks = await pickForOrder(pb, filledLines); + + // Give each filled line its price-check intel straight away so the + // blue marker on the Process page has something to show. + for (const { lineId, patch, quoteLine } of filledLines) { + const posPick = quoteLinePick(quoteLine) ?? picks.get(lineId); + await populateIntelAfterFill(lineId, patch, quoteLine, posPick); + } + return { filled, skipped }; }