- 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.
743 lines
28 KiB
TypeScript
743 lines
28 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|