From 42a683aec10256467df85270c24f5e2ab1b3f178 Mon Sep 17 00:00:00 2001 From: Dave Date: Sat, 24 Oct 2026 21:28:04 +0200 Subject: [PATCH] feat(orders): compute item intel right after the AI fills an order line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An order line the AI filled from its quote now gets its Item Intel immediately, so the blue price-check marker on the order Process page has something to show. The anchor is the SKU the line ended up with. The quote assistant deliberately keeps the POS SKU off the line's own `sku` column — it lives on the quote line's `ai_fill` — so that is the fallback when the order line has no SKU of its own. `quote_items.ai_fill` is only projected once the schema shows the column exists, because PocketBase rejects a projection naming a column the collection does not have. The step is additive and never throws: a failure is logged and the line still counts as filled. Co-Authored-By: Claude Opus 5.5 --- src/lib/server/order-assistant-lines.test.ts | 51 ++++++++++++++++ src/lib/server/order-assistant-lines.ts | 62 +++++++++++++++++++- 2 files changed, 111 insertions(+), 2 deletions(-) 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}:`,