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>
221 lines
8.4 KiB
TypeScript
221 lines
8.4 KiB
TypeScript
/**
|
||
* 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,
|
||
};
|
||
}
|