feat(quotes): compute item intel right after the AI fills a line

A line the AI filled now gets its Item Intel immediately, so the blue
price-check marker on the Process page has something to show instead of
"No intel available" until someone opens the ⓘ popover.

The intel is anchored to the POS item the AI actually picked
(ai_fill.stock_item_id / ai_fill.stock_item_sku), so it points at that
exact catalogue row rather than a description-fuzzy guess.

The step is strictly additive: it runs inside the same audit actor as
the fill, never throws (a failure is logged and the fill still counts
as filled), and it is skipped once the run's TICK_TIME_BUDGET_MS is
spent — the remaining lines keep their fills and get their intel on the
next ⓘ / Refresh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-24 21:22:06 +02:00
parent 110332402e
commit bf4968875b
2 changed files with 109 additions and 1 deletions

View file

@ -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<typeof import("@/lib/server/item-intel-service")>()),
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" });

View file

@ -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<string, unknown>,
deadlineMs: number,
nowFn: () => Date,
): Promise<void> {
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<FillQuoteResult> {
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;