From 0adf23ef4eaa3cda0e327cfc6bec91dbc9f14256 Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 16 Oct 2026 19:12:48 +0200 Subject: [PATCH] feat(quote-assistant): POS shortlist from stock_items Co-Authored-By: Claude Opus 5.5 --- src/lib/server/item-intel-service.ts | 8 +- .../server/quote-assistant-shortlist.test.ts | 120 ++++++++++++++++++ src/lib/server/quote-assistant-shortlist.ts | 109 ++++++++++++++++ 3 files changed, 233 insertions(+), 4 deletions(-) create mode 100644 src/lib/server/quote-assistant-shortlist.test.ts create mode 100644 src/lib/server/quote-assistant-shortlist.ts diff --git a/src/lib/server/item-intel-service.ts b/src/lib/server/item-intel-service.ts index f303c1c..3d7462f 100644 --- a/src/lib/server/item-intel-service.ts +++ b/src/lib/server/item-intel-service.ts @@ -96,7 +96,7 @@ const INTEL_STOPWORDS = new Set([ "the", "and", "with", "for", "of", "to", "a", "an", "in", "on", "at", "by", "from", "as", "is", "or", "x", ]); -function tokenizeIntel(s: string): Set { +export function tokenizeIntel(s: string): Set { const out = new Set(); if (!s) return out; for (const tok of String(s).toLowerCase().match(/[a-z0-9]+/g) || []) { @@ -130,7 +130,7 @@ function daysAgo(iso: string | null | undefined): number { const INFORMATIVE_MIN_LEN = 3; /** Pick the top N tokens by length desc (most distinctive first). */ -function informativeTokens(s: string, max = 4): string[] { +export function informativeTokens(s: string, max = 4): string[] { const toks = tokenizeIntel(s || ""); return [...toks] .filter((t) => !INTEL_STOPWORDS.has(t) && t.length >= INFORMATIVE_MIN_LEN) @@ -148,7 +148,7 @@ function buildPbContainsOR(field: string, targetDesc: string, maxTokens = 4): st } /** Combined Jaccard + overlap score, capped at 1. */ -function combinedScore(a: Set, b: Set): number { +export function combinedScore(a: Set, b: Set): number { return Math.max(jaccard(a, b), overlapScore(a, b)); } @@ -174,7 +174,7 @@ async function safeGetList( } } -async function loadSuppliersById(pb: Awaited>): Promise> { +export async function loadSuppliersById(pb: Awaited>): Promise> { try { const rows = await pb .collection("suppliers") diff --git a/src/lib/server/quote-assistant-shortlist.test.ts b/src/lib/server/quote-assistant-shortlist.test.ts new file mode 100644 index 0000000..9ad5256 --- /dev/null +++ b/src/lib/server/quote-assistant-shortlist.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from "vitest"; +import { shortlistStockItems, type AdminPb } from "./quote-assistant-shortlist"; + +type Row = { + id: string; + sku: string; + name: string; + description: string; + notes?: string; + unit?: string; + cost_price?: number; + selling_price?: number; + markup?: number; + supplier?: string; +}; + +function makePb(rows: Row[]) { + const getList = vi.fn<(page: number, per: number, opts: { filter?: string; fields?: string }) => Promise<{ items: Row[] }>>( + async () => ({ items: rows }), + ); + const getFullList = vi.fn(async () => [ + { id: "sup1", suppliername: "Bolt Supplies" }, + { id: "sup2", suppliername: "Other Co" }, + ]); + const collection = vi.fn((name: string) => { + if (name === "stock_items") return { getList }; + if (name === "suppliers") return { getFullList }; + throw new Error(`unexpected collection ${name}`); + }); + return { pb: { collection } as unknown as AdminPb, collection, getList, getFullList }; +} + +const NUT: Row = { + id: "s1", sku: "NUT-M18", name: "M18 NUT 8 PLN", description: "M18 NUT 8 PLN", + unit: "ea", cost_price: 2, selling_price: 3, markup: 1.5, supplier: "sup1", +}; +const SCREW: Row = { + id: "s2", sku: "SCR-M6", name: "M6x30 SET SCR A2 PLN", description: "M6x30 SET SCR A2 PLN", + cost_price: 1, selling_price: 2, markup: 2, supplier: "sup2", +}; + +describe("shortlistStockItems", () => { + it("filters active rows and ORs name/description/sku per informative token", async () => { + const { pb, getList } = makePb([NUT]); + await shortlistStockItems(pb, { description: "M18 hex nut grade 8" }); + expect(getList).toHaveBeenCalledTimes(1); + const opts = getList.mock.calls[0][2]; + expect(opts.filter!.startsWith("is_active = true && (")).toBe(true); + for (const tok of ["hex", "nut", "grade"]) { + expect(opts.filter).toContain(`name ~ "${tok}"`); + expect(opts.filter).toContain(`description ~ "${tok}"`); + expect(opts.filter).toContain(`sku ~ "${tok}"`); + } + expect(opts.fields).toBe("id,sku,name,description,notes,unit,cost_price,selling_price,markup,supplier"); + expect(getList.mock.calls[0][1]).toBe(500); + }); + + it("returns only rows that match the description", async () => { + const { pb } = makePb([NUT, SCREW]); + const out = await shortlistStockItems(pb, { description: "M18 hex nut grade 8" }); + expect(out.map((r) => r.id)).toEqual(["s1"]); + }); + + it("sorts by score descending and caps at limit", async () => { + const rows: Row[] = [ + { id: "a", sku: "A", name: "bolt hex", description: "bolt hex" }, + { id: "b", sku: "B", name: "bolt hex zinc long", description: "bolt hex zinc long" }, + { id: "c", sku: "C", name: "bolt", description: "bolt" }, + ]; + const { pb } = makePb(rows); + const all = await shortlistStockItems(pb, { description: "bolt hex" }); + expect(all.length).toBe(3); + for (let i = 1; i < all.length; i++) expect(all[i - 1].score).toBeGreaterThanOrEqual(all[i].score); + const capped = await shortlistStockItems(pb, { description: "bolt hex" }, 2); + expect(capped).toHaveLength(2); + expect(capped.map((r) => r.id)).toEqual(all.slice(0, 2).map((r) => r.id)); + }); + + it("ranks a normalised SKU hit first with score 1 even with a weak description", async () => { + const rows: Row[] = [ + NUT, + { id: "sku1", sku: "ab-123/x", name: "Widget", description: "totally different thing" }, + ]; + const { pb } = makePb(rows); + const out = await shortlistStockItems(pb, { description: "M18 hex nut grade 8", sku: "AB 123 X" }); + expect(out[0].id).toBe("sku1"); + expect(out[0].score).toBe(1); + expect(out.map((r) => r.id)).toContain("s1"); + }); + + it("includes the escaped SKU in the PB filter", async () => { + const { pb, getList } = makePb([]); + await shortlistStockItems(pb, { description: "", sku: 'AB"123' }); + expect(getList.mock.calls[0][2].filter).toContain('sku ~ "AB\\"123"'); + }); + + it("drops rows scoring below 0.2", async () => { + const weak: Row = { id: "w", sku: "W", name: "unrelated gadget", description: "unrelated gadget" }; + const { pb } = makePb([weak]); + expect(await shortlistStockItems(pb, { description: "M18 hex nut grade 8" })).toEqual([]); + }); + + it("resolves supplier_name and maps fields", async () => { + const { pb } = makePb([NUT]); + const [row] = await shortlistStockItems(pb, { description: "M18 hex nut grade 8" }); + expect(row).toMatchObject({ + id: "s1", name: "M18 NUT 8 PLN", sku: "NUT-M18", unit: "ea", + cost_price: 2, selling_price: 3, markup: 1.5, + supplier_id: "sup1", supplier_name: "Bolt Supplies", notes: "", + }); + expect(row.score).toBeGreaterThanOrEqual(0.2); + }); + + it("returns [] without any PB call for empty or stopword-only descriptions with no SKU", async () => { + const { pb, collection } = makePb([NUT]); + expect(await shortlistStockItems(pb, { description: "" })).toEqual([]); + expect(await shortlistStockItems(pb, { description: "the and of" })).toEqual([]); + expect(collection).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/server/quote-assistant-shortlist.ts b/src/lib/server/quote-assistant-shortlist.ts new file mode 100644 index 0000000..a2de8fb --- /dev/null +++ b/src/lib/server/quote-assistant-shortlist.ts @@ -0,0 +1,109 @@ +/** + * quote-assistant-shortlist — deterministic (no AI) candidate lookup of POS + * items (`stock_items`) for one quote line. A later step sends these + * candidates to the AI to choose from. + * + * Candidate pull mirrors item-intel-service's stock section: PB `~` OR'd over + * the top informative tokens, then in-app Jaccard/overlap scoring. Differences: + * only active items, name + description + sku are searched, and an exact + * (normalised) SKU hit always wins with score 1. + */ +import type { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; +import { + combinedScore, + informativeTokens, + loadSuppliersById, + tokenizeIntel, +} from "@/lib/server/item-intel-service"; +import { escapeForPbString, normaliseSku } from "@/lib/server/price-list-match"; + +export interface StockCandidate { + id: string; + name: string; + sku: string; + description: string; + notes: string; + unit: string; + cost_price: number; + selling_price: number; + markup: number; + supplier_id: string; + supplier_name: string; + score: number; +} + +export type AdminPb = Awaited>; + +const FETCH_LIMIT = 500; +const MIN_SCORE = 0.2; +const MAX_TOKENS = 4; + +type StockRow = { + id: string; + sku?: string; + name?: string; + description?: string; + notes?: string; + unit?: string; + cost_price?: number; + selling_price?: number; + markup?: number; + supplier?: string; +}; + +export async function shortlistStockItems( + pb: AdminPb, + line: { description: string; sku?: string }, + limit = 8, +): Promise { + const tokens = informativeTokens(line.description || "", MAX_TOKENS); + const rawSku = (line.sku || "").trim(); + const wantedSku = normaliseSku(rawSku); + + const clauses: string[] = []; + for (const t of tokens) { + const v = escapeForPbString(t); + clauses.push(`name ~ "${v}"`, `description ~ "${v}"`, `sku ~ "${v}"`); + } + if (rawSku) clauses.push(`sku ~ "${escapeForPbString(rawSku)}"`); + if (clauses.length === 0) return []; + + const res = await pb.collection("stock_items").getList(1, FETCH_LIMIT, { + filter: `is_active = true && (${clauses.join(" || ")})`, + fields: "id,sku,name,description,notes,unit,cost_price,selling_price,markup,supplier", + }); + + const lineTokens = tokenizeIntel(line.description || ""); + const scored = res.items + .map((row) => { + const skuHit = wantedSku !== "" && normaliseSku(row.sku) === wantedSku; + const score = skuHit + ? 1 + : Math.max( + combinedScore(lineTokens, tokenizeIntel(row.name || "")), + combinedScore(lineTokens, tokenizeIntel(row.description || "")), + ); + return { row, score, skuHit }; + }) + .filter((r) => r.skuHit || r.score >= MIN_SCORE) + .sort((a, b) => b.score - a.score || Number(b.skuHit) - Number(a.skuHit)) + .slice(0, limit); + + if (scored.length === 0) return []; + + const supplierNameById = await loadSuppliersById(pb); + return scored.map(({ row, score }) => ({ + id: row.id, + name: row.name || "", + sku: row.sku || "", + description: row.description || "", + notes: row.notes || "", + unit: row.unit || "", + cost_price: row.cost_price || 0, + selling_price: row.selling_price || 0, + markup: row.markup || 0, + supplier_id: row.supplier || "", + supplier_name: supplierNameById.get(row.supplier || "") || "", + score: +score.toFixed(3), + })); +}