WFDASH-WEBAPP/src/lib/server/price-list-match.ts
SJCoetz 9cdcb3b096 feat: implement sku-based catalogue pricing matching workflow
This commit adds the full end-to-end SKU pricing match flow for quote items:
- Add new QuoteItem and PriceListRow fields for SKU and pricing metadata
- Add sku_normalised to price_list for robust format-agnostic SKU matching
- Refactor price list search to use modular search builder and filter helpers
- Add database migrations, backfill scripts, and new package commands
- Update catalogue search UI and fix quote list accidental click behavior
- Add comprehensive unit, smoke, and end-to-end test suites
- Update documentation for the new price search and extraction logic
2026-06-30 21:20:53 +02:00

322 lines
12 KiB
TypeScript

/**
* `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<PriceListMatch[]> {
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<PriceListMatch>(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<PriceListMatch>(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<PriceListMatch>(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<PriceListMatch, "sku" | "sku_normalised">,
): 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<PriceListMatch | null> {
const matches = await findPriceListBySku(rawInput, options, pb);
return matches[0] ?? null;
}