When a mine puts a spot out to tender, several engineering companies
each ask us to price the same item list. Before pricing a new one the
operator wants to see what the earlier quote went out at.
The per-line answer already existed: populateItemIntel powers the ⓘ
popover on each row and returns recent_quote (prior quote number,
company, selling price, days ago). This adds no new matching logic —
it runs that same lookup across every line of a quote and rolls the
results up, so the operator gets one panel instead of clicking ⓘ on
each row.
What the roll-up adds over the per-line popover:
- "N of M lines have been quoted before" at a glance
- relatedQuotes ranked by HOW MANY of this quote's lines they also
carried; a prior quote covering 5 of 5 is very likely the same
tender from a different bidder, one covering 1 of 5 is a shared
commodity line
Two honesty details, both driven by what the first live run showed:
- match confidence is surfaced as close/likely/loose. The underlying
matcher accepts anything above 0.5, and a live check returned a
R150 hit against a magnetic base drill — a loose row must not look
as trustworthy as a close one.
- an unpriced prior quote renders "unpriced", never R0.00, which
would read as "we quoted it at zero".
Read-only: passes write:false so viewing the panel doesn't persist
intel onto rows the operator only looked at. On-demand rather than
auto-loading, since it costs one intel computation per line
(~400-650ms each, batched 5 at a time, capped at 40 lines).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
161 lines
5.9 KiB
TypeScript
161 lines
5.9 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||
import type { ItemIntel, ItemIntelQuoteRow } from "@/lib/types";
|
||
|
||
const { pbMock, store, clearStore, intelByItem } = vi.hoisted(() => {
|
||
const store: { items: Array<Record<string, unknown>> } = { items: [] };
|
||
const intelByItem = new Map<string, Partial<ItemIntel>>();
|
||
const pbMock = {
|
||
autoCancellation: () => undefined,
|
||
collection: () => ({
|
||
async getFullList() {
|
||
return store.items;
|
||
},
|
||
}),
|
||
};
|
||
const clearStore = () => {
|
||
store.items = [];
|
||
intelByItem.clear();
|
||
};
|
||
return { pbMock, store, clearStore, intelByItem };
|
||
});
|
||
|
||
vi.mock("@/lib/server/pocketbase-admin", () => ({
|
||
getAdminPocketBase: vi.fn().mockResolvedValue(pbMock),
|
||
}));
|
||
|
||
vi.mock("@/lib/server/item-intel-service", () => ({
|
||
populateItemIntel: vi.fn(async (itemId: string) => {
|
||
const found = intelByItem.get(itemId);
|
||
if (!found) throw new Error(`no stubbed intel for ${itemId}`);
|
||
return { recent_quote: [], ...found } as ItemIntel;
|
||
}),
|
||
}));
|
||
|
||
import { getQuotedBeforeForQuote } from "./quoted-before-service";
|
||
import { populateItemIntel } from "@/lib/server/item-intel-service";
|
||
|
||
/** Minimal ItemIntelQuoteRow — only the fields the roll-up reads. */
|
||
const priorQuote = (over: Partial<ItemIntelQuoteRow>): ItemIntelQuoteRow =>
|
||
({
|
||
score: 0.9,
|
||
quote_item_id: `qi-${Math.random()}`,
|
||
quote_id: "prior-1",
|
||
quote_number: "Q-PRIOR-1",
|
||
quote_status: "emailed",
|
||
customer_name: "",
|
||
company: "Blyvoor Gold",
|
||
description: "",
|
||
cost_price: 0,
|
||
selling_price: 100,
|
||
quantity: 1,
|
||
quoted_at: "2026-08-01T00:00:00Z",
|
||
days_ago: 10,
|
||
...over,
|
||
}) as ItemIntelQuoteRow;
|
||
|
||
const seedLine = (
|
||
itemId: string,
|
||
description: string,
|
||
priors: ItemIntelQuoteRow[],
|
||
quantity = 1,
|
||
) => {
|
||
store.items.push({ id: itemId, description, quantity });
|
||
intelByItem.set(itemId, { recent_quote: priors });
|
||
};
|
||
|
||
beforeEach(() => {
|
||
clearStore();
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
describe("getQuotedBeforeForQuote", () => {
|
||
it("rolls prior quotes up across lines, ranking by how many lines they cover", async () => {
|
||
// The tender case: one prior quote covered both of this quote's
|
||
// lines, another only shared a single commodity line.
|
||
seedLine("i-1", "M20 x 50mm Hex Bolt", [
|
||
priorQuote({ quote_id: "same-tender", quote_number: "Q-TENDER", selling_price: 10, quantity: 100 }),
|
||
priorQuote({ quote_id: "commodity", quote_number: "Q-COMMODITY", selling_price: 11, quantity: 1 }),
|
||
]);
|
||
seedLine("i-2", "M20 Flat Washer", [
|
||
priorQuote({ quote_id: "same-tender", quote_number: "Q-TENDER", selling_price: 2, quantity: 200 }),
|
||
]);
|
||
|
||
const result = await getQuotedBeforeForQuote("q-target");
|
||
|
||
expect(result.totalLines).toBe(2);
|
||
expect(result.matchedLines).toBe(2);
|
||
// Q-TENDER covered both lines, so it ranks first.
|
||
expect(result.relatedQuotes[0].quoteNumber).toBe("Q-TENDER");
|
||
expect(result.relatedQuotes[0].matchedLines).toBe(2);
|
||
// 10×100 + 2×200 = 1400
|
||
expect(result.relatedQuotes[0].matchedValue).toBe(1400);
|
||
expect(result.relatedQuotes[1].quoteNumber).toBe("Q-COMMODITY");
|
||
expect(result.relatedQuotes[1].matchedLines).toBe(1);
|
||
});
|
||
|
||
it("counts a prior quote once per line even if it matched twice on that line", async () => {
|
||
seedLine("i-1", "M20 x 50mm Hex Bolt", [
|
||
priorQuote({ quote_id: "dupe", quote_number: "Q-D", quote_item_id: "a" }),
|
||
priorQuote({ quote_id: "dupe", quote_number: "Q-D", quote_item_id: "b" }),
|
||
]);
|
||
|
||
const result = await getQuotedBeforeForQuote("q-target");
|
||
expect(result.relatedQuotes).toHaveLength(1);
|
||
expect(result.relatedQuotes[0].matchedLines).toBe(1);
|
||
});
|
||
|
||
it("flags an unpriced prior quote instead of implying it went out at zero", async () => {
|
||
seedLine("i-1", "M20 x 50mm Hex Bolt", [
|
||
priorQuote({ quote_id: "unpriced", selling_price: 0 }),
|
||
]);
|
||
|
||
const result = await getQuotedBeforeForQuote("q-target");
|
||
expect(result.relatedQuotes[0].matchedValue).toBe(0);
|
||
expect(result.relatedQuotes[0].hasPrice).toBe(false);
|
||
});
|
||
|
||
it("carries the best match score through so weak hits can be shown as weak", async () => {
|
||
seedLine("i-1", "M20 x 50mm Hex Bolt", [
|
||
priorQuote({ quote_id: "loose", score: 0.52 }),
|
||
]);
|
||
seedLine("i-2", "M20 Flat Washer", [
|
||
priorQuote({ quote_id: "strong", score: 0.95 }),
|
||
]);
|
||
|
||
const result = await getQuotedBeforeForQuote("q-target");
|
||
const loose = result.relatedQuotes.find((r) => r.quoteId === "loose");
|
||
const strong = result.relatedQuotes.find((r) => r.quoteId === "strong");
|
||
expect(loose?.matchConfidence).toBeCloseTo(0.52);
|
||
expect(strong?.matchConfidence).toBeCloseTo(0.95);
|
||
// Equal line coverage → the stronger match sorts first.
|
||
expect(result.relatedQuotes[0].quoteId).toBe("strong");
|
||
});
|
||
|
||
it("reports no matches when nothing was quoted before", async () => {
|
||
seedLine("i-1", "Some brand new bespoke part", []);
|
||
|
||
const result = await getQuotedBeforeForQuote("q-target");
|
||
expect(result.totalLines).toBe(1);
|
||
expect(result.matchedLines).toBe(0);
|
||
expect(result.relatedQuotes).toEqual([]);
|
||
});
|
||
|
||
it("keeps going when one line's intel lookup throws", async () => {
|
||
// A single bad line must not sink the whole panel.
|
||
store.items.push({ id: "i-broken", description: "explodes", quantity: 1 });
|
||
seedLine("i-ok", "M20 x 50mm Hex Bolt", [priorQuote({ quote_id: "p" })]);
|
||
|
||
const result = await getQuotedBeforeForQuote("q-target");
|
||
expect(result.totalLines).toBe(2);
|
||
expect(result.matchedLines).toBe(1);
|
||
expect(result.lines.find((l) => l.itemId === "i-broken")?.priorQuotes).toEqual([]);
|
||
});
|
||
|
||
it("does not persist intel — the panel is a read-only lookup", async () => {
|
||
seedLine("i-1", "M20 x 50mm Hex Bolt", [priorQuote({})]);
|
||
await getQuotedBeforeForQuote("q-target");
|
||
expect(populateItemIntel).toHaveBeenCalledWith("i-1", "quote_items", {
|
||
write: false,
|
||
});
|
||
});
|
||
});
|