/** * 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 { 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(); 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(); 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, }; }