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
322 lines
12 KiB
TypeScript
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;
|
|
}
|