fix(price-check): say "Similar items" for fuzzy matches; leave out the line's own quote/order
Seen on the TEST order: an M16x60 line with no POS item got an M22x65 bolt as its POS price, and "ordered before" listed the order itself. - the badge summary starts with "Similar items:" when the intel is a fuzzy match, so it no longer reads like this item's own price - quoted/ordered before skip the line's own quote / order Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
8ef10f9f75
commit
5e1aaa27f2
4 changed files with 73 additions and 9 deletions
|
|
@ -128,7 +128,17 @@ describe("buildPriceCheck — summary", () => {
|
|||
|
||||
it("falls back to the price list when there is no stock row", () => {
|
||||
const r = buildPriceCheck(intel({ price_list: [priceListRow()] }), LINE);
|
||||
expect(plain(r.summary)).toBe("price list R 0,40");
|
||||
expect(plain(r.summary)).toBe("Similar items: price list R 0,40");
|
||||
});
|
||||
|
||||
it("marks a fuzzy match as similar items, not this item's price", () => {
|
||||
const r = buildPriceCheck(intel({ stock_list: [stockRow()], match_kind: "fuzzy" }), LINE);
|
||||
expect(plain(r.summary)).toBe("Similar items: POS R 0,45 · 120 in stock");
|
||||
});
|
||||
|
||||
it("does not prefix an exact match", () => {
|
||||
const r = buildPriceCheck(intel({ stock_list: [stockRow()], match_kind: "sku" }), LINE);
|
||||
expect(plain(r.summary)).toBe("POS R 0,45 · 120 in stock");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -228,8 +228,11 @@ export function buildPriceCheck(
|
|||
bits.push(`price list ${formatMoney(row.cost_price)}`);
|
||||
}
|
||||
|
||||
// A fuzzy match may be a different item (an M22 bolt for an M16 line),
|
||||
// so the one-liner says so instead of reading like this item's price.
|
||||
const joined = bits.join(" · ");
|
||||
return {
|
||||
summary: bits.join(" · ") || "No price data yet",
|
||||
summary: joined ? (matchKind === "fuzzy" ? `Similar items: ${joined}` : joined) : "No price data yet",
|
||||
flags,
|
||||
matchKind,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -389,3 +389,36 @@ describe("populateItemIntel — anchored stock item", () => {
|
|||
expect((updates[0].data.ai_item_insights as { match_kind: string }).match_kind).toBe("stock_item");
|
||||
});
|
||||
});
|
||||
|
||||
describe("populateItemIntel — history leaves out the line's own quote / order", () => {
|
||||
it("does not list the order being looked at under ordered before", async () => {
|
||||
const line = { id: "oi-1", order: "ord-1", sku: "", description: "M20 x 50mm Hex Bolt", quantity: 10, supplier: "" };
|
||||
const { pb } = makePb({
|
||||
orderItems: [
|
||||
line,
|
||||
{ id: "oi-2", order: "ord-1", description: "M20 x 50mm Hex Bolt", updated: "2026-10-20T00:00:00.000Z" },
|
||||
{ id: "oi-3", order: "ord-2", description: "M20 x 50mm Hex Bolt", updated: "2026-09-01T00:00:00.000Z" },
|
||||
],
|
||||
});
|
||||
vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never);
|
||||
|
||||
const intel = await populateItemIntel("oi-1", "order_items", { write: false });
|
||||
|
||||
expect(intel.recent_order.map((r) => r.order_item_id)).toEqual(["oi-3"]);
|
||||
});
|
||||
|
||||
it("does not list other lines of the same quote under quoted before", async () => {
|
||||
const { pb } = makePb({
|
||||
target: { ...TARGET, quote: "q-1" },
|
||||
quoteItems: [
|
||||
{ id: "qi-2", quote: "q-1", description: "M20 x 50mm Hex Bolt", created: "2026-10-20T00:00:00.000Z" },
|
||||
{ id: "qi-3", quote: "q-2", description: "M20 x 50mm Hex Bolt", created: "2026-09-01T00:00:00.000Z" },
|
||||
],
|
||||
});
|
||||
vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never);
|
||||
|
||||
const intel = await populateItemIntel("qi-1", "quote_items", { write: false });
|
||||
|
||||
expect(intel.recent_quote.map((r) => r.quote_item_id)).toEqual(["qi-3"]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -522,7 +522,7 @@ async function buildPriceList(
|
|||
|
||||
async function buildQuoteHistory(
|
||||
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
||||
target: { id: string; description: string },
|
||||
target: { id: string; description: string; quoteId?: string },
|
||||
supplierNameById: Map<string, string>,
|
||||
): Promise<ItemIntelQuoteRow[]> {
|
||||
const filter = buildPbContainsOR("description", target.description);
|
||||
|
|
@ -538,7 +538,12 @@ async function buildQuoteHistory(
|
|||
};
|
||||
};
|
||||
}>(pb, "quote_items", {
|
||||
filter: `id != "${target.id}" && (${filter})`,
|
||||
// Never the line itself, nor another line of the same quote.
|
||||
filter: [
|
||||
`id != "${escapeForPbString(target.id)}"`,
|
||||
target.quoteId ? `quote != "${escapeForPbString(target.quoteId)}"` : "",
|
||||
`(${filter})`,
|
||||
].filter(Boolean).join(" && "),
|
||||
expand: "quote.company",
|
||||
fields: "id,description,cost_price,selling_price,quantity,created,quote,supplier,expand.quote.id,expand.quote.quote_number,expand.quote.status,expand.quote.company,expand.quote.expand.company.id,expand.quote.expand.company.company",
|
||||
max: 300,
|
||||
|
|
@ -582,7 +587,7 @@ const ranked = candRows.items
|
|||
|
||||
async function buildOrderHistory(
|
||||
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
||||
target: { description: string },
|
||||
target: { id: string; description: string; orderId?: string },
|
||||
supplierNameById: Map<string, string>,
|
||||
): Promise<ItemIntelOrderRow[]> {
|
||||
const filter = buildPbContainsOR("description", target.description);
|
||||
|
|
@ -592,7 +597,12 @@ async function buildOrderHistory(
|
|||
quantity: number; item_status: string; supplier: string; updated: string; order: string;
|
||||
expand?: { order?: { order_number: string; company?: string } };
|
||||
}>(pb, "order_items", {
|
||||
filter,
|
||||
// Never the line itself, nor another line of the same order.
|
||||
filter: [
|
||||
`id != "${escapeForPbString(target.id)}"`,
|
||||
target.orderId ? `order != "${escapeForPbString(target.orderId)}"` : "",
|
||||
`(${filter})`,
|
||||
].filter(Boolean).join(" && "),
|
||||
expand: "order",
|
||||
fields: "id,description,cost_price,selling_price,quantity,item_status,supplier,updated,order,expand.order.id,expand.order.order_number,expand.order.company",
|
||||
max: 300,
|
||||
|
|
@ -676,10 +686,18 @@ export async function populateItemIntel(
|
|||
const write = options.write !== false;
|
||||
const pb = await getAdminPocketBase();
|
||||
|
||||
const target = await pb.collection(collection).getOne<{
|
||||
// The parent relation is `quote` on quote_items and `order` on
|
||||
// order_items; it keeps the line's own quote/order out of its history.
|
||||
const parentField = collection === "quote_items" ? "quote" : "order";
|
||||
const targetRow = await pb.collection(collection).getOne<{
|
||||
id: string; sku: string; description: string; quantity: number;
|
||||
supplier: string;
|
||||
}>(itemId, { fields: "id,sku,description,quantity,supplier" });
|
||||
supplier: string; quote?: string; order?: string;
|
||||
}>(itemId, { fields: `id,sku,description,quantity,supplier,${parentField}` });
|
||||
const target = {
|
||||
...targetRow,
|
||||
quoteId: collection === "quote_items" ? targetRow.quote || "" : "",
|
||||
orderId: collection === "order_items" ? targetRow.order || "" : "",
|
||||
};
|
||||
|
||||
const supplierNameById = await loadSuppliersById(pb);
|
||||
const generated_at = new Date().toISOString();
|
||||
|
|
|
|||
Loading…
Reference in a new issue