WFDASH-WEBAPP/src/lib/server/quoted-before-service.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

221 lines
8.4 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.

/**
* quoted-before-service — quote-level roll-up of "have we priced this
* before, and what did it go out at?".
*
* ## Why this exists
*
* The office does mining supply. When a mine puts a spot out to
* tender, SEVERAL engineering companies each ask us to price the same
* item list, because they're all bidding for it. Before pricing the
* new one, the operator wants to see what the earlier quote went out
* at — partly for consistency, partly to give the loyal client the
* better number.
*
* The per-LINE answer already exists: `populateItemIntel` in
* `item-intel-service.ts` powers the ⓘ popover on each row of the
* process page and returns `recent_quote` (prior quote number,
* company, selling price, days ago). This service does NOT re-implement
* any of that matching — it calls the same function per line and rolls
* the results up to the QUOTE level, so the operator gets one panel
* instead of clicking ⓘ on every row.
*
* ## What the roll-up adds over the per-line popover
*
* - "N of your M lines have been quoted before" at a glance.
* - `relatedQuotes`: prior quotes ranked by HOW MANY of this quote's
* lines they also carried. A prior quote covering 5 of 5 lines is
* almost certainly the same tender from a different bidder; one
* covering 1 of 5 is just a shared commodity line.
*
* ## Deliberately not date-filtered
*
* The `quotes` collection has no trustworthy per-quote timestamp — a
* migration stamped `created` on 94% of rows and a later backfill
* stamped `updated` on 95%, while `quote_date` and `email_received_at`
* are effectively empty. Any recency cutoff here would therefore be
* arbitrary. The underlying intel ranks by match score and recency of
* the underlying row, which is the honest signal available.
*/
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { populateItemIntel } from "@/lib/server/item-intel-service";
import type { ItemIntelQuoteRow } from "@/lib/types";
/** One line of the target quote plus the prior quotes that carried it. */
export interface QuotedBeforeLine {
itemId: string;
description: string;
quantity: number;
/** Prior quote rows for this line, best match first. Empty when this
* line has never been quoted before. */
priorQuotes: ItemIntelQuoteRow[];
}
/** A prior quote, rolled up across every line of the target it matched. */
export interface QuotedBeforeRelatedQuote {
quoteId: string;
quoteNumber: string;
status: string;
company: string;
customerName: string;
/** How many of the TARGET quote's lines this prior quote also had.
* The ranking signal — high means "same list, different bidder". */
matchedLines: number;
/** Summed selling_price × quantity across just the matched lines.
* 0 means the prior quote's matched lines were never priced — the
* UI must say "unpriced" rather than render R0.00, which reads as
* "we quoted it at zero". */
matchedValue: number;
/** True when at least one matched line on the prior quote carried a
* price. Distinguishes "priced at R0" from "never priced". */
hasPrice: boolean;
/** Age of the most recent matched line on that quote. */
daysAgo: number;
/**
* Best per-line match score behind this roll-up (0-1), straight from
* the underlying Item Intel ranking. The intel threshold is 0.5, so
* anything here is a *candidate* rather than a certainty — a 0.5-0.6
* hit is often a different product sharing tokens. Surfaced so the
* operator can tell a same-part match from a loose one instead of
* trusting every row equally.
*/
matchConfidence: number;
}
export interface QuotedBeforeResult {
quoteId: string;
/** Lines on the target quote that were checked. */
totalLines: number;
/** How many of those had at least one prior quote. */
matchedLines: number;
lines: QuotedBeforeLine[];
/** Prior quotes ranked by `matchedLines` desc, then recency. */
relatedQuotes: QuotedBeforeRelatedQuote[];
}
/**
* How many line-intel computations run at once. Each `populateItemIntel`
* call fans out to several PB queries, so an unbounded `Promise.all`
* over a 20-line quote would hammer PB. Measured ~400-650ms per line,
* so a batch of 5 keeps a typical 3-10 line quote comfortably inside a
* couple of seconds.
*/
const INTEL_CONCURRENCY = 5;
/**
* Cap on lines checked per request. Quotes with more lines than this
* are truncated rather than left to run for a minute — the roll-up is
* an at-a-glance aid, and the per-line ⓘ popover is still there for
* the remainder.
*/
const MAX_LINES_CHECKED = 40;
export async function getQuotedBeforeForQuote(
quoteId: string,
): Promise<QuotedBeforeResult> {
const pb = await getAdminPocketBase();
pb.autoCancellation(false);
// `moved_to_quote` lines have been split off to another quote and
// aren't part of this one any more — same exclusion the intel
// batch path (`populateIntelForParent`) uses.
const items = await pb.collection("quote_items").getFullList<{
id: string;
description?: string;
quantity?: number;
}>({
filter: `quote = "${quoteId.replace(/"/g, '\\"')}" && (moved_to_quote = null || moved_to_quote = "")`,
fields: "id,description,quantity",
sort: "created",
});
const checked = items.slice(0, MAX_LINES_CHECKED);
const lines: QuotedBeforeLine[] = [];
for (let i = 0; i < checked.length; i += INTEL_CONCURRENCY) {
const batch = checked.slice(i, i + INTEL_CONCURRENCY);
const settled = await Promise.all(
batch.map(async (item) => {
try {
// `write: false` — this is a read-only panel. Persisting the
// blob on every page view would mutate rows the operator
// only looked at.
const intel = await populateItemIntel(item.id, "quote_items", {
write: false,
});
return {
itemId: item.id,
description: item.description || "",
quantity: Number(item.quantity) || 1,
priorQuotes: intel.recent_quote ?? [],
};
} catch {
// One bad line shouldn't sink the whole panel.
return {
itemId: item.id,
description: item.description || "",
quantity: Number(item.quantity) || 1,
priorQuotes: [] as ItemIntelQuoteRow[],
};
}
}),
);
lines.push(...settled);
}
// Roll up by prior quote. A quote that shows up against many of the
// target's lines is the interesting one.
const related = new Map<string, QuotedBeforeRelatedQuote>();
for (const line of lines) {
// Dedupe within a line: if two of the target's own rows matched the
// same prior quote, that prior quote still only covered this line
// once.
const seenThisLine = new Set<string>();
for (const prior of line.priorQuotes) {
if (!prior.quote_id || seenThisLine.has(prior.quote_id)) continue;
seenThisLine.add(prior.quote_id);
const existing = related.get(prior.quote_id);
const unitPrice = Number(prior.selling_price) || 0;
const lineValue = unitPrice * (Number(prior.quantity) || 1);
const score = Number(prior.score) || 0;
if (existing) {
existing.matchedLines += 1;
existing.matchedValue += lineValue;
existing.hasPrice = existing.hasPrice || unitPrice > 0;
existing.daysAgo = Math.min(existing.daysAgo, prior.days_ago ?? 0);
existing.matchConfidence = Math.max(existing.matchConfidence, score);
} else {
related.set(prior.quote_id, {
quoteId: prior.quote_id,
quoteNumber: prior.quote_number || "",
status: prior.quote_status || "",
company: prior.company || "",
customerName: prior.customer_name || "",
matchedLines: 1,
matchedValue: lineValue,
hasPrice: unitPrice > 0,
daysAgo: prior.days_ago ?? 0,
matchConfidence: score,
});
}
}
}
// Most-overlapping first (same list = same tender), then best match
// quality, then most recent. Confidence outranks recency so a strong
// older match isn't buried under a loose recent one.
const relatedQuotes = [...related.values()].sort(
(a, b) =>
b.matchedLines - a.matchedLines ||
b.matchConfidence - a.matchConfidence ||
a.daysAgo - b.daysAgo,
);
return {
quoteId,
totalLines: checked.length,
matchedLines: lines.filter((l) => l.priorQuotes.length > 0).length,
lines,
relatedQuotes,
};
}