/** * `findPriceListBySku` — SKU → catalogue row matcher. * * Used by the "Check pricing" button on the quote process page * (and, in v2, by the email-intake Code node + the AI rewriter). * * The match has two tiers, ranked by specificity: * * Tier 1 — exact equality on the canonical form: * - `sku = "${raw}"` (buyer typed it exactly) * - `sku_normalised = "${norm}"` (canonical form — dashes, * case, and other noise stripped) * * Tier 2 — substring fallback (only if Tier 1 yields zero hits): * - `sku ~ "${norm}"` (substring on canonical form; * catches "TS-12" inside "TS-12-55" * or vice-versa) * * Returns the candidates sorted with the **most recently updated * row first** — that's the v1 policy ("newest match wins") we * agreed for multi-supplier SKUs in the brainstorm. If the v1 * callers want a different policy they can use the `preferNewest` * option (default true). * * The function uses admin PB (no user session). Caller-side * permissions/feature gating lives in the route handler. */ import type PocketBase from "pocketbase"; import { getAdminPocketBase } from "./pocketbase-admin"; import type { PriceList } from "../types"; /** A catalogue row slimmed down to the fields the matcher * consumer actually needs. Includes `specifications` and * `search_text` so the picker modal's explainer can show why a * fuzzy candidate matched (e.g. "brand = BOSCH + description * contains grinder"). The shape is a structural superset of * the catalogue's typed fields — `PriceList` is assignable here. */ export type PriceListMatch = Pick< PriceList, | "id" | "sku" | "description" | "cost_price" | "selling_price" | "unit" | "supplier" | "brand" | "category" | "subcategory" | "sku_normalised" | "updated" | "specifications" | "search_text" >; export type FindPriceListBySkuOptions = { /** When true (default), sort the candidates newest-first. */ preferNewest?: boolean; /** Hard cap on the number of rows we look at. Default 25. Tier 3 * has its own tighter cap regardless. */ limit?: number; /** Set false from bulk auto-fill paths (Check pricing) so fuzzy * description/brand hits don't silently patch 10 unrelated rows. * The picker UI passes true. Default true. */ enableTier3?: boolean; }; /** Canonicalise a SKU: uppercase, strip everything that's not A-Z * / 0-9. Exported so callers (and tests) can use the same helper * the matcher uses to compare inputs. */ export function normaliseSku(raw: string | null | undefined): string { if (!raw) return ""; return raw.toUpperCase().replace(/[^A-Z0-9]/g, ""); } /** Escape a literal string for embedding inside a double-quoted PB * filter token. Doubles the only special character (`"` → `\"`). * Exported for unit tests + callers that build their own filters * on top of the matcher. */ export function escapeForPbString(value: string): string { return value.replace(/"/g, '\\"'); } /** * Look up `price_list` rows by SKU. Returns an empty array if no * row matches the input — callers translate that into a "no * match" UI badge. * * Three tiers, ranked by specificity: * * Tier 1 — exact equality on the SKU (raw + canonical form). * Catches "JEITD-06" and "JEITD06" against the canonical * "JEITD-06". * * Tier 2 — substring fallback on the SKU (raw + canonical form). * Catches "JEITD" matching "JEITD-D11", and partial * prefix hits like "TS-12" against "TS-12-55". * * Tier 3 — `search_text` fuzzy (only consulted when Tiers 1+2 * yield zero hits). `search_text` already contains the * SKU, the description, the brand, the category, the * supplier name, and every spec key/value/synonym — so * `search_text ~ "bosch grinder"` matches both rows * where brand="BOSCH" AND rows where a synonym contains * "grinder". This is the "M20 X 50 BOLTS or BOSCH GRINDER * still finds something" tier — it's broad, so we cap * it tighter than the SKU tiers AND only surface it * through the picker modal (the auto-fill rule still * requires Tier 1+2 because Tier 3 is high-risk on * common words). * * @param rawInput The buyer's SKU string. May be empty. * @param options `{ preferNewest, limit, enableTier3 }`. * `enableTier3` defaults to true — the picker * needs it. Pass false from the bulk "Check * pricing" path so a fuzzy hit on "BOSCH" doesn't * auto-populate 10 unrelated rows. * @param pb Optional PocketBase instance. */ export async function findPriceListBySku( rawInput: string, options: FindPriceListBySkuOptions = {}, pb?: PocketBase, ): Promise { const client = pb ?? (await getAdminPocketBase()); const raw = (rawInput ?? "").trim(); if (!raw) return []; const normalised = normaliseSku(raw); if (!normalised) return []; const limit = Math.max(1, Math.min(200, options.limit ?? 25)); const preferNewest = options.preferNewest ?? true; const enableTier3 = options.enableTier3 ?? true; // Tier 1 — exact equality on both raw and canonical forms. We // OR-chain (PB 0.26 has no `IN`) and ask for `-updated` so the // newest match comes back first; tier 2 below is only consulted // when this tier yields zero hits. const tier1Filter = `sku = "${escapeForPbString(raw)}" || sku_normalised = "${escapeForPbString(normalised)}"`; const tier1 = await client.collection("price_list").getList(1, limit, { filter: tier1Filter, sort: preferNewest ? "-updated" : "sku", fields: "id,sku,description,cost_price,selling_price,unit,supplier,brand,category,subcategory,sku_normalised,updated", }); if (tier1.items.length > 0) return tier1.items; // Tier 2 — substring fallback on the canonical form (catches // "JEITD" matching "JEITD-06-EXTRA", and partial-prefix hits // like the buyer typing "TS-12" against a catalogue "TS-12-55"). const tier2Filter = `(sku_normalised ~ "${escapeForPbString(normalised)}" || sku ~ "${escapeForPbString(raw)}")`; const tier2 = await client.collection("price_list").getList(1, limit, { filter: tier2Filter, sort: preferNewest ? "-updated" : "sku", fields: "id,sku,description,cost_price,selling_price,unit,supplier,brand,category,subcategory,sku_normalised,updated", }); if (tier2.items.length > 0) return tier2.items; // Tier 3 — fuzzy `search_text`. Catches description / brand / // category / synonyms matches that have no SKU overlap at all, // e.g. "BOSCH GRINDER" or "M20 X 50 BOLTS" — neither text has // a SKU substring, but the rows contain those tokens in their // description / brand fields. if (!enableTier3) return []; // Tighten the cap so a noisy input ("BOLT") doesn't return // thousands of rows. 10 candidates is plenty for the picker // modal to feel useful. const tier3 = await client.collection("price_list").getList(1, 10, { filter: `search_text ~ "${escapeForPbString(raw)}"`, sort: preferNewest ? "-updated" : "sku", fields: "id,sku,description,cost_price,selling_price,unit,supplier,brand,category,subcategory,sku_normalised,updated,specifications,search_text", }); return tier3.items; } /** * Per-tier label for a match. Used by the picker modal to badge * each candidate with how it was found. The numeric tier is what * the auto-fill rule uses; the label is for the operator's eyes * only. */ export function matchTierLabel(tier: 1 | 2 | 3): string { switch (tier) { case 1: return "Exact SKU"; case 2: return "SKU substring"; case 3: return "Description / brand match"; } } /** * Compute the tier (1, 2, or 3) that produced a match for a given * input. The endpoint uses this to decide whether to auto-fill * (`tier <= 2`) or show the picker (`tier === 3`). * * Cheap and pure — does not call PB. */ export function classifyMatch( rawInput: string, match: Pick, ): 1 | 2 | 3 { const raw = rawInput.trim(); const normalised = normaliseSku(raw); if ( match.sku === raw || (normalised && match.sku_normalised === normalised) ) { return 1; } if ( (normalised && match.sku_normalised.includes(normalised)) || (raw && match.sku.includes(raw)) ) { return 2; } return 3; } /** * Build a short human-readable explanation of why this catalogue * row matched the input. Rule-based today (no LLM call) — runs in * <1ms per candidate so the picker modal renders synchronously * and feels snappy. Designed so an LLM explainer can replace it * in one swap: the picker renders whatever string this returns, * so a future `llmExplainMatch()` can be called instead and its * output dropped in. Once we have a workflow (an n8n hook or a * direct model call) that runs in <500ms reliably, the modal * shows the rule-based line immediately and replaces it when the * LLM returns — instant fill, enriched reading. * * @param rawInput The buyer's SKU / description / brand / etc. * @param match The catalogue row we matched against. * @returns One short sentence (≤120 chars) explaining the * match in the operator's voice. */ export function explainMatch( rawInput: string, match: PriceListMatch, ): string { const tier = classifyMatch(rawInput, match); const raw = rawInput.trim(); const inputLower = raw.toLowerCase(); const inputWords = inputLower.split(/\s+/).filter((w) => w.length >= 2); const bits: string[] = []; // Always anchor on the row's headline identifier. bits.push(match.sku); // Tier 1 & 2 — explain the SKU dimension. if (tier === 1) { bits.push("exact SKU match"); } else if (tier === 2) { bits.push("SKU substring"); } // Tier 3 — explain the description / brand / spec hits. We // surface what concretely matched so the operator can scan // 5 candidates and pick fast. if (tier === 3) { const detailBits: string[] = []; if (match.brand && inputWords.some((w) => match.brand!.toLowerCase().includes(w))) { detailBits.push(`brand: ${match.brand}`); } if (match.category && inputWords.some((w) => match.category!.toLowerCase().includes(w))) { detailBits.push(`category: ${match.category}`); } if (match.supplier && inputWords.some((w) => match.supplier!.toLowerCase().includes(w))) { detailBits.push(`supplier: ${match.supplier}`); } if (match.description) { const descLower = match.description.toLowerCase(); const matchedWords = inputWords.filter((w) => descLower.includes(w)); if (matchedWords.length > 0) { detailBits.push(`description matches "${matchedWords.slice(0, 3).join(", ")}"`); } } // Spec keys & values. `search_text` flattens everything in // `specifications` into one blob, so a substring hit on // `material` showing in the spec means there was a hit. if (match.specifications && typeof match.specifications === "object") { for (const [k, v] of Object.entries(match.specifications)) { if (typeof v === "string" && inputWords.some((w) => v.toLowerCase().includes(w))) { detailBits.push(`${k}: ${v}`); break; // one spec fact per line is enough noise-wise } if (Array.isArray(v) && v.some((sv) => typeof sv === "string" && inputWords.some((w) => sv.toLowerCase().includes(w)))) { detailBits.push(`synonym matches "${k}"`); break; } } } if (detailBits.length === 0) { // Generic fallback so we never render an empty explanation. detailBits.push(`"${raw}" found in catalogue text`); } bits.push(detailBits.join("; ")); } // Closing hint for the supplier so the operator knows where // the price comes from without needing a second lookup. if (match.supplier) { bits.push(`from ${match.supplier}`); } return bits.slice(0, 4).join(" — ").slice(0, 240); } /** Quick "did we match anything" helper. Useful in UI code that * only needs a yes/no/which-row. */ export async function findFirstPriceListBySku( rawInput: string, options?: FindPriceListBySkuOptions, pb?: PocketBase, ): Promise { const matches = await findPriceListBySku(rawInput, options, pb); return matches[0] ?? null; }