feat(orders): compute item intel right after the AI fills an order line

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 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-24 21:28:04 +02:00
parent bf4968875b
commit 42a683aec1
2 changed files with 111 additions and 2 deletions

View file

@ -8,6 +8,14 @@ vi.mock("@/lib/server/quotes-service", () => ({
fetchQuoteItemsForQuotesAdmin: vi.fn(), 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<typeof import("@/lib/server/item-intel-service")>()),
populateItemIntel: (...args: unknown[]) => populateIntel(...(args as [])),
}));
import { import {
fillOrderFromQuote, fillOrderFromQuote,
getOrderAssistantCounts, getOrderAssistantCounts,
@ -113,6 +121,8 @@ function mockAdminPb(pb: ReturnType<typeof makePb>) {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
resetOrderFillFieldCheck(); resetOrderFillFieldCheck();
populateIntel.mockClear();
populateIntel.mockResolvedValue({} as never);
vi.mocked(fetchQuoteItemsForQuotesAdmin).mockResolvedValue([ vi.mocked(fetchQuoteItemsForQuotesAdmin).mockResolvedValue([
QUOTE_LINE, QUOTE_LINE,
] as never); ] as never);
@ -287,6 +297,47 @@ describe("fillOrderFromQuote", () => {
expect(filters.length).toBeGreaterThan(0); expect(filters.length).toBeGreaterThan(0);
for (const f of filters) expect(f).not.toMatch(/\bIN\s*\(/); 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", () => { describe("getOrderAssistantCounts", () => {

View file

@ -13,6 +13,7 @@ import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { fetchQuoteItemsForQuotesAdmin } from "@/lib/server/quotes-service"; import { fetchQuoteItemsForQuotesAdmin } from "@/lib/server/quotes-service";
import { escapeForPbString } from "@/lib/server/price-list-match"; import { escapeForPbString } from "@/lib/server/price-list-match";
import { buildOrderLineFillPatch, isOrderLineWritable } from "@/lib/server/order-assistant-core"; 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 { isOrderAiFillPending } from "@/lib/order-assistant-review";
import type { OrderAiLinePair, OrderItem, QuoteItem } from "@/lib/types"; import type { OrderAiLinePair, OrderItem, QuoteItem } from "@/lib/types";
@ -129,15 +130,69 @@ const QUOTE_ITEM_FILL_FIELDS = [
"matched_price_list_id", "matched_price_list_id",
].join(","); ].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<string> {
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<string, unknown>,
quoteLine: QuoteItem,
): Promise<void> {
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 { export interface OrderAssistantFillDeps {
now?: () => Date; now?: () => Date;
} }
let fillFieldsConfirmed = false; let fillFieldsConfirmed = false;
/** Test hook: forget the cached schema check. */ /** Test hook: forget the cached schema checks. */
export function resetOrderFillFieldCheck(): void { export function resetOrderFillFieldCheck(): void {
fillFieldsConfirmed = false; fillFieldsConfirmed = false;
quoteAiFillSupported = null;
} }
/** /**
@ -241,7 +296,7 @@ export async function fillOrderFromQuote(
const quoteItems = await fetchQuoteItemsForQuotesAdmin( const quoteItems = await fetchQuoteItemsForQuotesAdmin(
pb, pb,
[quoteId], [quoteId],
QUOTE_ITEM_FILL_FIELDS, await quoteItemFillFields(pb),
); );
const quoteLines = new Map<string, QuoteItem>(); const quoteLines = new Map<string, QuoteItem>();
for (const q of quoteItems) quoteLines.set(q.id, q); for (const q of quoteItems) quoteLines.set(q.id, q);
@ -296,6 +351,9 @@ export async function fillOrderFromQuote(
try { try {
await pb.collection("order_items").update(fresh.id, patch); await pb.collection("order_items").update(fresh.id, patch);
filled += 1; 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) { } catch (e) {
console.error( console.error(
`[order-assistant] could not fill line ${fresh.id} from quote ${quoteId}:`, `[order-assistant] could not fill line ${fresh.id} from quote ${quoteId}:`,