diff --git a/src/lib/server/quote-assistant-service.test.ts b/src/lib/server/quote-assistant-service.test.ts index 6124053..b5ff37b 100644 --- a/src/lib/server/quote-assistant-service.test.ts +++ b/src/lib/server/quote-assistant-service.test.ts @@ -88,6 +88,14 @@ vi.mock("@/lib/server/audit-context", () => ({ }, })); +// 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 [])), +})); + const settings = { enabled: true, quote_assistant_enabled: true, @@ -220,6 +228,8 @@ beforeEach(() => { actors.length = 0; seq = 0; recordRun.mockClear(); + populateIntel.mockClear(); + populateIntel.mockResolvedValue({} as never); Object.assign(settings, { enabled: true, quote_assistant_enabled: true, @@ -265,6 +275,50 @@ describe("fillQuote", () => { expect((unmatched.ai_fill as { status: string }).status).toBe("no_match"); }); + it("populates the item intel for a filled line, anchored to the POS item the AI picked", async () => { + addQuote("q1"); + const a = addItem("q1", { id: "a" }); + const b = addItem("q1", { id: "b" }); + const { deps } = makeDeps({ + decisions: () => [decisionFor(a), decisionFor(b, { stock_item_id: null, confidence: null })], + }); + + await fillQuote("q1", { deps }); + + // Only the filled line gets intel. + expect(populateIntel).toHaveBeenCalledTimes(1); + expect(populateIntel).toHaveBeenCalledWith("a", "quote_items", { + write: true, + anchor: { stockItemId: "stk1", sku: "HB-1050" }, + }); + }); + + it("skips the item intel once the run's time budget is spent", async () => { + addQuote("q1"); + const a = addItem("q1", { id: "a" }); + const { deps } = makeDeps(); + + const r = await fillQuote("q1", { deps, deadlineMs: NOW.getTime() - 1 }); + + // The fill still happened — only the intel step was skipped. + expect(r.filled).toBe(1); + expect((store.items.get(a)!.ai_fill as { status: string }).status).toBe("filled"); + expect(populateIntel).not.toHaveBeenCalled(); + }); + + it("never fails the fill when the item intel step fails", async () => { + addQuote("q1"); + const a = addItem("q1", { id: "a" }); + const { deps } = makeDeps(); + populateIntel.mockRejectedValueOnce(new Error("intel boom")); + + const r = await fillQuote("q1", { deps }); + + expect(r).toEqual({ quoteId: "q1", filled: 1, unmatched: 0, skipped: 0, error: "" }); + expect((store.items.get(a)!.ai_fill as { status: string }).status).toBe("filled"); + expect(store.items.get(a)!.cost_price).toBe(10); + }); + it("marks a line with no shortlist candidates as no_candidates and does not send it to the AI", async () => { addQuote("q1"); const a = addItem("q1", { id: "a", description: "matchable" }); diff --git a/src/lib/server/quote-assistant-service.ts b/src/lib/server/quote-assistant-service.ts index f0950e7..32b2720 100644 --- a/src/lib/server/quote-assistant-service.ts +++ b/src/lib/server/quote-assistant-service.ts @@ -38,7 +38,7 @@ import { type StockCandidate, } from "@/lib/server/quote-assistant-shortlist"; import { escapeForPbString } from "@/lib/server/price-list-match"; -import { loadSuppliersById } from "@/lib/server/item-intel-service"; +import { loadSuppliersById, populateItemIntel } from "@/lib/server/item-intel-service"; import { isAiFillPending } from "@/lib/quote-assistant-review"; import type { Quote, QuoteAssistantRunSummary, QuoteItem } from "@/lib/types"; import type { AdminPb } from "@/lib/server/quote-assistant-shortlist"; @@ -124,6 +124,46 @@ export function boundRunErrors(errors: string[]): string[] { ); } +/** + * Compute the Item Intel for a line the AI just filled. + * + * Purely additive: the fill already succeeded by the time we get here, + * and a failure here must never turn a good fill into an error. It + * only means the ⓘ popover stays empty until someone hits Refresh. + * + * The anchor is the POS item the AI picked (`ai_fill.stock_item_id` / + * `ai_fill.stock_item_sku`), so the intel points at that exact + * catalogue row instead of a description-fuzzy guess. + * + * Skipped once the run's time budget is spent — the remaining lines + * keep their fills, they just get their intel on the next ⓘ / Refresh. + */ +async function populateIntelAfterFill( + lineId: string, + patch: Record, + deadlineMs: number, + nowFn: () => Date, +): Promise { + const aiFill = patch.ai_fill as + | { stock_item_id?: string | null; stock_item_sku?: string | null } + | undefined; + const anchor = { + stockItemId: aiFill?.stock_item_id || undefined, + sku: aiFill?.stock_item_sku || undefined, + }; + if (nowFn().getTime() >= deadlineMs) { + console.warn( + `[quote-assistant] time budget spent — skipping item intel for line ${lineId}`, + ); + return; + } + try { + await populateItemIntel(lineId, "quote_items", { write: true, anchor }); + } catch (e) { + console.error(`[quote-assistant] item intel failed for line ${lineId}:`, e); + } +} + /** * Fill the unpriced lines of one quote. Never throws for AI failures: they * are recorded on the lines (`status: "error"`) and in `result.error`. @@ -135,6 +175,10 @@ export async function fillQuote( maxLines?: number; actorName?: string; deps?: AssistantDeps; + /** Epoch-ms deadline for the optional Item Intel step. Defaults to + * `now + TICK_TIME_BUDGET_MS`. Once passed, lines still get their + * fills — they just skip the intel computation. */ + deadlineMs?: number; } = {}, ): Promise { const decide = opts.deps?.decide ?? decideWithAi; @@ -142,6 +186,7 @@ export async function fillQuote( const nowFn = opts.deps?.now ?? (() => new Date()); const maxLines = opts.maxLines ?? QUOTE_MAX_LINES; const actorName = opts.actorName ? `${ACTOR_NAME} (run by ${opts.actorName})` : ACTOR_NAME; + const deadlineMs = opts.deadlineMs ?? nowFn().getTime() + TICK_TIME_BUDGET_MS; const result: FillQuoteResult = { quoteId, filled: 0, unmatched: 0, skipped: 0, error: "" }; @@ -257,6 +302,11 @@ export async function fillQuote( if (status === "filled") result.filled += 1; else if (status === "no_match" || status === "no_candidates") result.unmatched += 1; if (noAnswer) unanswered += 1; + // Give the line its price-check intel straight away so the blue + // marker on the Process page has something to show. + if (status === "filled") { + await populateIntelAfterFill(line.id, patch, deadlineMs, nowFn); + } } catch (e) { if (!result.error) result.error = `write failed for line ${line.id}: ${errMessage(e)}`; } @@ -347,6 +397,9 @@ export async function runAssistantTick( } const startMs = now.getTime(); + // One absolute deadline for the whole run: the per-quote intel step + // stops once the tick's time budget is spent. + const intelDeadlineMs = startMs + TICK_TIME_BUDGET_MS; for (const [quoteId, group] of perQuote) { // Time budget: never start another quote late in the run, so a run // finishes inside n8n's request timeout. Leftovers go next run. @@ -357,6 +410,7 @@ export async function runAssistantTick( const r = await fillQuote(quoteId, { maxLines: Math.min(group.length, QUOTE_MAX_LINES), deps, + deadlineMs: intelDeadlineMs, }); summary.lines_filled += r.filled; summary.lines_unmatched += r.unmatched;