diff --git a/src/lib/server/item-intel-service.test.ts b/src/lib/server/item-intel-service.test.ts new file mode 100644 index 0000000..25251a7 --- /dev/null +++ b/src/lib/server/item-intel-service.test.ts @@ -0,0 +1,391 @@ +/** + * item-intel-service — anchored intel. + * + * The mock PB is a small in-memory filter engine so the real PB + * filter syntax the service emits (`sku = "…"`, `sku ~ "…"`, + * `description ~ "…" || description ~ "…"`, `id != "…" && (…)`) is + * exercised rather than bypassed. PocketBase has no `IN` operator — + * the service only ever uses equality, `!=` and `~`. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/server/pocketbase-admin", () => ({ + getAdminPocketBase: vi.fn(), +})); + +import { populateItemIntel, combinedScore, tokenizeIntel } from "./item-intel-service"; +import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; + +type Row = Record; + +// ─── Tiny PB filter evaluator ───────────────────────────────────────── + +function stripOuterParens(expr: string): string { + let t = expr.trim(); + while (t.startsWith("(") && t.endsWith(")")) { + let depth = 0; + let wrapsAll = true; + for (let i = 0; i < t.length; i++) { + if (t[i] === "(") depth++; + else if (t[i] === ")") { + depth--; + if (depth === 0 && i < t.length - 1) { + wrapsAll = false; + break; + } + } + } + if (!wrapsAll) break; + t = t.slice(1, -1).trim(); + } + return t; +} + +function splitTop(expr: string, sep: string): string[] { + const parts: string[] = []; + let depth = 0; + let buf = ""; + for (let i = 0; i < expr.length; i++) { + const ch = expr[i]; + if (ch === "(") depth++; + if (ch === ")") depth--; + if (depth === 0 && expr.startsWith(sep, i)) { + parts.push(buf); + buf = ""; + i += sep.length - 1; + continue; + } + buf += ch; + } + parts.push(buf); + return parts.map((p) => p.trim()).filter((p) => p.length > 0); +} + +function evalFilter(filter: string, row: Row): boolean { + const expr = stripOuterParens(filter); + const orParts = splitTop(expr, "||"); + if (orParts.length > 1) return orParts.some((p) => evalFilter(p, row)); + const andParts = splitTop(expr, "&&"); + if (andParts.length > 1) return andParts.every((p) => evalFilter(p, row)); + + const m = expr.match(/^([A-Za-z0-9_.]+)\s*(=|!=|~)\s*"([^"]*)"$/); + if (!m) return true; // unknown clause — don't silently drop the row + const [, field, op, value] = m; + const actual = String(row[field] ?? ""); + if (op === "=") return actual === value; + if (op === "!=") return actual !== value; + return actual.toLowerCase().includes(value.toLowerCase()); +} + +// ─── Mock PB ───────────────────────────────────────────────────────── + +interface Seed { + target?: Row; + stockItems?: Row[]; + suppliers?: Row[]; + stockPurchaseItems?: Row[]; + priceList?: Row[]; + quoteItems?: Row[]; + orderItems?: Row[]; +} + +function makePb(seed: Seed) { + const tables: Record = { + stock_items: seed.stockItems ?? [], + suppliers: seed.suppliers ?? [], + stock_purchase_items: seed.stockPurchaseItems ?? [], + price_list: seed.priceList ?? [], + quote_items: seed.quoteItems ?? [], + order_items: seed.orderItems ?? [], + }; + // The line under inspection lives in the collection we read the + // target from. It is seeded into `quote_items` only — seeding it + // into `order_items` would make the order-history fuzzy pull match + // the line against itself. + if (seed.target) { + tables.quote_items = [seed.target, ...tables.quote_items]; + } + const updates: Array<{ id: string; data: Row }> = []; + const queries: Array<{ collection: string; filter?: string }> = []; + + const pb = { + autoCancellation: () => undefined, + collection(name: string) { + const rows = () => tables[name] ?? []; + return { + async getOne(id: string) { + const hit = rows().find((r) => r.id === id); + if (!hit) { + const err = new Error(`404 not found: ${name}/${id}`) as Error & { status: number }; + err.status = 404; + throw err; + } + return { ...hit }; + }, + async getList(_page: number, _perPage: number, opts: { filter?: string } = {}) { + queries.push({ collection: name, filter: opts.filter }); + const items = rows().filter((r) => !opts.filter || evalFilter(opts.filter, r)); + return { items: items.map((r) => ({ ...r })), totalItems: items.length }; + }, + async getFullList(opts: { filter?: string } = {}) { + queries.push({ collection: name, filter: opts.filter }); + return rows() + .filter((r) => !opts.filter || evalFilter(opts.filter, r)) + .map((r) => ({ ...r })); + }, + async update(id: string, data: Row) { + updates.push({ id, data }); + return { ...data }; + }, + }; + }, + }; + + return { pb, updates, queries }; +} + +// ─── Fixtures ──────────────────────────────────────────────────────── + +const TARGET: Row = { + id: "qi-1", + sku: "", + description: "M20 x 50mm Hex Bolt", + quantity: 100, + supplier: "sup-fowkes", +}; + +/** The row the caller pins via the anchor. Its description scores + * LOWER on fuzzy overlap than FUZZY_ITEM on purpose — so "anchored + * row ends up first" proves the anchor did the work, not the score. */ +const ANCHORED_ITEM: Row = { + id: "si-anchor", + sku: "BOLT-M20-50", + name: "M20 Hex Bolt 50mm", + description: "M20 Hex Bolt Zinc Plated", + cost_price: 0.42, + selling_price: 0.78, + current_qty: 120, + min_qty: 20, + supplier: "sup-fowkes", +}; + +/** A plain description-fuzzy hit — scores higher than the anchored row. */ +const FUZZY_ITEM: Row = { + id: "si-fuzzy", + sku: "OTHER-BOLT", + name: "M20 Hex Bolt 50mm", + description: "M20 x 50mm Hex Bolt", + cost_price: 0.55, + selling_price: 0.9, + current_qty: 4, + min_qty: 10, + supplier: "sup-other", +}; + +const SUPPLIERS: Row[] = [ + { id: "sup-fowkes", suppliername: "Fowkes" }, + { id: "sup-other", suppliername: "AfriSand" }, +]; + +/** A purchase row for the anchored stock item. */ +const ANCHOR_PURCHASE: Row = { + id: "spi-1", + stock_item: "si-anchor", + cost_price: 0.42, + quantity: 100, + expand: { + purchase: { + id: "sp-1", + purchase_number: "PO26080101STK", + supplier: "sup-fowkes", + created: "2026-08-12T08:00:00.000Z", + }, + }, +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("populateItemIntel — anchored stock item", () => { + it("puts the anchored stock item first in stock_list and records match_kind=stock_item", async () => { + const { pb } = makePb({ + target: TARGET, + stockItems: [FUZZY_ITEM, ANCHORED_ITEM], + suppliers: SUPPLIERS, + }); + vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never); + + const intel = await populateItemIntel("qi-1", "quote_items", { + write: false, + anchor: { stockItemId: "si-anchor" }, + }); + + expect(intel.match_kind).toBe("stock_item"); + expect(intel.stock_list[0].stock_item_id).toBe("si-anchor"); + expect(intel.stock_list[0].qty).toBe(120); + expect(intel.stock_list[0].status).toBe("in_stock"); + expect(intel.stock_list[0].supplier_name).toBe("Fowkes"); + // The anchored row wins even though its own fuzzy score is lower + // than the plain description match behind it — the anchor did the + // work, not the ranking. + const naturalAnchorScore = combinedScore( + tokenizeIntel(TARGET.description as string), + tokenizeIntel(ANCHORED_ITEM.description as string), + ); + const fuzzyScore = combinedScore( + tokenizeIntel(TARGET.description as string), + tokenizeIntel(FUZZY_ITEM.description as string), + ); + expect(naturalAnchorScore).toBeLessThan(fuzzyScore); + expect(intel.stock_list[1].stock_item_id).toBe("si-fuzzy"); + // The anchored row is never listed twice. + expect(intel.stock_list.filter((r) => r.stock_item_id === "si-anchor")).toHaveLength(1); + }); + + it("includes the anchored item's purchase history in supplier_history", async () => { + const { pb } = makePb({ + target: TARGET, + stockItems: [ANCHORED_ITEM], + suppliers: SUPPLIERS, + stockPurchaseItems: [ANCHOR_PURCHASE], + }); + vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never); + + const intel = await populateItemIntel("qi-1", "quote_items", { + write: false, + anchor: { stockItemId: "si-anchor" }, + }); + + expect(intel.supplier_history).toHaveLength(1); + expect(intel.supplier_history[0].supplier_name).toBe("Fowkes"); + expect(intel.supplier_history[0].last_cost).toBe(0.42); + expect(intel.supplier_history[0].times_bought).toBe(1); + expect(intel.supplier_history[0].stock_item_id).toBe("si-anchor"); + }); + + it("resolves the anchor by SKU when no id is given (match_kind=sku)", async () => { + const { pb, queries } = makePb({ + target: TARGET, + stockItems: [ANCHORED_ITEM], + suppliers: SUPPLIERS, + }); + vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never); + + const intel = await populateItemIntel("qi-1", "quote_items", { + write: false, + anchor: { sku: "BOLT-M20-50" }, + }); + + expect(intel.match_kind).toBe("sku"); + expect(intel.stock_list[0].stock_item_id).toBe("si-anchor"); + // Exact SKU equality, not a `~` contains match. + expect( + queries.some((q) => q.collection === "stock_items" && q.filter === 'sku = "BOLT-M20-50"'), + ).toBe(true); + }); + + it("matches the canonical SKU form when the raw one misses (JEITD06 → JEITD-06)", async () => { + const jeitd: Row = { + id: "si-jeitd", + sku: "JEITD-06", + name: "JEITD 06", + description: "JEITD 06 coupling", + cost_price: 12, + selling_price: 20, + current_qty: 3, + min_qty: 1, + supplier: "sup-other", + }; + const { pb } = makePb({ + target: { ...TARGET, description: "JEITD 06 coupling" }, + stockItems: [jeitd], + suppliers: SUPPLIERS, + }); + vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never); + + const intel = await populateItemIntel("qi-1", "quote_items", { + write: false, + anchor: { sku: "JEITD06" }, + }); + + expect(intel.match_kind).toBe("sku"); + expect(intel.stock_list[0].stock_item_id).toBe("si-jeitd"); + }); + + it("keeps the fuzzy behaviour and match_kind=fuzzy when no anchor is given", async () => { + const { pb } = makePb({ + target: TARGET, + stockItems: [FUZZY_ITEM, ANCHORED_ITEM], + suppliers: SUPPLIERS, + }); + vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never); + + const intel = await populateItemIntel("qi-1", "quote_items", { write: false }); + + expect(intel.match_kind).toBe("fuzzy"); + // Purely score-ranked — the exact description match wins on its + // own score, no forced first row. + expect(intel.stock_list[0].stock_item_id).toBe("si-fuzzy"); + expect(intel.stock_list[0].score).toBe(1); + expect(intel.stock_list.map((r) => r.stock_item_id)).toContain("si-anchor"); + }); + + it("falls back to fuzzy when the anchored stock item no longer exists", async () => { + const { pb } = makePb({ + target: TARGET, + stockItems: [FUZZY_ITEM], + suppliers: SUPPLIERS, + }); + vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never); + + const intel = await populateItemIntel("qi-1", "quote_items", { + write: false, + anchor: { stockItemId: "si-deleted", sku: "GONE-SKU" }, + }); + + expect(intel.match_kind).toBe("fuzzy"); + expect(intel.stock_list.map((r) => r.stock_item_id)).toContain("si-fuzzy"); + }); + + it("still produces intel for a line with no descriptive tokens when anchored", async () => { + // The fuzzy candidate pull needs informative tokens in the + // description; an anchor must work even without them. + const { pb } = makePb({ + target: { ...TARGET, description: "—" }, + stockItems: [ANCHORED_ITEM], + suppliers: SUPPLIERS, + stockPurchaseItems: [ANCHOR_PURCHASE], + }); + vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never); + + const intel = await populateItemIntel("qi-1", "quote_items", { + write: false, + anchor: { stockItemId: "si-anchor" }, + }); + + expect(intel.match_kind).toBe("stock_item"); + expect(intel.stock_list).toHaveLength(1); + expect(intel.stock_list[0].stock_item_id).toBe("si-anchor"); + expect(intel.supplier_history).toHaveLength(1); + }); + + it("writes the payload including match_kind", async () => { + const { pb, updates } = makePb({ + target: TARGET, + stockItems: [ANCHORED_ITEM], + suppliers: SUPPLIERS, + }); + vi.mocked(getAdminPocketBase).mockResolvedValue(pb as never); + + await populateItemIntel("qi-1", "quote_items", { + write: true, + anchor: { stockItemId: "si-anchor" }, + }); + + expect(updates).toHaveLength(1); + expect(updates[0].id).toBe("qi-1"); + expect((updates[0].data.ai_item_insights as { match_kind: string }).match_kind).toBe("stock_item"); + }); +}); diff --git a/src/lib/server/item-intel-service.ts b/src/lib/server/item-intel-service.ts index 3d7462f..ba40bef 100644 --- a/src/lib/server/item-intel-service.ts +++ b/src/lib/server/item-intel-service.ts @@ -52,6 +52,18 @@ * from get hidden behind a low stock score. The SPI walk uses a * wider cap (10) and runs in parallel. * + * ## Anchoring (exact POS item) + * + * When the caller already knows which catalogue row the line refers + * to — the AI quote/order assistant filled the line from a specific + * `stock_items` row — it passes `anchor: { stockItemId?, sku? }`. + * The anchored row is forced to the top of `stock_list` and always + * gets its purchase history walked, so its supplier appears in + * `supplier_history` even when the fuzzy ranking would have missed + * it. `match_kind` on the payload records how it resolved: + * `"stock_item"` (found by PB id), `"sku"` (found by SKU) or + * `"fuzzy"` (no anchor, or the anchor no longer resolves). + * * ## Auth + writes * * Uses admin PB client. Writes `quote_items.ai_item_insights` or @@ -61,8 +73,11 @@ import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; import { jaccard } from "@/lib/server/order-quote-match"; +import { escapeForPbString, normaliseSku } from "@/lib/server/price-list-match"; import type { ItemIntel, + ItemIntelAnchor, + ItemIntelMatchKind, ItemIntelPriceListRow, ItemIntelQuoteRow, ItemIntelOrderRow, @@ -187,10 +202,106 @@ export async function loadSuppliersById(pb: Awaited>, + id: string, +): Promise { + if (!id) return null; + try { + const row = await pb.collection("stock_items").getOne(id, { + fields: STOCK_ITEM_FIELDS, + }); + return row ?? null; + } catch (err) { + const status = (err as { status?: number })?.status; + if (status === 404 || status === 400) return null; + console.warn(`[item-intel] anchor stock_item ${id} lookup failed:`, status); + return null; + } +} + +/** Fetch a `stock_items` row by SKU. + * + * Tier 1 — exact `sku = "…"`. + * Tier 2 — canonical equality ("JEITD06" ≡ "JEITD-06"). `stock_items` + * has no `sku_normalised` column (only `price_list` does), so + * the canonical comparison happens app-side over a loose + * candidate pull keyed on the SKU's alphabetic stem. */ +async function fetchStockItemBySku( + pb: Awaited>, + sku: string, +): Promise { + const raw = (sku || "").trim(); + if (!raw) return null; + + const exact = await safeGetList(pb, "stock_items", { + filter: `sku = "${escapeForPbString(raw)}"`, + fields: STOCK_ITEM_FIELDS, + max: 20, + }); + const exactHit = exact.items.find((r) => (r.sku || "").trim() === raw) || exact.items[0]; + if (exactHit) return exactHit; + + const normalised = normaliseSku(raw); + // Alphabetic stem — "JEITD-06" → "JEITD". Too short to be a useful + // filter on its own, so skip the loose pull in that case. + const stem = normalised.replace(/[0-9]+$/, ""); + if (stem.length < 3) return null; + + const loose = await safeGetList(pb, "stock_items", { + filter: `sku ~ "${escapeForPbString(stem)}"`, + fields: STOCK_ITEM_FIELDS, + max: 60, + }); + return loose.items.find((r) => normaliseSku(r.sku) === normalised) ?? null; +} + +/** Resolve the caller's anchor to a concrete `stock_items` row. + * + * `stockItemId` wins over `sku`. Returns null when neither resolves — + * the caller then keeps today's fuzzy behaviour. */ +async function resolveAnchorStockItem( + pb: Awaited>, + anchor: ItemIntelAnchor | undefined, +): Promise<{ row: StockItemRow; matchKind: ItemIntelMatchKind } | null> { + if (!anchor) return null; + + if (anchor.stockItemId) { + const byId = await fetchStockItemById(pb, anchor.stockItemId); + if (byId) return { row: byId, matchKind: "stock_item" }; + } + if (anchor.sku) { + const bySku = await fetchStockItemBySku(pb, anchor.sku); + if (bySku) return { row: bySku, matchKind: "sku" }; + } + return null; +} + async function buildStock( pb: Awaited>, target: { id: string; description: string }, supplierNameById: Map, + anchor?: ItemIntelAnchor, ): Promise<{ matches: ItemIntelStockRow[]; purchaseHistory: Array<{ stock_item_id: string; stock_item_sku: string; @@ -202,28 +313,41 @@ async function buildStock( purchased_at: string; days_ago: number; purchase_number: string; -}> }> { - const filter = buildPbContainsOR("description", target.description); - if (!filter) return { matches: [], purchaseHistory: [] }; +}>; matchKind: ItemIntelMatchKind }> { + const anchored = await resolveAnchorStockItem(pb, anchor); + const matchKind: ItemIntelMatchKind = anchored ? anchored.matchKind : "fuzzy"; - const candRows = await safeGetList<{ - id: string; sku: string; name: string; description: string; - cost_price: number; selling_price: number; current_qty: number; - min_qty: number | null; supplier: string; - }>(pb, "stock_items", { - filter, - fields: "id,sku,name,description,cost_price,selling_price,current_qty,min_qty,supplier", - max: 500, - }); + const filter = buildPbContainsOR("description", target.description); + let candRows: PbListResult = { items: [] }; + if (filter) { + candRows = await safeGetList(pb, "stock_items", { + filter, + fields: STOCK_ITEM_FIELDS, + max: 500, + }); + } const targetTokens = tokenizeIntel(target.description); const rankedAll = candRows.items .map((si) => ({ si, score: combinedScore(targetTokens, tokenizeIntel(si.description || "")) })) .filter((r) => r.score >= 0.2) - .sort((a, b) => b.score - a.score); + .sort((a, b) => b.score - a.score) + // The anchored row is prepended separately — drop it from the fuzzy + // list so it can't appear twice. + .filter((r) => !anchored || r.si.id !== anchored.row.id); - const forDisplay = rankedAll.slice(0, ITEM_INTEL_LIMITS.stock); - const forPurchases = rankedAll.slice(0, 10); + // Anchored row first (score 1 — it IS the match), then the best + // fuzzy candidates in the remaining slots. + const forDisplay = anchored + ? [{ si: anchored.row, score: 1 }, ...rankedAll].slice(0, ITEM_INTEL_LIMITS.stock) + : rankedAll.slice(0, ITEM_INTEL_LIMITS.stock); + + // The purchase walk always covers the anchored row so its supplier + // shows up in `supplier_history` even when the fuzzy ranking missed + // it entirely. + const forPurchases = anchored + ? [{ si: anchored.row, score: 1 }, ...rankedAll].slice(0, PURCHASE_SCAN_CAP) + : rankedAll.slice(0, PURCHASE_SCAN_CAP); const matches: ItemIntelStockRow[] = forDisplay.map(({ si, score }) => { const current_qty = si.current_qty || 0; @@ -290,7 +414,7 @@ async function buildStock( )).flat(); purchaseHistory.sort((a, b) => (b.purchased_at || "").localeCompare(a.purchased_at || "")); - return { matches, purchaseHistory }; + return { matches, purchaseHistory, matchKind }; } /** Last order at every unique supplier. Latest purchase wins per @@ -536,11 +660,18 @@ function composeSummary(input: { * * `write: false` runs the whole algorithm without persisting — use * for the live preview path. + * + * `anchor` pins the stock match when the caller already knows which + * catalogue row the line refers to (the AI filled the line from a + * specific POS item). The anchored row is forced to the top of + * `stock_list` and its purchase history always feeds + * `supplier_history`. `match_kind` records how it resolved — + * `"stock_item"`, `"sku"`, or `"fuzzy"` when nothing anchored. */ export async function populateItemIntel( itemId: string, collection: "quote_items" | "order_items", - options: { write?: boolean } = {}, + options: { write?: boolean; anchor?: ItemIntelAnchor } = {}, ): Promise { const write = options.write !== false; const pb = await getAdminPocketBase(); @@ -554,7 +685,7 @@ export async function populateItemIntel( const generated_at = new Date().toISOString(); const [stockRaw, price_list, recent_quote, recent_order] = await Promise.all([ - buildStock(pb, target, supplierNameById), + buildStock(pb, target, supplierNameById, options.anchor), buildPriceList(pb, target), buildQuoteHistory(pb, target, supplierNameById), buildOrderHistory(pb, target, supplierNameById), @@ -591,6 +722,7 @@ export async function populateItemIntel( price_list, recent_quote, recent_order, + match_kind: stockRaw.matchKind, }; if (write) { diff --git a/src/lib/types.ts b/src/lib/types.ts index d299c14..6ffe0b0 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -2335,6 +2335,30 @@ export interface ItemHistoryResponse { // empty sections are empty arrays, never padded. // ───────────────────────────────────────────────────────────────────────────── +/** How the intel's primary stock match was found. + * + * "stock_item" — the caller pinned an exact `stock_items` row (PB id). + * "sku" — matched on SKU (exact, then canonical form). + * "fuzzy" — description-fuzzy candidate ranking (the default, + * used when no anchor was supplied or the anchor no + * longer resolves). + */ +export type ItemIntelMatchKind = "stock_item" | "sku" | "fuzzy"; + +/** Optional pin for the intel computation. + * + * When the caller already knows which catalogue row the line refers to + * (the AI filled the line from a specific POS item), pass it so the + * anchored row is ranked first in `stock_list` and its purchase history + * always feeds `supplier_history`. `stockItemId` wins over `sku`. + */ +export interface ItemIntelAnchor { + /** PB id of the `stock_items` row. */ + stockItemId?: string; + /** SKU to match on when no id is given. */ + sku?: string; +} + /** The target row that the intel is about (header in the popover). */ export interface ItemIntelTarget { id: string; @@ -2510,7 +2534,9 @@ export interface ItemIntel { item?: ItemIntelTarget; summary: string; generated_at: string; - /** Top-3 stock_items ranked by description-fuzzy score. Empty if none. */ + /** Top-3 stock_items ranked by description-fuzzy score. Empty if none. + * When the intel was computed with an anchor, the anchored row is + * forced to the top and the remaining slots hold fuzzy matches. */ stock_list: ItemIntelStockRow[]; /** Last order at every unique supplier we ever bought the item from. */ supplier_history: ItemIntelSupplierRow[]; @@ -2524,6 +2550,9 @@ export interface ItemIntel { * and trims the non-matches. See `ItemIntelVerified` for the * contract. Absent = not yet verified (raw fuzzy matches shown). */ verified?: ItemIntelVerified; + /** How the primary stock match was found. Absent on blobs written + * before anchoring existed — treat absent as "fuzzy". */ + match_kind?: ItemIntelMatchKind; } // ─────────────────────────────────────────────────────────────────────────────