WFDASH-WEBAPP/src/lib/server/quotes-search-service.ts
Dave 4a59288a01 feat(deliveries): enhance delivery notes management and UI
- Introduced a new `DeliveryNoteRow` type to track delivery notes alongside items, improving the organization of delivery data.
- Updated the `DeliveryListResponse` type to include delivery notes, allowing for better integration with the existing delivery management system.
- Enhanced the `groupByCustomerOrder` function to accommodate delivery notes, ensuring they are grouped correctly with their respective orders.
- Implemented state management for delivery notes in the `DeliveriesPage` component, including loading and error handling.
- Added search functionality for delivery notes, enabling operators to filter by delivery note number, customer PO, and order details.
- Updated the UI to display delivery notes alongside items, improving the overall user experience in the deliveries management interface.
- Enhanced tests to cover new delivery notes functionality and ensure correct behavior across various scenarios.
2026-08-17 01:27:03 +02:00

743 lines
28 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.

/**
* Server-side quote search.
*
* Replaces the legacy `searchQuotesWithItems` in `pb-helpers.ts` (a 500-row
* client-side substring scan) with a ranked, tab-aware, pushdown-filter
* search. The old search had three big problems the office kept hitting:
*
* 1. **500-row cap.** Fetched only the 500 most recent quotes. Anything
* older was unreachable. The live PB has ~8.5k quotes; ~94% of the
* population was invisible to search.
* 2. **No SKU / notes / pricing_notes / email_body matching.** Operators
* routinely search by part number or by remembered description
* fragment — the old code's field list was too narrow.
* 3. **No relevance ranking.** Every match was first-class, in
* `created` order. A quote with "acme" buried in a 200-word
* description outranked `quote_number = "ACME-001"`.
*
* Pipeline (revised Aug 2026 after operators reported minute-long
* waits on a typed query like "instance"):
*
* 1. Resolve the optional `tab` filter (canonical mapping in
* `QUOTE_TAB_STATUSES`).
* 2. Push the search down to PB via case-insensitive `~` filters on
* each searchable surface:
* - `quotes` filtered by `~` on the four identifier fields
* (quote_number / quote_reference / rfq_number / external_number)
* - `customers` filtered by `~` on `customer`, then `quotes` with
* `customer IN (...)` for the survivor IDs
* - `companies` filtered by `~` on `company`, then `quotes` with
* `company IN (...)` for the survivor IDs
* - `quote_items` filtered by `~` across description / sku /
* notes / pricing_notes / quote_description, then `quotes`
* with `id IN (...)` for the distinct parent quote IDs
* The tab filter is AND'd into every quote query so we never
* load out-of-tab rows.
* 3. Dedupe the surviving quote IDs into a small candidate set
* (typically dozens, occasionally a few hundred for very
* common tokens like "the").
* 4. Fetch the candidate quotes WITH expand (customer / customer.rep
* / company) — one small PB roundtrip.
* 5. Fetch items for the candidate quote IDs only — again, one
* small PB roundtrip via `fetchQuoteItemsForQuotes`.
* 6. Score each candidate in memory using the same ranker as
* before. PB-side `~` filters are conservative (any-field
* substring); the in-memory scorer upgrades exact-prefix and
* identifier hits above body matches and gives us a sort order.
* 7. Sort by score desc (id asc as stable tiebreak), paginate,
* return top N + per-tab match counts.
*
* Why pushdown matters: the live PB has ~8.5k quotes, each with up
* to dozens of items (and supplier expand). Pulling all of them on
* every keystroke is what made the old pipeline minute-scale. With
* pushdown, the candidate set is the actual number of matches —
* usually tens of rows.
*
* Pure scoring helpers (`scoreQuote`) live next to the service so
* they can be unit-tested without PB. The PB-aware glue lives here.
*/
import { getAdminPocketBase } from "./pocketbase-admin";
import { fetchQuoteItemsForQuotes } from "@/lib/pb-helpers";
import { tokenize, jaccard } from "@/lib/server/order-quote-match";
import {
QUOTE_TAB_STATUSES,
type QuoteTabKey,
} from "@/lib/server/quotes-service";
import type { Quote, QuoteItem } from "@/lib/types";
/* ============================================================================
* Constants
* ========================================================================== */
/** Hard cap on how many quotes we score. Live PB has ~8.5k; the page
* shows the top 50 to the operator. Anything beyond the cap is
* unreachable from this search — the office can fall back to the
* tab-filtered list with no query if they need to scroll history. */
const SEARCH_CANDIDATE_CAP = 8500;
/** Default page size returned by the search endpoint. Page numbering
* follows the same 1-indexed convention as the regular list route. */
const DEFAULT_PER_PAGE = 50;
/** Maximum per-page the API will honour. Operators can't ask for
* more than 100 — same clamp the rest of the API uses. */
const MAX_PER_PAGE = 100;
/* ============================================================================
* Field weights — single source of truth for the ranker
* ============================================================================
*
* Tuned empirically against the office's feedback (Aug 2026):
* - Identifier hits (quote_number / quote_reference / rfq_number /
* external_number) dominate. If the operator typed an exact
* quote_number, that quote should be first regardless of how
* well the body matches.
* - Customer / company header matches are the next-highest signal.
* When the operator types "Acme" they almost always mean the
* customer, not a part called "acme".
* - Item description / SKU / notes come last. These are the "I
* remember a line on this quote" searches — common but lower
* specificity than a header match.
*
* The "exact" prefix on each weight is awarded when the candidate
* value STARTS WITH the query (the strongest substring match —
* usually the operative one for an operator typing letter-by-letter).
* The regular weight is awarded for any substring hit.
*/
const WEIGHTS = {
// Identifier fields — these are designed to be the lookup key.
quoteNumber: { exact: 100, contains: 50 },
quoteReference: { exact: 80, contains: 40 },
rfqNumber: { exact: 80, contains: 40 },
externalNumber: { exact: 80, contains: 40 },
// Header fields — high specificity, second tier.
customer: { exact: 60, contains: 30, fuzzy: 18 },
company: { exact: 60, contains: 30, fuzzy: 18 },
// Item fields — collected (multi-line search), lower per-field but
// summed across the items so a quote matching many lines still wins.
itemDescription: { exact: 25, contains: 12, fuzzy: 8 },
itemSku: { exact: 35, contains: 18, fuzzy: 10 },
itemNotes: { exact: 15, contains: 8, fuzzy: 5 },
itemPricingNotes: { exact: 15, contains: 8, fuzzy: 5 },
} as const;
/* ============================================================================
* Result types
* ========================================================================== */
export interface QuoteSearchHit {
quote: Quote;
items: QuoteItem[];
/** Score (0..1, normalised). Two decimals — stable across re-renders. */
score: number;
/** Rule-by-rule breakdown so the UI can show "why this match"
* (mirrors the order-quote-matcher's `ScoreBreakdown` pattern). */
breakdown: QuoteSearchBreakdown;
}
export interface QuoteSearchBreakdown {
/** Total raw score (sum of weighted contributions). */
raw: number;
/** Normalised 0..1 confidence for the UI. */
score: number;
/** Per-field points. `null` = field not applicable (e.g. empty
* customer). Omitted fields mean "0 points". */
quoteNumber: number | null;
quoteReference: number | null;
rfqNumber: number | null;
externalNumber: number | null;
customer: number | null;
company: number | null;
/** Item-level totals (sum across all matching lines). */
itemDescription: number;
itemSku: number;
itemNotes: number;
itemPricingNotes: number;
/** True if at least one identifier matched (the strongest signal). */
matchedIdentifier: boolean;
}
export interface QuoteSearchResult {
hits: QuoteSearchHit[];
/** Total candidates considered (before cap). */
totalCandidates: number;
/** Total quoted, post-cap. */
totalReturned: number;
/** Active tab filter (`undefined` if no tab was supplied). */
tab: QuoteTabKey | undefined;
/** Total wall-clock ms so the caller can log + diagnose. */
tookMs: number;
/** Debug echo of the query so the UI can show "Showing results for
* 'whatever you typed'". */
query: string;
/** Per-tab hit counts. Keys are the live tab keys; the operator
* reads this like "ACME" is in: New (2), Completed (1). */
perTab: Record<string, number>;
}
/* ============================================================================
* Search parameters
* ========================================================================== */
export interface QuoteSearchParams {
query: string;
/** Optional tab filter. The matching is applied BEFORE scoring, so
* the per-tab counts and the returned hits are all from the same
* subset. When omitted, every quote (including templates) is in
* the candidate set. */
tab?: QuoteTabKey;
/** 1-indexed page. Default 1. */
page?: number;
/** Page size. Default 50, clamped to 1..100. */
perPage?: number;
}
/* ============================================================================
* Pure scoring helpers (unit-testable without PB)
* ========================================================================== */
/** award(headerValue, query, weight) — returns the matching weight or 0.
* `null` when the header value is empty (the row genuinely has no
* data on this field, so we don't want to penalise it). */
function awardHeader(
haystack: string | null | undefined,
needles: { lower: string; tokens: Set<string> } | null,
weight: { exact: number; contains: number; fuzzy?: number },
): number | null {
if (!haystack) return null;
if (!needles) return 0;
const h = haystack.toLowerCase();
if (h.startsWith(needles.lower)) return weight.exact;
if (h.includes(needles.lower)) return weight.contains;
if (weight.fuzzy !== undefined) {
const hTokens = tokenize(haystack);
const j = jaccard(hTokens, needles.tokens);
if (j >= 0.5) return weight.fuzzy;
}
return 0;
}
/** Award per-line: returns the SINGLE best contribution from one item
* field across all items. We don't sum across items because an item
* that matches twice (e.g. description AND sku) is one occurrence, not
* two — a quote with five sku-matching items still surfaces five
* matches, but each item contributes its own row. The caller loops
* over items and sums the per-item result. */
function awardItemField(
haystack: string | null | undefined,
needles: { lower: string; tokens: Set<string> } | null,
weight: { exact: number; contains: number; fuzzy?: number },
): number {
if (!haystack || !needles) return 0;
const h = haystack.toLowerCase();
if (h.startsWith(needles.lower)) return weight.exact;
if (h.includes(needles.lower)) return weight.contains;
if (weight.fuzzy !== undefined) {
const hTokens = tokenize(haystack);
const j = jaccard(hTokens, needles.tokens);
if (j >= 0.5) return weight.fuzzy;
}
return 0;
}
/** Evaluate each header field and the item-level fields for one quote.
* Pure function — no PB, no I/O. */
export function scoreQuote(
quote: Quote,
items: QuoteItem[],
needles: { lower: string; tokens: Set<string> } | null,
): QuoteSearchBreakdown {
const breakdown: QuoteSearchBreakdown = {
raw: 0,
score: 0,
quoteNumber: null,
quoteReference: null,
rfqNumber: null,
externalNumber: null,
customer: null,
company: null,
itemDescription: 0,
itemSku: 0,
itemNotes: 0,
itemPricingNotes: 0,
matchedIdentifier: false,
};
if (!needles) return breakdown;
breakdown.quoteNumber = awardHeader(quote.quote_number, needles, WEIGHTS.quoteNumber);
breakdown.quoteReference = awardHeader(quote.quote_reference, needles, WEIGHTS.quoteReference);
breakdown.rfqNumber = awardHeader(quote.rfq_number, needles, WEIGHTS.rfqNumber);
breakdown.externalNumber = awardHeader(quote.external_number, needles, WEIGHTS.externalNumber);
breakdown.customer = awardHeader(quote.expand?.customer?.customer, needles, WEIGHTS.customer);
breakdown.company = awardHeader(quote.expand?.company?.company, needles, WEIGHTS.company);
// Item-level: sum the best per-line contribution for each field.
// We sum because five items matching the query is a stronger signal
// than one item matching (the office often scrolls "the quote with
// the acme bolts" — many matching items is more meaningful than one).
for (const it of items) {
breakdown.itemDescription += awardItemField(it.description, needles, WEIGHTS.itemDescription);
breakdown.itemSku += awardItemField(it.sku, needles, WEIGHTS.itemSku);
breakdown.itemNotes += awardItemField(it.notes, needles, WEIGHTS.itemNotes);
breakdown.itemPricingNotes += awardItemField(it.pricing_notes, needles, WEIGHTS.itemPricingNotes);
}
// Sum everything except nulls (nulls mean "field not applicable").
for (const v of [
breakdown.quoteNumber,
breakdown.quoteReference,
breakdown.rfqNumber,
breakdown.externalNumber,
breakdown.customer,
breakdown.company,
breakdown.itemDescription,
breakdown.itemSku,
breakdown.itemNotes,
breakdown.itemPricingNotes,
]) {
if (v !== null) breakdown.raw += v;
}
breakdown.matchedIdentifier =
(breakdown.quoteNumber ?? 0) > 0 ||
(breakdown.quoteReference ?? 0) > 0 ||
(breakdown.rfqNumber ?? 0) > 0 ||
(breakdown.externalNumber ?? 0) > 0;
return breakdown;
}
/** The maximum raw score a candidate could achieve under the current
* weights. Used for normalization so scores are comparable across
* candidates (a 1-item quote with hit on quote_number ≈ 1.0; a 50-item
* quote with hit on quote_number ≈ 1.0 too). */
function maxRawScore(itemCount: number): number {
const headerMax =
WEIGHTS.quoteNumber.exact +
WEIGHTS.quoteReference.exact +
WEIGHTS.rfqNumber.exact +
WEIGHTS.externalNumber.exact +
WEIGHTS.customer.fuzzy! +
WEIGHTS.company.fuzzy!;
const perItemMax =
WEIGHTS.itemDescription.exact +
WEIGHTS.itemSku.exact +
WEIGHTS.itemNotes.exact +
WEIGHTS.itemPricingNotes.exact;
// Cap at 10 items worth of item-fields so a 50-item quote doesn't
// naturally score lower than a 1-item quote just for being long.
return headerMax + perItemMax * Math.min(itemCount, 10);
}
/** Normalize a raw score against the candidate's item-count ceiling. */
function normalize(raw: number, itemCount: number): number {
const max = maxRawScore(itemCount);
if (max <= 0) return 0;
const v = Math.max(0, Math.min(1, raw / max));
return Math.round(v * 100) / 100;
}
/* ============================================================================
* PB-aware glue
* ========================================================================== */
/** Build the query needles (lowercased query + tokenised query for
* fuzzy match). Returns `null` when the query is empty — callers
* short-circuit on null so we don't score every quote at zero. */
function buildNeedles(query: string): { lower: string; tokens: Set<string> } | null {
const trimmed = query.trim();
if (!trimmed) return null;
const lower = trimmed.toLowerCase();
// Token-form is only used for fuzzy matches on long descriptions
// (the stop-word filter in `tokenize` keeps the precision up).
return { lower, tokens: tokenize(trimmed) };
}
/** Build a PB filter for the tab's status set. Returns `undefined`
* when no filter is needed (e.g. `all`, `templates`, `delivery_note`)
* — these are handled by the caller via custom filters. */
function tabFilter(tab: QuoteTabKey | undefined): string | undefined {
if (!tab) return undefined;
const statuses = QUOTE_TAB_STATUSES[tab];
if (statuses.length === 0) return undefined;
if (statuses.length === 1) return `status = "${statuses[0]}"`;
return `(${statuses.map((s) => `status = "${s}"`).join(" || ")})`;
}
/** Custom filter for tabs that don't map to a status IN-list. */
function customTabFilter(tab: QuoteTabKey): string | undefined {
if (tab === "delivery_note") return `delivery_note != ""`;
if (tab === "templates") return `is_template = true`;
// "all" = no filter — every quote minus templates. Templates are
// excluded so the catch-all tab doesn't drown in scaffolding rows.
if (tab === "all") return `is_template = false`;
return undefined;
}
/** Tab-handled ship-out. Reads the SKU pivot in the same order as
* the live page. */
function resolveTabFilter(tab: QuoteTabKey | undefined): string | undefined {
return tabFilter(tab) ?? customTabFilter(tab as QuoteTabKey);
}
/** Escape the value for a PB `~` filter expression. PB uses double
* quotes as the string delimiter and treats `\` literally inside
* them, so we escape both backslashes and quotes. Splitting tokens
* isn't worth the complexity — `~` is a simple `LIKE %x%` under
* the hood and operators don't routinely search punctuation. */
function escapeForPbFilter(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
/** Build `(base) && (tabFilter)` when a tab is supplied; otherwise
* just `base`. Lets each pushdown call compose its own match
* filter with the tab constraint without re-implementing the
* resolveTabFilter logic per branch. */
function withTab(base: string, tab: QuoteTabKey | undefined): string {
const t = resolveTabFilter(tab);
if (!t) return base;
return `(${base}) && (${t})`;
}
/** Dedupe + validate an ID list. Drops empty / non-string values so
* a stray empty `quote` relation from PB doesn't poison the IN
* filter. Cap-defensive: if upstream returned >5000 IDs, return
* the first 5000 to keep the OR-chain well under PB's filter
* length limit. The 5000 limit is comfortably above what "instance"
* or any other realistic query should hit (operator-side memory
* notes from August 2026 put the worst real query at ~600 hits). */
function dedupeIds(ids: (string | undefined | null)[], cap = 5000): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const id of ids) {
if (typeof id !== "string" || id.length === 0) continue;
if (seen.has(id)) continue;
seen.add(id);
out.push(id);
if (out.length >= cap) break;
}
return out;
}
/** Build an OR-chain of `id = "<id>"` clauses. Avoids the PB `IN`
* filter entirely because some PB versions reject `IN` with very
* long lists; OR-chains of N <5000 are reliable across versions. */
function idEqualsChain(ids: string[]): string {
return ids.map((id) => `id = "${escapeForPbFilter(id)}"`).join(" || ");
}
/** Build an OR-chain of `customer = "<id>"` / `company = "<id>"`
* clauses for header-side joins. Same OR-chain rationale as
* `idEqualsChain`. */
function relationEqualsChain(customerIds: string[], companyIds: string[]): string {
const parts: string[] = [];
for (const id of customerIds) parts.push(`customer = "${escapeForPbFilter(id)}"`);
for (const id of companyIds) parts.push(`company = "${escapeForPbFilter(id)}"`);
return parts.join(" || ");
}
/** Minimal quote projection used during the pushdown phase. We only
* need the IDs to dedupe + feed the next roundtrip; the expanded
* rows come back in the final fetch. Trimmed to absolute minimum
* to keep the pushdown responses small. */
const QUOTE_ID_FIELDS = "id,status,is_template,delivery_note";
/** Fetch all candidate quote IDs that match the search anywhere in
* their identifier fields, header customer / company name, or any
* line-item field. The fetch is a fan-out of up to 4 small PB
* queries (identifier, customer-name, company-name, item-text),
* each AND'd with the active tab filter.
*
* Returns a deduped array of quote IDs. The order is not
* meaningful — the scorer ranks them. */
async function fetchCandidateIds(
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
esc: string,
tab: QuoteTabKey | undefined,
): Promise<string[]> {
// 1. Identifier matches on quotes. No need to expand — we want
// every quote whose quote_number / quote_reference /
// rfq_number / external_number contains the substring.
const idQuotes = await pb.collection("quotes").getFullList<{
id: string;
status?: string;
is_template?: boolean;
delivery_note?: string;
}>({
filter: withTab(
`quote_number~"${esc}" || quote_reference~"${esc}" || rfq_number~"${esc}" || external_number~"${esc}"`,
tab,
),
fields: QUOTE_ID_FIELDS,
batch: 500,
});
// 2. + 3. Header matches: hit `customers.customer` and
// `companies.company` tables first (much smaller), then
// fetch quotes with those relations. Parallel because they
// touch independent collections.
const [matchedCustomers, matchedCompanies] = await Promise.all([
pb.collection("customers").getFullList<{ id: string }>({
filter: `customer~"${esc}"`,
fields: "id",
batch: 500,
}),
pb.collection("companies").getFullList<{ id: string }>({
filter: `company~"${esc}"`,
fields: "id",
batch: 500,
}),
]);
const customerIds = matchedCustomers.map((c) => c.id);
const companyIds = matchedCompanies.map((c) => c.id);
const headerQuotes =
customerIds.length + companyIds.length > 0
? await pb.collection("quotes").getFullList<{
id: string;
status?: string;
is_template?: boolean;
delivery_note?: string;
}>({
filter: withTab(relationEqualsChain(customerIds, companyIds), tab),
fields: QUOTE_ID_FIELDS,
batch: 500,
})
: [];
// 4. Item-text matches. Query `quote_items` directly, then
// fetch the parent quotes so the in-memory scorer can read
// item descriptions against the query. Distinct and finite:
// `description` + `quote_description` cover the body, the
// rest cover operator-skimmed fields.
const matchedItems = await pb
.collection("quote_items")
.getFullList<{ quote?: string }>({
filter: `description~"${esc}" || quote_description~"${esc}" || sku~"${esc}" || notes~"${esc}" || pricing_notes~"${esc}"`,
fields: "quote",
batch: 1000,
});
const itemQuoteIds = dedupeIds(matchedItems.map((m) => m.quote));
const itemQuotes =
itemQuoteIds.length > 0
? await pb.collection("quotes").getFullList<{
id: string;
status?: string;
is_template?: boolean;
delivery_note?: string;
}>({
filter: withTab(idEqualsChain(itemQuoteIds), tab),
fields: QUOTE_ID_FIELDS,
batch: 500,
})
: [];
// 5. Dedupe across all three sources. The set is the union of
// (quote identifier hits) ∪ (header name hits) ∪ (item-text
// hits).
const allIds: string[] = [
...idQuotes.map((q) => q.id),
...headerQuotes.map((q) => q.id),
...itemQuotes.map((q) => q.id),
];
return dedupeIds(allIds);
}
/* ============================================================================
* Main public API
* ========================================================================== */
/**
* Ranked quote search. Returns the top N (page-sized) hits for the
* query, after applying the optional tab filter and pushdown-filtering
* on PocketBase (so the candidate set is the actual number of
* matches, not the full quote collection).
*/
export async function searchQuotes(
params: QuoteSearchParams,
): Promise<QuoteSearchResult> {
const start = Date.now();
const { query, tab } = params;
const page = Math.max(1, params.page ?? 1);
const perPage = Math.min(MAX_PER_PAGE, Math.max(1, params.perPage ?? DEFAULT_PER_PAGE));
const needles = buildNeedles(query);
// Empty query → no hits. The caller (the page) suppresses the
// search call when the input is empty, but we guard here too.
if (!needles) {
return {
hits: [],
totalCandidates: 0,
totalReturned: 0,
tab,
tookMs: Date.now() - start,
query,
perTab: {},
};
}
const pb = await getAdminPocketBase();
pb.autoCancellation(false);
const esc = escapeForPbFilter(needles.lower);
// 1. Pushdown: get a small deduped list of candidate quote IDs
// from PB via `~` filters on every searchable surface.
const candidateIds = await fetchCandidateIds(pb, esc, tab);
// 2. Fetch the candidate quotes WITH expand so the scorer can
// read the customer / company name (pushdown step only
// produced IDs).
const candidates =
candidateIds.length > 0
? await pb.collection("quotes").getFullList<Quote>({
filter: idEqualsChain(candidateIds),
fields: QUOTE_SEARCH_FIELDS,
expand: QUOTE_SEARCH_EXPAND,
batch: 500,
})
: [];
// 3. Items for the candidates (chunked IN under the hood).
const allItems = await fetchQuoteItemsForQuotes(candidateIds, {
fields: QUOTE_ITEM_SEARCH_FIELDS,
expand: QUOTE_ITEM_SEARCH_EXPAND,
});
const itemsByQuote: Record<string, QuoteItem[]> = {};
for (const it of allItems) {
(itemsByQuote[it.quote] ||= []).push(it);
}
// 4. Score each candidate.
const scored: QuoteSearchHit[] = [];
const perTab: Record<string, number> = {};
for (const quote of candidates) {
const items = itemsByQuote[quote.id] ?? [];
const breakdown = scoreQuote(quote, items, needles);
if (breakdown.raw <= 0) continue; // skip zero-match candidates
const score = normalize(breakdown.raw, items.length);
scored.push({ quote, items, score, breakdown });
// Per-tab hit counts (rounded to 2dp on the way out).
const t = deriveTabKey(quote);
if (t) perTab[t] = (perTab[t] ?? 0) + 1;
}
// 5. Sort by score desc, then by quote id (stable) for deterministic
// pagination. Score is the primary key; created is intentionally
// NOT used as a tiebreak so the operator sees the strongest
// match first, not the newest.
scored.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
return a.quote.id < b.quote.id ? -1 : a.quote.id > b.quote.id ? 1 : 0;
});
// 6. Cap at the global candidate limit (defensive — the
// pushdown should already keep this in check; very-common
// tokens like "the" can still produce hundreds).
const capped = scored.slice(0, SEARCH_CANDIDATE_CAP);
// 7. Paginate.
const startIdx = (page - 1) * perPage;
const hits = capped.slice(startIdx, startIdx + perPage);
return {
hits,
totalCandidates: candidateIds.length,
totalReturned: capped.length,
tab,
tookMs: Date.now() - start,
query,
perTab,
};
}
/* ============================================================================
* Embedded fields / expand — kept here so the legacy fields-projection
* bug (PB drops the `expand` block if "expand" isn't listed) doesn't
* bite us again. The same projection-drop root caused the sprint-page
* regression locked in at `sprint/page.test.tsx`.
* ========================================================================== */
const QUOTE_SEARCH_FIELDS = [
"id",
"quote_number",
"quote_reference",
"rfq_number",
"external_number",
"status",
"claimed_by",
"is_template",
"follow_up_due",
"last_reply_at",
"customer",
"company",
"delivery_note",
"created",
"updated",
// Expanded block — needed for the customer/company/rep cells.
"expand",
"company.id",
"company.company",
"customer.id",
"customer.customer",
"customer.rep",
"customer.rep.id",
"customer.rep.name",
"customer.rep.email",
].join(",");
const QUOTE_SEARCH_EXPAND = "customer,customer.rep,company";
const QUOTE_ITEM_SEARCH_FIELDS = [
"id",
"quote",
"description",
"sku",
"notes",
"pricing_notes",
"quantity",
"selling_price",
"cost_price",
"is_processed",
"supplier",
"supplier.suppliername",
"expand",
].join(",");
const QUOTE_ITEM_SEARCH_EXPAND = "supplier";
/* ============================================================================
* Tab derivation — used to attribute hits to a tab so the page can
* flip the tab badges to per-tab match counts.
* ========================================================================== */
/** Map a quote row to the tab it would land on (matches the live
* page's `QUOTE_TAB_STATUSES` exactly). Templates are attributed
* to `templates`, not the underlying status — the page sorts
* templates into their own bucket. */
function deriveTabKey(quote: Quote): QuoteTabKey | undefined {
// Templates first — the templates tab is its own thing.
if (quote.is_template === true) return "templates";
// Delivery-note tab is relation-based.
if (quote.delivery_note && (quote.delivery_note as unknown as string) !== "") {
return "delivery_note";
}
const status = (quote.status || "").toString().trim();
// Walk the tab order so the first match wins (preserves the
// page's expected bucketing for status collisions).
const order: QuoteTabKey[] = [
"new",
"claimed",
"pending_approval",
"completed",
"cancelled",
"email_failed",
"problem",
];
for (const t of order) {
if (QUOTE_TAB_STATUSES[t].includes(status)) return t;
}
return undefined;
}