feat(intel): anchor item intel to the exact POS item the AI picked
populateItemIntel now accepts an optional anchor ({ stockItemId?, sku? }).
When it resolves, the anchored stock_items row is forced to the top of
stock_list and its purchase history always feeds supplier_history, so a
line filled by the AI assistant points at the catalogue row it actually
came from instead of whatever the description-fuzzy ranking happened to
surface.
match_kind records how it resolved: "stock_item" (found by PB id),
"sku" (found by SKU — exact, then canonical form via the SKU's
alphabetic stem) or "fuzzy" when nothing anchored or the anchor no
longer resolves. The field is optional so blobs written before this
change stay readable.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
793c9ea215
commit
110332402e
3 changed files with 571 additions and 19 deletions
391
src/lib/server/item-intel-service.test.ts
Normal file
391
src/lib/server/item-intel-service.test.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
|
||||
// ─── 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<string, Row[]> = {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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<ReturnType<typeof getAdminPo
|
|||
|
||||
// ─── The 5 sections ──────────────────────────────────────────────────
|
||||
|
||||
/** Columns we read from `stock_items` for the stock list. */
|
||||
const STOCK_ITEM_FIELDS =
|
||||
"id,sku,name,description,cost_price,selling_price,current_qty,min_qty,supplier";
|
||||
|
||||
type StockItemRow = {
|
||||
id: string; sku: string; name: string; description: string;
|
||||
cost_price: number; selling_price: number; current_qty: number;
|
||||
min_qty: number | null; supplier: string;
|
||||
};
|
||||
|
||||
/** How many stock_items we walk when collecting purchase history.
|
||||
* Wider than the 3 we display — the supplier dedup needs every
|
||||
* plausible row, not just the visible ones. */
|
||||
const PURCHASE_SCAN_CAP = 10;
|
||||
|
||||
/** Fetch one `stock_items` row by its PB id.
|
||||
*
|
||||
* Returns null when the row is gone (deleted catalogue entry) so the
|
||||
* caller can fall back to the fuzzy path instead of failing the whole
|
||||
* intel computation. */
|
||||
async function fetchStockItemById(
|
||||
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
||||
id: string,
|
||||
): Promise<StockItemRow | null> {
|
||||
if (!id) return null;
|
||||
try {
|
||||
const row = await pb.collection("stock_items").getOne<StockItemRow>(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<ReturnType<typeof getAdminPocketBase>>,
|
||||
sku: string,
|
||||
): Promise<StockItemRow | null> {
|
||||
const raw = (sku || "").trim();
|
||||
if (!raw) return null;
|
||||
|
||||
const exact = await safeGetList<StockItemRow>(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<StockItemRow>(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<ReturnType<typeof getAdminPocketBase>>,
|
||||
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<ReturnType<typeof getAdminPocketBase>>,
|
||||
target: { id: string; description: string },
|
||||
supplierNameById: Map<string, string>,
|
||||
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<StockItemRow> = { items: [] };
|
||||
if (filter) {
|
||||
candRows = await safeGetList<StockItemRow>(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<ItemIntel> {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Reference in a new issue