From 5e1aaa27f247e63a98568455edc7df300ad7b7b0 Mon Sep 17 00:00:00 2001 From: Dave Date: Sat, 24 Oct 2026 22:23:13 +0200 Subject: [PATCH] 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 --- src/lib/price-check.test.ts | 12 ++++++++- src/lib/price-check.ts | 5 +++- src/lib/server/item-intel-service.test.ts | 33 +++++++++++++++++++++++ src/lib/server/item-intel-service.ts | 32 +++++++++++++++++----- 4 files changed, 73 insertions(+), 9 deletions(-) diff --git a/src/lib/price-check.test.ts b/src/lib/price-check.test.ts index a1b761e..87a10ee 100644 --- a/src/lib/price-check.test.ts +++ b/src/lib/price-check.test.ts @@ -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"); }); }); diff --git a/src/lib/price-check.ts b/src/lib/price-check.ts index 89a6213..f47fe8c 100644 --- a/src/lib/price-check.ts +++ b/src/lib/price-check.ts @@ -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, }; diff --git a/src/lib/server/item-intel-service.test.ts b/src/lib/server/item-intel-service.test.ts index 25251a7..4f077ed 100644 --- a/src/lib/server/item-intel-service.test.ts +++ b/src/lib/server/item-intel-service.test.ts @@ -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"]); + }); +}); diff --git a/src/lib/server/item-intel-service.ts b/src/lib/server/item-intel-service.ts index ba40bef..725f47f 100644 --- a/src/lib/server/item-intel-service.ts +++ b/src/lib/server/item-intel-service.ts @@ -522,7 +522,7 @@ async function buildPriceList( async function buildQuoteHistory( pb: Awaited>, - target: { id: string; description: string }, + target: { id: string; description: string; quoteId?: string }, supplierNameById: Map, ): Promise { 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>, - target: { description: string }, + target: { id: string; description: string; orderId?: string }, supplierNameById: Map, ): Promise { 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();