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:
Dave 2026-10-24 22:23:13 +02:00
parent 8ef10f9f75
commit 5e1aaa27f2
4 changed files with 73 additions and 9 deletions

View file

@ -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");
});
});

View file

@ -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,
};

View file

@ -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"]);
});
});

View file

@ -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();