The POS shortlist ranked by shared words only, so "BOLT HEX M16X60 GR8.8 ZP" put the M22x65, the M27x170 and the M20x160 all on 0.75 — they share "BOLT 8.8 ZP" — and the M16 the customer asked for never reached the AI. pos-match.ts is now the shared POS matcher: the word pull plus a size-targeted pull (name ~ "M16" || description ~ "M16"), ranked by the size comparison and then by word score. A line with no size information keeps today's word-only behaviour. - shortlistStockItems delegates to it, so the quote assistant gets the better list for free. - the intel popover's fuzzy stock section uses the same ranking, so the right size wins there too. - tokenizeIntel / informativeTokens / combinedScore moved to item-match-text.ts so the matcher and the intel service can share one implementation without an import cycle. PocketBase has no IN operator: every filter here is an OR-chain of ~ and = clauses, escaped with escapeForPbString, capped at 300 rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
762 lines
No EOL
30 KiB
TypeScript
762 lines
No EOL
30 KiB
TypeScript
/**
|
||
* item-intel-service — deterministic, description-fuzzy intel for any
|
||
* quote_item or order_item. Powers the ⓘ popover (Option A — replaces
|
||
* the older `ItemHistoryPopover` content).
|
||
*
|
||
* ## What it answers for one item
|
||
*
|
||
* - "Do we have it in stock?" → stock_list (fuzzy stock_items)
|
||
* - "Did we buy it before, and from whom?" → supplier_history
|
||
* (last order per unique
|
||
* supplier, via
|
||
* stock_purchase_items ↔
|
||
* stock_purchases)
|
||
* - "Is it on our price list?" → price_list (fuzzy price_list,
|
||
* ranked by overlapScore on
|
||
* the denormalised search_text)
|
||
* - "Have we quoted it before?" → recent_quote (fuzzy quote_items)
|
||
* - "Have we ordered it before?" → recent_order (fuzzy order_items)
|
||
*
|
||
* ## Matching strategy (description-fuzzy, no SKU gate)
|
||
*
|
||
* New items don't have a SKU. The previous SKU-first services
|
||
* (`getItemHistory`, `populateQuoteItemInsights`) returned empty for
|
||
* every section whenever no SKU match could be recovered via the
|
||
* description-fuzzy SKU recovery. This service runs the description
|
||
* fuzzy match against each section's collection directly, so brand
|
||
* new items with only a free-text description still get intel.
|
||
*
|
||
* The candidate pull for stock_items, quote_items, and order_items
|
||
* uses PB's `~` operator with an OR of the top informative tokens.
|
||
* In-app Jaccard + overlapScore then rank the candidates. Price list
|
||
* uses the same OR but on a multi-column candidate filter
|
||
* (description + search_text + sku + sku_normalised) since its
|
||
* `search_text` field is the denormalised token soup the Catalogue
|
||
* tab already relies on.
|
||
*
|
||
* ## Per-section limits (the popover must stay scannable)
|
||
*
|
||
* stock_list → 3 rows
|
||
* supplier_history → one row per unique supplier (latest purchase)
|
||
* price_list → 5 rows
|
||
* recent_quote → 2 rows
|
||
* recent_order → 2 rows
|
||
*
|
||
* Empty sections are `[]`, never padded.
|
||
*
|
||
* ## Stock display vs purchase-cap decoupling
|
||
*
|
||
* Stock list displays the top 3 by fuzzy score. But the supplier
|
||
* dedup needs purchase history from ALL stock_items that fuzzy-match
|
||
* (not just the top 3), otherwise legitimate suppliers we bought
|
||
* 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
|
||
* `order_items.ai_item_insights` directly. Idempotent — safe to
|
||
* re-run. The MCP `populate_insights` tool delegates here.
|
||
*/
|
||
|
||
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
||
import { escapeForPbString, normaliseSku } from "@/lib/server/price-list-match";
|
||
import {
|
||
combinedScore,
|
||
informativeTokens,
|
||
overlapScore,
|
||
tokenizeIntel,
|
||
} from "@/lib/item-match-text";
|
||
import { loadSuppliersById, shortlistPosItems } from "@/lib/server/pos-match";
|
||
import type {
|
||
ItemIntel,
|
||
ItemIntelAnchor,
|
||
ItemIntelMatchKind,
|
||
ItemIntelPosPick,
|
||
ItemIntelPriceListRow,
|
||
ItemIntelQuoteRow,
|
||
ItemIntelOrderRow,
|
||
ItemIntelStockRow,
|
||
ItemIntelSupplierRow,
|
||
ItemIntelTarget,
|
||
} from "@/lib/types";
|
||
import { ITEM_INTEL_LIMITS } from "@/lib/types";
|
||
|
||
export {
|
||
combinedScore,
|
||
informativeTokens,
|
||
tokenizeIntel,
|
||
} from "@/lib/item-match-text";
|
||
export { loadSuppliersById } from "@/lib/server/pos-match";
|
||
|
||
// ─── Pure scoring helpers ────────────────────────────────────────────
|
||
//
|
||
// `tokenizeIntel`, `informativeTokens` and `combinedScore` moved to
|
||
// `@/lib/item-match-text` so the POS matcher (`pos-match.ts`) and this
|
||
// service can share one implementation without importing each other.
|
||
// They are re-exported above for the callers that still import them
|
||
// from here.
|
||
|
||
/** Days between `iso` and `now`. Negative clamped to 0. */
|
||
function daysAgo(iso: string | null | undefined): number {
|
||
if (!iso) return 0;
|
||
const t = new Date(iso).getTime();
|
||
if (Number.isNaN(t)) return 0;
|
||
const diffMs = Date.now() - t;
|
||
return diffMs <= 0 ? 0 : Math.floor(diffMs / 86_400_000);
|
||
}
|
||
|
||
/** PocketBase `~` is case-insensitive substring match. OR'ing the top
|
||
* informative tokens keeps the candidate set broad while letting
|
||
* the in-app Jaccard/overlap rank with precision. */
|
||
function buildPbContainsOR(field: string, targetDesc: string, maxTokens = 4): string {
|
||
const toks = informativeTokens(targetDesc, maxTokens);
|
||
if (toks.length === 0) return "";
|
||
return toks.map((t) => `${field} ~ "${t}"`).join(" || ");
|
||
}
|
||
|
||
// ─── PB I/O helpers ───────────────────────────────────────────────────
|
||
|
||
type PbListResult<T> = { items: T[]; totalItems?: number };
|
||
|
||
async function safeGetList<T>(
|
||
pb: ReturnType<typeof Object> extends never ? never : Awaited<ReturnType<typeof getAdminPocketBase>>,
|
||
collection: string,
|
||
opts: { max?: number; filter?: string; sort?: string; fields?: string; expand?: string },
|
||
fallback: PbListResult<T> = { items: [] },
|
||
): Promise<PbListResult<T>> {
|
||
try {
|
||
return (await pb.collection(collection).getList(1, opts.max ?? 300, opts)) as PbListResult<T>;
|
||
} catch (err) {
|
||
const status = (err as { status?: number })?.status;
|
||
if (status === 400 || status === 404) {
|
||
console.warn(`[item-intel] ${collection} returned ${status} — degrading`);
|
||
return fallback;
|
||
}
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
// ─── 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; sku?: string },
|
||
supplierNameById: Map<string, string>,
|
||
anchor?: ItemIntelAnchor,
|
||
matchKindOverride?: ItemIntelMatchKind,
|
||
): Promise<{ matches: ItemIntelStockRow[]; purchaseHistory: Array<{
|
||
stock_item_id: string;
|
||
stock_item_sku: string;
|
||
stock_item_name: string;
|
||
supplier_id: string;
|
||
supplier_name: string;
|
||
cost_price: number;
|
||
quantity: number;
|
||
purchased_at: string;
|
||
days_ago: number;
|
||
purchase_number: string;
|
||
}>; matchKind: ItemIntelMatchKind }> {
|
||
const anchored = await resolveAnchorStockItem(pb, anchor);
|
||
const matchKind: ItemIntelMatchKind =
|
||
matchKindOverride ?? (anchored ? anchored.matchKind : "fuzzy");
|
||
|
||
// The fuzzy section uses the shared SIZE-AWARE shortlist, so the
|
||
// right-size row wins even without an AI pick. Before this, the
|
||
// ranking was by shared words only and every bolt tied on
|
||
// "BOLT 8.8 ZP".
|
||
const candidates = await shortlistPosItems(
|
||
pb,
|
||
{ description: target.description, sku: target.sku },
|
||
ITEM_INTEL_LIMITS.stock + PURCHASE_SCAN_CAP,
|
||
supplierNameById,
|
||
);
|
||
|
||
// The anchored row is prepended separately — drop it from the fuzzy
|
||
// list so it can't appear twice.
|
||
const rankedAll: Array<{
|
||
id: string; name: string; sku: string; description: string;
|
||
current_qty?: number; min_qty?: number | null;
|
||
cost_price: number; selling_price: number; supplier: string; score: number;
|
||
}> = candidates
|
||
.filter((c) => !anchored || c.id !== anchored.row.id)
|
||
.map((c) => ({
|
||
id: c.id,
|
||
name: c.name,
|
||
sku: c.sku,
|
||
description: c.description,
|
||
current_qty: c.current_qty,
|
||
min_qty: c.min_qty ?? null,
|
||
cost_price: c.cost_price,
|
||
selling_price: c.selling_price,
|
||
supplier: c.supplier_id,
|
||
score: c.score,
|
||
}));
|
||
|
||
// Anchored row first (score 1 — it IS the match), then the best
|
||
// fuzzy candidates in the remaining slots.
|
||
const forDisplay = anchored
|
||
? [{ ...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
|
||
? [{ ...anchored.row, score: 1 }, ...rankedAll].slice(0, PURCHASE_SCAN_CAP)
|
||
: rankedAll.slice(0, PURCHASE_SCAN_CAP);
|
||
|
||
const matches: ItemIntelStockRow[] = forDisplay.map((si) => {
|
||
const current_qty = si.current_qty || 0;
|
||
const min_qty = si.min_qty ?? null;
|
||
let status: ItemIntelStockRow["status"] = "out";
|
||
if (current_qty > 0) status = min_qty != null && current_qty <= min_qty ? "low" : "in_stock";
|
||
return {
|
||
stock_item_id: si.id,
|
||
score: +si.score.toFixed(3),
|
||
name: si.name || "",
|
||
sku: si.sku || "",
|
||
description: si.description || "",
|
||
qty: current_qty,
|
||
min_qty,
|
||
cost_price: si.cost_price || 0,
|
||
selling_price: si.selling_price || 0,
|
||
supplier_id: si.supplier || "",
|
||
supplier_name: supplierNameById.get(si.supplier) || si.supplier || "",
|
||
status,
|
||
};
|
||
});
|
||
|
||
const purchaseHistory: Array<{
|
||
stock_item_id: string; stock_item_sku: string; stock_item_name: string;
|
||
supplier_id: string; supplier_name: string;
|
||
cost_price: number; quantity: number; purchased_at: string; days_ago: number;
|
||
purchase_number: string;
|
||
}> = (await Promise.all(
|
||
forPurchases.map(async (si) => {
|
||
try {
|
||
const spiRows = await pb
|
||
.collection("stock_purchase_items")
|
||
.getFullList<{
|
||
id: string; cost_price: number; quantity: number;
|
||
expand?: { purchase?: { id: string; purchase_number: string; supplier: string; created: string } };
|
||
}>({
|
||
filter: `stock_item = "${si.id}"`,
|
||
expand: "purchase",
|
||
fields: "id,cost_price,quantity,expand.purchase.id,expand.purchase.purchase_number,expand.purchase.supplier,expand.purchase.created",
|
||
});
|
||
const out: typeof purchaseHistory = [];
|
||
for (const spi of spiRows) {
|
||
const p = spi.expand?.purchase;
|
||
if (!p) continue;
|
||
out.push({
|
||
stock_item_id: si.id,
|
||
stock_item_sku: si.sku || "",
|
||
stock_item_name: si.name || "",
|
||
supplier_id: p.supplier || "",
|
||
supplier_name: supplierNameById.get(p.supplier) || p.supplier || "",
|
||
cost_price: spi.cost_price || 0,
|
||
quantity: spi.quantity || 0,
|
||
purchased_at: p.created,
|
||
days_ago: daysAgo(p.created),
|
||
purchase_number: p.purchase_number || "",
|
||
});
|
||
}
|
||
return out;
|
||
} catch (err) {
|
||
console.warn(`[item-intel] spi join failed for ${si.id}:`, (err as { status?: number })?.status);
|
||
return [];
|
||
}
|
||
}),
|
||
)).flat();
|
||
|
||
purchaseHistory.sort((a, b) => (b.purchased_at || "").localeCompare(a.purchased_at || ""));
|
||
return { matches, purchaseHistory, matchKind };
|
||
}
|
||
|
||
/** Last order at every unique supplier. Latest purchase wins per
|
||
* supplier_id; sort by purchased_at desc. The stock_item_* fields
|
||
* are taken from the most recent purchase row for that supplier —
|
||
* earlier rows for the same supplier carry older stock_items. */
|
||
function buildLastSupplierPrice(purchaseHistory: Array<{
|
||
supplier_id: string; supplier_name: string; cost_price: number;
|
||
purchased_at: string; days_ago: number; purchase_number: string;
|
||
stock_item_id: string; stock_item_sku: string; stock_item_name: string;
|
||
}>): ItemIntelSupplierRow[] {
|
||
const bySupplier = new Map<string, {
|
||
supplier_id: string; supplier_name: string; last_cost: number;
|
||
last_bought_at: string; days_ago: number; last_purchase_number: string;
|
||
times_bought: number;
|
||
stock_item_id: string; stock_item_sku: string; stock_item_name: string;
|
||
}>();
|
||
for (const r of purchaseHistory) {
|
||
if (!r.supplier_id) continue;
|
||
const prev = bySupplier.get(r.supplier_id);
|
||
if (!prev) {
|
||
bySupplier.set(r.supplier_id, {
|
||
supplier_id: r.supplier_id,
|
||
supplier_name: r.supplier_name,
|
||
last_cost: r.cost_price,
|
||
last_bought_at: r.purchased_at,
|
||
days_ago: r.days_ago,
|
||
last_purchase_number: r.purchase_number,
|
||
times_bought: 1,
|
||
stock_item_id: r.stock_item_id,
|
||
stock_item_sku: r.stock_item_sku,
|
||
stock_item_name: r.stock_item_name,
|
||
});
|
||
} else {
|
||
prev.times_bought += 1;
|
||
if (new Date(r.purchased_at).getTime() > new Date(prev.last_bought_at).getTime()) {
|
||
prev.last_cost = r.cost_price;
|
||
prev.last_bought_at = r.purchased_at;
|
||
prev.days_ago = r.days_ago;
|
||
prev.last_purchase_number = r.purchase_number;
|
||
prev.stock_item_id = r.stock_item_id;
|
||
prev.stock_item_sku = r.stock_item_sku;
|
||
prev.stock_item_name = r.stock_item_name;
|
||
}
|
||
}
|
||
}
|
||
return [...bySupplier.values()].sort((a, b) =>
|
||
(b.last_bought_at || "").localeCompare(a.last_bought_at || ""),
|
||
);
|
||
}
|
||
|
||
async function buildPriceList(
|
||
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
||
target: { description: string },
|
||
): Promise<ItemIntelPriceListRow[]> {
|
||
const toks = informativeTokens(target.description, 4);
|
||
if (toks.length === 0) return [];
|
||
|
||
// OR informative tokens across every text column the repo already
|
||
// keeps searchable. `search_text` is the denormalised token soup
|
||
// built by `buildPriceListSearchText` (src/lib/price-list-search.ts)
|
||
// — it's how the Catalogue tab does fuzzy hits and the cheapest
|
||
// way to catch items where the catalogue wording diverges from the
|
||
// operator's wording (e.g. "SAE 50 - 20L" → row "SAE 50 SF/CF4 - 20 LT").
|
||
const cols = ["description", "search_text", "sku", "sku_normalised"];
|
||
const clauses = cols.flatMap((c) => toks.map((t) => `${c} ~ "${t}"`));
|
||
const filter = `(${clauses.join(" || ")})`;
|
||
|
||
const candRows = await safeGetList<{
|
||
id: string; sku: string; sku_normalised: string; description: string;
|
||
brand: string; category: string; cost_price: number; selling_price: number;
|
||
supplier: string; source_file: string; updated: string; search_text: string;
|
||
}>(pb, "price_list", {
|
||
filter,
|
||
fields: "id,sku,sku_normalised,description,brand,category,cost_price,selling_price,supplier,source_file,updated,search_text",
|
||
max: 300,
|
||
});
|
||
|
||
// Rank with overlapScore against the concatenated text columns —
|
||
// for short queries against a rich denormalised blob, overlap is
|
||
// the right measure (it doesn't penalise extra tokens the way
|
||
// Jaccard does).
|
||
const targetTokens = tokenizeIntel(target.description);
|
||
const ranked: Array<{ row: typeof candRows.items[number]; score: number }> = [];
|
||
for (const r of candRows.items) {
|
||
const haystack = [r.description, r.search_text, r.sku, r.sku_normalised, r.brand, r.category, r.supplier, r.source_file]
|
||
.filter(Boolean).join(" ");
|
||
const score = overlapScore(targetTokens, tokenizeIntel(haystack));
|
||
if (score >= 0.6) ranked.push({ row: r, score });
|
||
}
|
||
ranked.sort((a, b) => b.score - a.score);
|
||
return ranked.slice(0, ITEM_INTEL_LIMITS.priceList).map(({ row: r, score }) => ({
|
||
score: +score.toFixed(3),
|
||
sku: r.sku || "",
|
||
description: r.description || "",
|
||
brand: r.brand || "",
|
||
category: r.category || "",
|
||
cost_price: r.cost_price || 0,
|
||
selling_price: r.selling_price || 0,
|
||
supplier: r.supplier || "",
|
||
source_file: r.source_file || "",
|
||
updated: r.updated,
|
||
}));
|
||
}
|
||
|
||
async function buildQuoteHistory(
|
||
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
||
target: { id: string; description: string; quoteId?: string },
|
||
supplierNameById: Map<string, string>,
|
||
): Promise<ItemIntelQuoteRow[]> {
|
||
const filter = buildPbContainsOR("description", target.description);
|
||
if (!filter) return [];
|
||
const candRows = await safeGetList<{
|
||
id: string; description: string; cost_price: number; selling_price: number;
|
||
quantity: number; created: string; quote: string; supplier: string;
|
||
expand?: {
|
||
quote?: {
|
||
quote_number: string; status: string;
|
||
customer_name?: string; company?: string;
|
||
expand?: { company?: { id: string; company?: string } };
|
||
};
|
||
};
|
||
}>(pb, "quote_items", {
|
||
// Never the line itself, nor another line of the same quote.
|
||
filter: [
|
||
`id != "${escapeForPbString(target.id)}"`,
|
||
target.quoteId ? `quote != "${escapeForPbString(target.quoteId)}"` : "",
|
||
`(${filter})`,
|
||
].filter(Boolean).join(" && "),
|
||
expand: "quote.company",
|
||
fields: "id,description,cost_price,selling_price,quantity,created,quote,supplier,expand.quote.id,expand.quote.quote_number,expand.quote.status,expand.quote.company,expand.quote.expand.company.id,expand.quote.expand.company.company",
|
||
max: 300,
|
||
sort: "-created",
|
||
});
|
||
const targetTokens = tokenizeIntel(target.description);
|
||
const ranked = candRows.items
|
||
.map((q) => ({ q, score: combinedScore(targetTokens, tokenizeIntel(q.description || "")) }))
|
||
.filter((r) => r.score >= 0.5)
|
||
.sort((a, b) => b.score - a.score)
|
||
.slice(0, ITEM_INTEL_LIMITS.recentQuote);
|
||
return ranked.map(({ q, score }) => {
|
||
// Resolve the customer/company FKs to human-readable names so the
|
||
// popover doesn't show opaque ids like `vvns7ji6qeao9xk`. Prefer
|
||
// the expanded company name (from the `companies` collection);
|
||
// fall back to the customer name field on the quote, then the raw
|
||
// FK id, then an em-dash.
|
||
const companyName = q.expand?.quote?.expand?.company?.company || "";
|
||
const customerName = q.expand?.quote?.customer_name || "";
|
||
return {
|
||
score: +score.toFixed(3),
|
||
quote_item_id: q.id,
|
||
quote_id: q.quote,
|
||
quote_number: q.expand?.quote?.quote_number || "",
|
||
quote_status: q.expand?.quote?.status || "",
|
||
customer_name: companyName || customerName || q.expand?.quote?.company || "",
|
||
company: companyName || q.expand?.quote?.company || "",
|
||
description: q.description || "",
|
||
cost_price: q.cost_price || 0,
|
||
selling_price: q.selling_price || 0,
|
||
quantity: q.quantity || 0,
|
||
quoted_at: q.created,
|
||
days_ago: daysAgo(q.created),
|
||
supplier_id: q.supplier || "",
|
||
supplier_name: q.supplier
|
||
? supplierNameById.get(q.supplier) || ""
|
||
: "",
|
||
};
|
||
});
|
||
}
|
||
|
||
async function buildOrderHistory(
|
||
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
||
target: { id: string; description: string; orderId?: string },
|
||
supplierNameById: Map<string, string>,
|
||
): Promise<ItemIntelOrderRow[]> {
|
||
const filter = buildPbContainsOR("description", target.description);
|
||
if (!filter) return [];
|
||
const candRows = await safeGetList<{
|
||
id: string; description: string; cost_price: number; selling_price: number;
|
||
quantity: number; item_status: string; supplier: string; updated: string; order: string;
|
||
expand?: { order?: { order_number: string; company?: string } };
|
||
}>(pb, "order_items", {
|
||
// Never the line itself, nor another line of the same order.
|
||
filter: [
|
||
`id != "${escapeForPbString(target.id)}"`,
|
||
target.orderId ? `order != "${escapeForPbString(target.orderId)}"` : "",
|
||
`(${filter})`,
|
||
].filter(Boolean).join(" && "),
|
||
expand: "order",
|
||
fields: "id,description,cost_price,selling_price,quantity,item_status,supplier,updated,order,expand.order.id,expand.order.order_number,expand.order.company",
|
||
max: 300,
|
||
sort: "-updated",
|
||
});
|
||
const targetTokens = tokenizeIntel(target.description);
|
||
const ranked = candRows.items
|
||
.map((o) => ({ o, score: combinedScore(targetTokens, tokenizeIntel(o.description || "")) }))
|
||
.filter((r) => r.score >= 0.5)
|
||
.sort((a, b) => b.score - a.score)
|
||
.slice(0, ITEM_INTEL_LIMITS.recentOrder);
|
||
return ranked.map(({ o, score }) => ({
|
||
score: +score.toFixed(3),
|
||
order_item_id: o.id,
|
||
order_id: o.order,
|
||
order_number: o.expand?.order?.order_number || "",
|
||
description: o.description || "",
|
||
cost_price: o.cost_price || 0,
|
||
selling_price: o.selling_price || 0,
|
||
quantity: o.quantity || 0,
|
||
item_status: o.item_status || "",
|
||
supplier_id: o.supplier || "",
|
||
supplier_name: supplierNameById.get(o.supplier) || o.supplier || "",
|
||
updated: o.updated,
|
||
days_ago: daysAgo(o.updated),
|
||
}));
|
||
}
|
||
|
||
// ─── Summary ─────────────────────────────────────────────────────────
|
||
|
||
function composeSummary(input: {
|
||
matches: ItemIntelStockRow[];
|
||
supplierHistory: ItemIntelSupplierRow[];
|
||
recent_quote: ItemIntelQuoteRow[];
|
||
recent_order: ItemIntelOrderRow[];
|
||
price_list: ItemIntelPriceListRow[];
|
||
}): string {
|
||
const bits: string[] = [];
|
||
const topStock = input.matches[0];
|
||
if (topStock && topStock.qty > 0) {
|
||
bits.push(`in stock ${topStock.qty} (${topStock.supplier_name || "—"}, cost R${topStock.cost_price.toFixed(2)})`);
|
||
} else if (topStock) {
|
||
bits.push(`out of stock (last supplier ${topStock.supplier_name || "—"})`);
|
||
}
|
||
if (input.supplierHistory.length === 1) {
|
||
const s = input.supplierHistory[0];
|
||
bits.push(`bought from ${s.supplier_name} ${s.days_ago}d ago @ R${s.last_cost.toFixed(2)} (×${s.times_bought})`);
|
||
} else if (input.supplierHistory.length > 1) {
|
||
bits.push(`bought from ${input.supplierHistory.length} suppliers — top: ${input.supplierHistory[0].supplier_name} R${input.supplierHistory[0].last_cost.toFixed(2)} ${input.supplierHistory[0].days_ago}d ago`);
|
||
}
|
||
const lq = input.recent_quote[0];
|
||
if (lq) bits.push(`last quoted R${lq.selling_price.toFixed(2)} ${lq.days_ago}d ago (${lq.quote_number})`);
|
||
const lo = input.recent_order[0];
|
||
if (lo) bits.push(`last order ${lo.days_ago}d ago (${lo.order_number}, ${lo.item_status})`);
|
||
if (input.price_list.length > 0) bits.push(`on price list (${input.price_list.length})`);
|
||
return bits.join(" · ") || "No history yet for this item.";
|
||
}
|
||
|
||
// ─── Public entry points ─────────────────────────────────────────────
|
||
|
||
/**
|
||
* Compute and write the Item Intel payload for one quote_item or
|
||
* order_item. Collection is the PB collection name (e.g.
|
||
* `"quote_items"`). The intel is written to `<collection>.ai_item_insights`.
|
||
*
|
||
* `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; anchor?: ItemIntelAnchor; posPick?: ItemIntelPosPick } = {},
|
||
): Promise<ItemIntel> {
|
||
const write = options.write !== false;
|
||
const pb = await getAdminPocketBase();
|
||
|
||
// The parent relation is `quote` on quote_items and `order` on
|
||
// order_items; it keeps the line's own quote/order out of its history.
|
||
const parentField = collection === "quote_items" ? "quote" : "order";
|
||
const targetRow = await pb.collection(collection).getOne<{
|
||
id: string; sku: string; description: string; quantity: number;
|
||
supplier: string; quote?: string; order?: string;
|
||
}>(itemId, { fields: `id,sku,description,quantity,supplier,${parentField}` });
|
||
const target = {
|
||
...targetRow,
|
||
quoteId: collection === "quote_items" ? targetRow.quote || "" : "",
|
||
orderId: collection === "order_items" ? targetRow.order || "" : "",
|
||
};
|
||
|
||
const supplierNameById = await loadSuppliersById(pb);
|
||
const generated_at = new Date().toISOString();
|
||
|
||
// An AI pick pins the stock row it chose. It outranks any other
|
||
// anchor, and it is what `match_kind: "pos_pick"` reports.
|
||
const posPick = options.posPick;
|
||
const anchor: ItemIntelAnchor | undefined = posPick?.stock_item_id
|
||
? { stockItemId: posPick.stock_item_id }
|
||
: options.anchor;
|
||
const matchKindOverride: ItemIntelMatchKind | undefined = posPick?.stock_item_id
|
||
? "pos_pick"
|
||
: undefined;
|
||
|
||
const [stockRaw, price_list, recent_quote, recent_order] = await Promise.all([
|
||
buildStock(pb, target, supplierNameById, anchor, matchKindOverride),
|
||
buildPriceList(pb, target),
|
||
buildQuoteHistory(pb, target, supplierNameById),
|
||
buildOrderHistory(pb, target, supplierNameById),
|
||
]);
|
||
|
||
const supplier_history = buildLastSupplierPrice(stockRaw.purchaseHistory);
|
||
|
||
const item: ItemIntelTarget = {
|
||
id: itemId,
|
||
collection,
|
||
description: target.description || "",
|
||
sku: target.sku || "",
|
||
quantity: target.quantity || 0,
|
||
supplier: target.supplier || "",
|
||
supplier_name: target.supplier
|
||
? supplierNameById.get(target.supplier) || ""
|
||
: "",
|
||
};
|
||
|
||
const summary = composeSummary({
|
||
matches: stockRaw.matches,
|
||
supplierHistory: supplier_history,
|
||
recent_quote,
|
||
recent_order,
|
||
price_list,
|
||
});
|
||
|
||
const intel: ItemIntel = {
|
||
item,
|
||
summary,
|
||
generated_at,
|
||
stock_list: stockRaw.matches,
|
||
supplier_history,
|
||
price_list,
|
||
recent_quote,
|
||
recent_order,
|
||
match_kind: stockRaw.matchKind,
|
||
...(posPick ? { pos_pick: posPick } : {}),
|
||
};
|
||
|
||
if (write) {
|
||
await pb.collection(collection).update(itemId, { ai_item_insights: intel });
|
||
}
|
||
|
||
return intel;
|
||
}
|
||
|
||
/**
|
||
* Populate Item Intel for every active line on a quote or order.
|
||
* Parallel-safe within one parent; the caller controls cross-parent
|
||
* batching to avoid hammering PB.
|
||
*/
|
||
export async function populateIntelForParent(
|
||
parentId: string,
|
||
collection: "quote_items" | "order_items",
|
||
parentCollection: "quotes" | "orders",
|
||
options: { write?: boolean } = {},
|
||
): Promise<{ parent_id: string; total: number; populated: number; no_match: number }> {
|
||
const pb = await getAdminPocketBase();
|
||
const parentField = parentCollection === "quotes" ? "quote" : "order";
|
||
const items = await pb.collection(collection).getFullList<{ id: string }>({
|
||
filter: `${parentField} = "${parentId}" && (moved_to_quote = null || moved_to_quote = "")`,
|
||
fields: "id",
|
||
});
|
||
const results = await Promise.all(
|
||
items.map((it) => populateItemIntel(it.id, collection, options).then(
|
||
(intel) => ({
|
||
id: it.id,
|
||
found: intel.stock_list.length > 0 || intel.price_list.length > 0 ||
|
||
intel.recent_quote.length > 0 || intel.recent_order.length > 0 ||
|
||
intel.supplier_history.length > 0,
|
||
}),
|
||
() => ({ id: it.id, found: false }),
|
||
)),
|
||
);
|
||
return {
|
||
parent_id: parentId,
|
||
total: items.length,
|
||
populated: results.filter((r) => r.found).length,
|
||
no_match: results.filter((r) => !r.found).length,
|
||
};
|
||
} |