WFDASH-WEBAPP/src/lib/server/quoted-before-service.test.ts
Dave 7c3083c017 feat(quotes): add quote-level "Quoted before?" panel to the process page
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>
2026-08-29 08:52:19 +02:00

161 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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