diff --git a/src/lib/server/order-assistant-lines.test.ts b/src/lib/server/order-assistant-lines.test.ts index d8cad52..d609a38 100644 --- a/src/lib/server/order-assistant-lines.test.ts +++ b/src/lib/server/order-assistant-lines.test.ts @@ -8,6 +8,14 @@ vi.mock("@/lib/server/quotes-service", () => ({ fetchQuoteItemsForQuotesAdmin: vi.fn(), })); +// The intel step runs for real against PB in production; here it is +// stubbed so the test can assert the anchor it was handed. +const populateIntel = vi.fn(async () => ({}) as never); +vi.mock("@/lib/server/item-intel-service", async (importOriginal) => ({ + ...(await importOriginal()), + populateItemIntel: (...args: unknown[]) => populateIntel(...(args as [])), +})); + import { fillOrderFromQuote, getOrderAssistantCounts, @@ -113,6 +121,8 @@ function mockAdminPb(pb: ReturnType) { beforeEach(() => { vi.clearAllMocks(); resetOrderFillFieldCheck(); + populateIntel.mockClear(); + populateIntel.mockResolvedValue({} as never); vi.mocked(fetchQuoteItemsForQuotesAdmin).mockResolvedValue([ QUOTE_LINE, ] as never); @@ -287,6 +297,47 @@ describe("fillOrderFromQuote", () => { expect(filters.length).toBeGreaterThan(0); for (const f of filters) expect(f).not.toMatch(/\bIN\s*\(/); }); + + it("populates the item intel for a filled line, anchored to the filled sku", async () => { + const pb = makePb(); + mockAdminPb(pb); + + const res = await fillOrderFromQuote("ord1"); + + expect(res).toEqual({ filled: 1, skipped: 0 }); + expect(populateIntel).toHaveBeenCalledTimes(1); + expect(populateIntel).toHaveBeenCalledWith("ol1", "order_items", { + write: true, + anchor: { sku: "56020510" }, + }); + }); + + it("anchors to the quote line's POS sku when the order line has none of its own", async () => { + // The quote assistant keeps the POS sku off the line's own `sku` + // column — it lives on the quote line's ai_fill. + vi.mocked(fetchQuoteItemsForQuotesAdmin).mockResolvedValue([ + { ...QUOTE_LINE, sku: "", ai_fill: { status: "filled", stock_item_sku: "POS-SKU-1" } }, + ] as never); + const pb = makePb(); + mockAdminPb(pb); + + await fillOrderFromQuote("ord1"); + + expect(populateIntel).toHaveBeenCalledWith("ol1", "order_items", { + write: true, + anchor: { sku: "POS-SKU-1" }, + }); + }); + + it("never fails the fill when the item intel step fails", async () => { + const pb = makePb(); + mockAdminPb(pb); + populateIntel.mockRejectedValueOnce(new Error("intel boom")); + + const res = await fillOrderFromQuote("ord1"); + + expect(res).toEqual({ filled: 1, skipped: 0 }); + }); }); describe("getOrderAssistantCounts", () => { diff --git a/src/lib/server/order-assistant-lines.ts b/src/lib/server/order-assistant-lines.ts index 47566af..f285b3f 100644 --- a/src/lib/server/order-assistant-lines.ts +++ b/src/lib/server/order-assistant-lines.ts @@ -13,6 +13,7 @@ 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 { populateItemIntel } from "@/lib/server/item-intel-service"; import { isOrderAiFillPending } from "@/lib/order-assistant-review"; import type { OrderAiLinePair, OrderItem, QuoteItem } from "@/lib/types"; @@ -129,15 +130,69 @@ const QUOTE_ITEM_FILL_FIELDS = [ "matched_price_list_id", ].join(","); +/** + * Does `quote_items` have the `ai_fill` column? + * + * The quote assistant's migration adds it, and it may not have run on + * every install. PocketBase rejects a projection that names a column + * the collection does not have — so the column is only projected once + * we have seen the schema. Cached like the fill's own field check. + */ +let quoteAiFillSupported: boolean | null = null; + +async function quoteItemFillFields(pb: AdminPb): Promise { + if (quoteAiFillSupported === null) { + try { + const col = (await pb.collections.getOne("quote_items")) as { + fields?: Array<{ name: string }>; + schema?: Array<{ name: string }>; + }; + const names = col?.fields ?? col?.schema ?? []; + quoteAiFillSupported = names.some((f) => f.name === "ai_fill"); + } catch { + quoteAiFillSupported = false; + } + } + return quoteAiFillSupported ? `${QUOTE_ITEM_FILL_FIELDS},ai_fill` : QUOTE_ITEM_FILL_FIELDS; +} + +/** + * Compute the Item Intel for an order line the AI just filled from its + * quote. + * + * 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. + */ +async function populateIntelAfterFill( + lineId: string, + patch: Record, + quoteLine: QuoteItem, +): Promise { + const aiFill = patch.ai_fill as { sku?: string } | undefined; + const sku = + (aiFill?.sku ?? "").trim() || (quoteLine.ai_fill?.stock_item_sku ?? "").trim(); + try { + await populateItemIntel(lineId, "order_items", { write: true, anchor: { sku } }); + } catch (e) { + console.error(`[order-assistant] item intel failed for line ${lineId}:`, errMessage(e)); + } +} + export interface OrderAssistantFillDeps { now?: () => Date; } let fillFieldsConfirmed = false; -/** Test hook: forget the cached schema check. */ +/** Test hook: forget the cached schema checks. */ export function resetOrderFillFieldCheck(): void { fillFieldsConfirmed = false; + quoteAiFillSupported = null; } /** @@ -241,7 +296,7 @@ export async function fillOrderFromQuote( const quoteItems = await fetchQuoteItemsForQuotesAdmin( pb, [quoteId], - QUOTE_ITEM_FILL_FIELDS, + await quoteItemFillFields(pb), ); const quoteLines = new Map(); for (const q of quoteItems) quoteLines.set(q.id, q); @@ -296,6 +351,9 @@ 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); } catch (e) { console.error( `[order-assistant] could not fill line ${fresh.id} from quote ${quoteId}:`,