feat(order-fill): the filled line's intel follows the AI's POS pick
The order fill copied the quote's price onto the line and then looked the POS item up by SKU — so the price check could still show the wrong size next to the right price. Now the fill anchors the line's intel on the POS row the AI actually picked: the quote line's own pick when it has one (exact, or closest with its differences), the SKU otherwise, and for the lines nothing else anchors it runs ONE POS pick for the whole order. The pick is stored on the intel blob as pos_pick, and match_kind becomes "pos_pick". When the AI is switched off the lines keep the fuzzy price check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b7e80e7f85
commit
c5cfca1955
3 changed files with 341 additions and 12 deletions
|
|
@ -244,6 +244,49 @@ describe("populateItemIntel — anchored stock item", () => {
|
|||
expect(intel.stock_list.filter((r) => r.stock_item_id === "si-anchor")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("anchors on an AI pick and records match_kind=pos_pick", async () => {
|
||||
const { pb } = makePb({
|
||||
target: TARGET,
|
||||
stockItems: [FUZZY_ITEM, ANCHORED_ITEM],
|
||||
suppliers: SUPPLIERS,
|
||||
});
|
||||
vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never);
|
||||
|
||||
const intel = await populateItemIntel("qi-1", "quote_items", {
|
||||
write: false,
|
||||
posPick: {
|
||||
stock_item_id: "si-anchor",
|
||||
match: "closest",
|
||||
differs: ["length 65 vs 60"],
|
||||
reason: "nearest bolt",
|
||||
model: "m",
|
||||
},
|
||||
});
|
||||
|
||||
expect(intel.match_kind).toBe("pos_pick");
|
||||
expect(intel.pos_pick).toEqual({
|
||||
stock_item_id: "si-anchor",
|
||||
match: "closest",
|
||||
differs: ["length 65 vs 60"],
|
||||
reason: "nearest bolt",
|
||||
model: "m",
|
||||
});
|
||||
expect(intel.stock_list[0].stock_item_id).toBe("si-anchor");
|
||||
});
|
||||
|
||||
it("has no pos_pick when there was no AI pick", async () => {
|
||||
const { pb } = makePb({
|
||||
target: TARGET,
|
||||
stockItems: [FUZZY_ITEM],
|
||||
suppliers: SUPPLIERS,
|
||||
});
|
||||
vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never);
|
||||
|
||||
const intel = await populateItemIntel("qi-1", "quote_items", { write: false });
|
||||
expect(intel.pos_pick).toBeUndefined();
|
||||
expect(intel.match_kind).toBe("fuzzy");
|
||||
});
|
||||
|
||||
it("includes the anchored item's purchase history in supplier_history", async () => {
|
||||
const { pb } = makePb({
|
||||
target: TARGET,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,14 @@ vi.mock("@/lib/server/item-intel-service", async (importOriginal) => ({
|
|||
populateItemIntel: (...args: unknown[]) => populateIntel(...(args as [])),
|
||||
}));
|
||||
|
||||
// The AI pick for the filled lines — stubbed so the test can assert
|
||||
// what the fill asked for.
|
||||
const pickPosItems = vi.fn(async () => ({ picks: new Map(), model: "" }));
|
||||
vi.mock("@/lib/server/pos-match", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/server/pos-match")>()),
|
||||
pickPosItems: (...args: unknown[]) => pickPosItems(...(args as [])),
|
||||
}));
|
||||
|
||||
import {
|
||||
fillOrderFromQuote,
|
||||
getOrderAssistantCounts,
|
||||
|
|
@ -25,6 +33,7 @@ import {
|
|||
} from "@/lib/server/order-assistant-lines";
|
||||
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
||||
import { fetchQuoteItemsForQuotesAdmin } from "@/lib/server/quotes-service";
|
||||
import { AiDisabledError } from "@/lib/server/ai-settings-service";
|
||||
|
||||
const ORDERS = "orders";
|
||||
const ORDER_ITEMS = "order_items";
|
||||
|
|
@ -123,6 +132,8 @@ beforeEach(() => {
|
|||
resetOrderFillFieldCheck();
|
||||
populateIntel.mockClear();
|
||||
populateIntel.mockResolvedValue({} as never);
|
||||
pickPosItems.mockClear();
|
||||
pickPosItems.mockResolvedValue({ picks: new Map(), model: "" });
|
||||
vi.mocked(fetchQuoteItemsForQuotesAdmin).mockResolvedValue([
|
||||
QUOTE_LINE,
|
||||
] as never);
|
||||
|
|
@ -329,6 +340,158 @@ describe("fillOrderFromQuote", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("anchors on the quote line's own AI pick", async () => {
|
||||
vi.mocked(fetchQuoteItemsForQuotesAdmin).mockResolvedValue([
|
||||
{
|
||||
...QUOTE_LINE,
|
||||
ai_fill: {
|
||||
status: "filled",
|
||||
stock_item_id: "stk9",
|
||||
stock_item_sku: "POS-SKU-1",
|
||||
reason: "same bolt",
|
||||
model: "m",
|
||||
},
|
||||
},
|
||||
] as never);
|
||||
const pb = makePb();
|
||||
mockAdminPb(pb);
|
||||
|
||||
await fillOrderFromQuote("ord1");
|
||||
|
||||
expect(populateIntel).toHaveBeenCalledWith("ol1", "order_items", {
|
||||
write: true,
|
||||
anchor: { stockItemId: "stk9" },
|
||||
posPick: {
|
||||
stock_item_id: "stk9",
|
||||
match: "exact",
|
||||
differs: [],
|
||||
reason: "same bolt",
|
||||
model: "m",
|
||||
},
|
||||
});
|
||||
// The quote line already answered — no AI call needed.
|
||||
expect(pickPosItems).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("records a closest quote-line pick with what differs", async () => {
|
||||
vi.mocked(fetchQuoteItemsForQuotesAdmin).mockResolvedValue([
|
||||
{
|
||||
...QUOTE_LINE,
|
||||
ai_fill: {
|
||||
status: "no_match",
|
||||
stock_item_id: null,
|
||||
stock_item_sku: "",
|
||||
reason: "nearest bolt",
|
||||
model: "m",
|
||||
closest: {
|
||||
stock_item_id: "stk8",
|
||||
name: "HEX BOLT M22X65 GR8.8 ZP",
|
||||
sku: "BOLT-M22X65",
|
||||
cost_price: 8,
|
||||
selling_price: 12,
|
||||
differs: ["length 65 vs 60"],
|
||||
},
|
||||
},
|
||||
},
|
||||
] as never);
|
||||
const pb = makePb();
|
||||
mockAdminPb(pb);
|
||||
|
||||
await fillOrderFromQuote("ord1");
|
||||
|
||||
expect(populateIntel).toHaveBeenCalledWith("ol1", "order_items", {
|
||||
write: true,
|
||||
anchor: { stockItemId: "stk8" },
|
||||
posPick: {
|
||||
stock_item_id: "stk8",
|
||||
match: "closest",
|
||||
differs: ["length 65 vs 60"],
|
||||
reason: "nearest bolt",
|
||||
model: "m",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("runs ONE AI pick for the order when nothing else anchors the line", async () => {
|
||||
vi.mocked(fetchQuoteItemsForQuotesAdmin).mockResolvedValue([
|
||||
{ ...QUOTE_LINE, sku: "" },
|
||||
] as never);
|
||||
pickPosItems.mockResolvedValue({
|
||||
model: "test-model",
|
||||
picks: new Map([
|
||||
[
|
||||
"ol1",
|
||||
{
|
||||
line_id: "ol1",
|
||||
stock_item_id: "stk7",
|
||||
match: "closest",
|
||||
differs: ["length 65 vs 60"],
|
||||
confidence: "low",
|
||||
reason: "nearest",
|
||||
},
|
||||
],
|
||||
]),
|
||||
} as never);
|
||||
const pb = makePb({
|
||||
[ORDER_ITEMS]: {
|
||||
getFullList: vi.fn().mockResolvedValue([
|
||||
{ ...ORDER_LINE, description: "BOLT HEX M16X60 GR8.8 ZP", sku: "" },
|
||||
]),
|
||||
getOne: vi.fn().mockResolvedValue({
|
||||
...ORDER_LINE,
|
||||
description: "BOLT HEX M16X60 GR8.8 ZP",
|
||||
sku: "",
|
||||
}),
|
||||
update: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
});
|
||||
mockAdminPb(pb);
|
||||
|
||||
await fillOrderFromQuote("ord1");
|
||||
|
||||
expect(pickPosItems).toHaveBeenCalledTimes(1);
|
||||
const pickArgs = pickPosItems.mock.calls[0] as unknown as [
|
||||
Array<{ id: string; description: string }>,
|
||||
];
|
||||
const lines = pickArgs[0][0];
|
||||
expect(lines.id).toBe("ol1");
|
||||
expect(lines.description).toBe("BOLT HEX M16X60 GR8.8 ZP");
|
||||
expect(populateIntel).toHaveBeenCalledWith("ol1", "order_items", {
|
||||
write: true,
|
||||
anchor: { stockItemId: "stk7" },
|
||||
posPick: {
|
||||
stock_item_id: "stk7",
|
||||
match: "closest",
|
||||
differs: ["length 65 vs 60"],
|
||||
reason: "nearest",
|
||||
model: "test-model",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the fuzzy price check when the AI is disabled", async () => {
|
||||
vi.mocked(fetchQuoteItemsForQuotesAdmin).mockResolvedValue([
|
||||
{ ...QUOTE_LINE, sku: "" },
|
||||
] as never);
|
||||
pickPosItems.mockRejectedValue(new AiDisabledError("AI is off"));
|
||||
const pb = makePb({
|
||||
[ORDER_ITEMS]: {
|
||||
getFullList: vi.fn().mockResolvedValue([{ ...ORDER_LINE, sku: "" }]),
|
||||
getOne: vi.fn().mockResolvedValue({ ...ORDER_LINE, sku: "" }),
|
||||
update: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
});
|
||||
mockAdminPb(pb);
|
||||
|
||||
const res = await fillOrderFromQuote("ord1");
|
||||
|
||||
expect(res).toEqual({ filled: 1, skipped: 0 });
|
||||
expect(populateIntel).toHaveBeenCalledWith("ol1", "order_items", {
|
||||
write: true,
|
||||
anchor: { sku: "" },
|
||||
});
|
||||
});
|
||||
|
||||
it("never fails the fill when the item intel step fails", async () => {
|
||||
const pb = makePb();
|
||||
mockAdminPb(pb);
|
||||
|
|
|
|||
|
|
@ -14,8 +14,10 @@ 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 { AiDisabledError } from "@/lib/server/ai-settings-service";
|
||||
import { pickPosItems } from "@/lib/server/pos-match";
|
||||
import { isOrderAiFillPending } from "@/lib/order-assistant-review";
|
||||
import type { OrderAiLinePair, OrderItem, QuoteItem } from "@/lib/types";
|
||||
import type { ItemIntelPosPick, OrderAiLinePair, OrderItem, QuoteItem } from "@/lib/types";
|
||||
|
||||
export type AdminPb = Awaited<ReturnType<typeof getAdminPocketBase>>;
|
||||
|
||||
|
|
@ -108,6 +110,8 @@ const ORDER_ITEM_FILL_FIELDS = [
|
|||
"order",
|
||||
"quote_item",
|
||||
"item_status",
|
||||
"description",
|
||||
"notes",
|
||||
"cost_price",
|
||||
"selling_price",
|
||||
"markup",
|
||||
|
|
@ -156,6 +160,48 @@ async function quoteItemFillFields(pb: AdminPb): Promise<string> {
|
|||
return quoteAiFillSupported ? `${QUOTE_ITEM_FILL_FIELDS},ai_fill` : QUOTE_ITEM_FILL_FIELDS;
|
||||
}
|
||||
|
||||
/**
|
||||
* The SKU the filled line ended up with — the order line's own, or the
|
||||
* POS SKU the quote assistant put on the quote line.
|
||||
*/
|
||||
function filledSku(patch: Record<string, unknown>, quoteLine: QuoteItem): string {
|
||||
const aiFill = patch.ai_fill as { sku?: string } | undefined;
|
||||
return (aiFill?.sku ?? "").trim() || (quoteLine.ai_fill?.stock_item_sku ?? "").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* The AI's pick for a quote line, when the quote assistant made one.
|
||||
*
|
||||
* An exact pick (`ai_fill.stock_item_id`) anchors the order line's
|
||||
* intel on that POS row. A closest pick (`ai_fill.closest`) is
|
||||
* recorded too, with what differs — the intel then says "this is the
|
||||
* nearest item, and this is what is wrong with it".
|
||||
*/
|
||||
function quoteLinePick(quoteLine: QuoteItem): ItemIntelPosPick | undefined {
|
||||
const fill = quoteLine.ai_fill;
|
||||
if (!fill) return undefined;
|
||||
if (fill.stock_item_id) {
|
||||
return {
|
||||
stock_item_id: fill.stock_item_id,
|
||||
match: "exact",
|
||||
differs: [],
|
||||
reason: fill.reason || "",
|
||||
model: fill.model || "",
|
||||
};
|
||||
}
|
||||
const closest = fill.closest;
|
||||
if (closest?.stock_item_id) {
|
||||
return {
|
||||
stock_item_id: closest.stock_item_id,
|
||||
match: "closest",
|
||||
differs: closest.differs || [],
|
||||
reason: fill.reason || "",
|
||||
model: fill.model || "",
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the Item Intel for an order line the AI just filled from its
|
||||
* quote.
|
||||
|
|
@ -163,26 +209,92 @@ async function quoteItemFillFields(pb: AdminPb): Promise<string> {
|
|||
* 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.
|
||||
* The anchor is the POS row the AI picked, when there was one:
|
||||
*
|
||||
* 1. the quote line's own AI pick (exact, or closest with its
|
||||
* differences);
|
||||
* 2. otherwise the SKU the line ended up with.
|
||||
*
|
||||
* The pick is stored on the intel blob as `pos_pick`, so the price
|
||||
* check can say whether this is the exact item or only the closest.
|
||||
*/
|
||||
async function populateIntelAfterFill(
|
||||
lineId: string,
|
||||
patch: Record<string, unknown>,
|
||||
quoteLine: QuoteItem,
|
||||
posPick?: ItemIntelPosPick,
|
||||
): Promise<void> {
|
||||
const aiFill = patch.ai_fill as { sku?: string } | undefined;
|
||||
const sku =
|
||||
(aiFill?.sku ?? "").trim() || (quoteLine.ai_fill?.stock_item_sku ?? "").trim();
|
||||
const sku = filledSku(patch, quoteLine);
|
||||
try {
|
||||
await populateItemIntel(lineId, "order_items", { write: true, anchor: { sku } });
|
||||
await populateItemIntel(lineId, "order_items", {
|
||||
write: true,
|
||||
anchor: posPick?.stock_item_id ? { stockItemId: posPick.stock_item_id } : { sku },
|
||||
...(posPick ? { posPick } : {}),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`[order-assistant] item intel failed for line ${lineId}:`, errMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
/** One filled order line, and the quote line it came from. */
|
||||
interface FilledOrderLine {
|
||||
lineId: string;
|
||||
patch: Record<string, unknown>;
|
||||
quoteLine: QuoteItem;
|
||||
orderLine: OrderItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Let the AI pick a POS item for the filled lines nothing else
|
||||
* anchors — no quote-line pick and no SKU.
|
||||
*
|
||||
* ONE AI call for the whole order. When the AI is switched off the
|
||||
* lines simply keep the fuzzy price check.
|
||||
*/
|
||||
async function pickForOrder(
|
||||
pb: AdminPb,
|
||||
filledLines: FilledOrderLine[],
|
||||
): Promise<Map<string, ItemIntelPosPick>> {
|
||||
const out = new Map<string, ItemIntelPosPick>();
|
||||
const needs = filledLines.filter(({ patch, quoteLine, orderLine }) => {
|
||||
if (quoteLinePick(quoteLine)) return false;
|
||||
if (filledSku(patch, quoteLine)) return false;
|
||||
return !!(orderLine.description || quoteLine.description || "").trim();
|
||||
});
|
||||
if (needs.length === 0) return out;
|
||||
|
||||
try {
|
||||
const { picks, model } = await pickPosItems(
|
||||
needs.map(({ lineId, orderLine, quoteLine }) => ({
|
||||
id: lineId,
|
||||
description: orderLine.description || quoteLine.description || "",
|
||||
quantity: Number(orderLine.quantity) || 0,
|
||||
sku: (orderLine.sku || "").trim(),
|
||||
notes: (orderLine.notes || "").trim(),
|
||||
})),
|
||||
{ pb },
|
||||
);
|
||||
for (const [lineId, pick] of picks) {
|
||||
if (!pick.stock_item_id || pick.match === "none") continue;
|
||||
out.set(lineId, {
|
||||
stock_item_id: pick.stock_item_id,
|
||||
match: pick.match,
|
||||
differs: pick.differs,
|
||||
reason: pick.reason,
|
||||
model,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof AiDisabledError) {
|
||||
// AI switched off — fall back to the fuzzy price check.
|
||||
console.warn("[order-assistant] AI is disabled — the filled lines keep the fuzzy price check");
|
||||
return out;
|
||||
}
|
||||
console.error(`[order-assistant] the POS pick failed: ${errMessage(e)}`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface OrderAssistantFillDeps {
|
||||
now?: () => Date;
|
||||
}
|
||||
|
|
@ -306,6 +418,8 @@ export async function fillOrderFromQuote(
|
|||
if (l?.order_line_id) aiReasons.set(l.order_line_id, l.reason || "");
|
||||
}
|
||||
|
||||
const filledLines: FilledOrderLine[] = [];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
// Re-read the line right before writing it. A person may have typed
|
||||
// a cost since we listed the candidates — their value wins.
|
||||
|
|
@ -351,9 +465,7 @@ 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);
|
||||
filledLines.push({ lineId: fresh.id, patch, quoteLine, orderLine: fresh });
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`[order-assistant] could not fill line ${fresh.id} from quote ${quoteId}:`,
|
||||
|
|
@ -363,6 +475,17 @@ export async function fillOrderFromQuote(
|
|||
}
|
||||
}
|
||||
|
||||
// ONE AI pick for the order — only for the lines nothing else
|
||||
// anchors (no quote-line pick, no SKU).
|
||||
const picks = await pickForOrder(pb, filledLines);
|
||||
|
||||
// Give each filled line its price-check intel straight away so the
|
||||
// blue marker on the Process page has something to show.
|
||||
for (const { lineId, patch, quoteLine } of filledLines) {
|
||||
const posPick = quoteLinePick(quoteLine) ?? picks.get(lineId);
|
||||
await populateIntelAfterFill(lineId, patch, quoteLine, posPick);
|
||||
}
|
||||
|
||||
return { filled, skipped };
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue