import { getAdminPocketBase } from "./pocketbase-admin"; import { escapeForPbString, normaliseSku } from "./price-list-match"; /** * `price_list.add_items` — bulk upsert parsed line items into the * `price_list` catalogue from the MCP server. * * Why this exists * ─────────────── * The n8n workflow has historically been the only path for adding * items to the catalogue: operator uploads a PDF via `/pricelist`, * n8n picks up the `raw_price_list` row, runs AI extraction, writes * structured rows to `price_list`. That flow is async, human-in-the- * loop, and gates on n8n being healthy. * * This service takes the **already-parsed** line items straight from * the MCP agent (which has its own native vision capability) and * commits them in one round-trip. The agent does the OCR; this * service does the upsert. No LLM work in the MCP server. * * ## What it does per item * * 1. Resolve the supplier — `price_list.supplier` is a free-text * field (NOT a relation to `suppliers`), so we just write the * agent's `supplier_name` verbatim. If the operator wants a * relation-style FK later, that's a separate schema change. * 2. Normalise the SKU — same canonical form `price-list-match.ts` * uses (`uppercase`, strip non-alphanumeric). Catches * `JEITD-06` vs `JEITD06` on dedupe. * 3. Compute `search_text` — lowercase, space-separated concat of * every searchable field (sku, description, brand, supplier, * category, subcategory, specs synonyms). Same shape as the * n8n workflow produces. * 4. Upsert — if a row with `(sku, supplier)` already exists, * patch `cost_price`, `description`, `selling_price`, `unit`, * `category`, `subcategory`, `brand`, `markup`, `stock_qty`, * `search_text`, `sku_normalised`. Else insert. * * ## Upsert policy * * cost_price OVERWRITES. The agent just parsed the latest price * from a fresh document — that's the new truth. The previous * price is preserved in the row's `updated` timestamp (the buyer * can diff in the Catalogue tab) and (if `source_file_id` is * set) the source PDF is still in PB. * * Skipped items get an explicit reason so the agent can report * them back to the operator (missing supplier name, empty SKU, * malformed cost price, etc.) — never silently dropped. * * ## Bulk size * * 500 items per call. Larger lists should be chunked by the * agent. Rejecting with a clear error is cheaper than letting a * 5,000-line sheet hit PB in one transaction. */ export type AddPriceListItemInput = { /** Raw supplier name as printed on the source document. Stored * verbatim on `price_list.supplier` (free-text field). */ supplier_name: string; /** SKU exactly as printed. Empty/whitespace-only items are * rejected with `skipped[].reason = "missing_sku"`. */ sku: string; /** Human-readable description of the line item. */ description: string; /** What the supplier charges (R). 0 or negative is rejected. */ cost_price: number; /** Markup-based selling price (R). Optional — auto-computed from * cost_price + markup if not supplied. */ selling_price?: number; /** Markup percentage (e.g. 25 = 25%). Optional. */ markup?: number; /** Unit string ("EA", "BOX OF 12", "L", etc). Optional. */ unit?: string; /** Optional categorisation. */ category?: string; subcategory?: string; brand?: string; /** Current on-hand qty for the catalogue row. Optional; default * 0. The catalogue's stock_qty is a denormalised snapshot — the * live `current_qty` lives on `stock_items`. */ stock_qty?: number; }; export type AddPriceListItemsInput = { /** One call's worth of line items. Hard cap 500. */ items: AddPriceListItemInput[]; /** * Optional FK to `raw_price_list.id` so the new rows can be * traced back to the source document. Pass when the agent * already created a `raw_price_list` row for this upload; * leave undefined for ad-hoc / paste-into-chat flows. */ source_file_id?: string; /** * Optional human-readable label for the source document * ("Q-AFRISAN-2026-08", "AFRIQ-INA64721", etc.). Stored on each * new row's `source_file` text field so the Catalogue tab can * show "extracted from: …" without a join. */ source_label?: string; }; export type AddPriceListItemsOk = { ok: true; added: number; updated: number; /** PB ids of the upserted rows (added + updated). */ ids: string[]; /** Items the service refused to commit, with the reason. */ skipped: Array<{ index: number; sku: string; reason: string }>; /** * Stock-item sync problems encountered after the price_list write * landed. The price_list write itself always succeeds (or lands * in `skipped`); the stock_items patch is a best-effort step * that fires only when an `addPriceListItems` row matches a * `stock_items` row by `sku_normalised` AND that stock row's * `selling_price` is zero. Sync failures are collected here so a * single broken stock row can never poison a 500-item batch. * * Empty when no sync attempt was needed (no matching stock_item) * or every sync attempt succeeded. */ warnings?: Array<{ /** The sku whose sync attempt failed. */ sku: string; /** The stock_items row id the sync was targeting. */ stock_item_id: string; /** Short failure reason (PB error message). */ reason: string; }>; }; export type AddPriceListItemsErr = { ok: false; reason: "no_items" | "too_many_items" | "pb_unavailable"; message: string; }; export type AddPriceListItemsResult = | AddPriceListItemsOk | AddPriceListItemsErr; export const MAX_ADD_ITEMS = 500; /** * Build the `search_text` blob for a `price_list` row. * * Lowercased, space-separated tokens. Mirrors what the n8n * extraction workflow produces (`src/app/(app)/pricelist/...`) so * the catalogue search box hits the same denormalised column. * * Exported for unit tests so we can verify the token ordering + * lowercasing without spinning up PB. */ export function buildSearchText(input: { sku: string; description: string; brand?: string; supplier: string; category?: string; subcategory?: string; specifications?: { synonyms?: string[] }; }): string { const parts: string[] = []; if (input.sku) parts.push(input.sku); if (input.description) parts.push(input.description); if (input.brand) parts.push(input.brand); if (input.supplier) parts.push(input.supplier); if (input.category) parts.push(input.category); if (input.subcategory) parts.push(input.subcategory); const synonyms = input.specifications?.synonyms; if (Array.isArray(synonyms)) { for (const syn of synonyms) { if (typeof syn === "string" && syn) parts.push(syn); } } return parts.join(" ").toLowerCase().trim(); } /** * Compute the default selling price when the agent didn't supply * one. markup is a percentage (e.g. 25 for 25%); cost_price is the * supplier's price in R. Returns rounded-to-cents. * * If markup is missing or non-positive, returns cost_price (i.e. * zero markup). The Catalogue tab still shows the cost as * "selling_price" until the operator overwrites it. */ export function computeSellingPrice( costPrice: number, markupPercent: number | undefined, ): number { if (typeof markupPercent !== "number" || !Number.isFinite(markupPercent) || markupPercent <= 0) { return Math.max(0, Math.round(costPrice * 100) / 100); } const multiplier = 1 + markupPercent / 100; return Math.max(0, Math.round(costPrice * multiplier * 100) / 100); } /** * After a successful `price_list` upsert, mirror the row's * selling_price + markup onto the matching `stock_items` row so * the till (which reads `stock_items.selling_price`) sees the new * price. The price_list catalogue is the source of truth for * items that have *no* shelf price; the POS cashier or catalogue * patch is the source once a price has been set — so we never * overwrite a non-zero `stock_items.selling_price`. * * Sync rules: * - Match by `sku_normalised` on `stock_items` (single row only; * if multiple match, do nothing — ambiguous). * - Match only when the stock row's `selling_price` is 0/null AND * the price_list row has a positive `selling_price` to seed. * - If `stock_items.markup` is 0/null AND `price_list.markup` is * positive, also patch `markup`. * - Sync failures are returned to the caller, never thrown. A * broken sync must not poison a 500-item batch. * * Returns `null` when no sync was needed (no match, already priced, * or missing selling_price on the price_list row); otherwise * returns `{ stock_item_id, warnings? }` describing the outcome. */ async function syncPriceListToStockItem( pb: Awaited>, args: { sku: string; sellingPrice: number | undefined; markup: number | undefined; }, ): Promise< | { stock_item_id: string; warning?: { sku: string; stock_item_id: string; reason: string } } | null > { // No point syncing if the price_list row doesn't carry a price. if ( typeof args.sellingPrice !== "number" || !Number.isFinite(args.sellingPrice) || args.sellingPrice <= 0 ) { return null; } const skuNormalised = normaliseSku(args.sku); if (!skuNormalised) return null; // Look up the stock row by canonical SKU. We don't auto-create // from price_list — supplier catalogues carry thousands of SKUs // that have never been stocked. let stockItems: Array<{ id: string; selling_price?: number | null; markup?: number | null }>; try { stockItems = ( await pb.collection("stock_items").getList<{ id: string; selling_price?: number | null; markup?: number | null; }>(1, 2, { filter: `sku_normalised = "${escapeForPbString(skuNormalised)}"`, fields: "id,selling_price,markup", }) ).items; } catch (err) { const message = err instanceof Error ? err.message : String(err); return { stock_item_id: "", warning: { sku: args.sku, stock_item_id: "", reason: `lookup_failed: ${message}` }, }; } // Ambiguous match → skip without error. The price_list still // landed; the operator can resolve the dupes in the catalogue. if (stockItems.length !== 1) return null; const stockRow = stockItems[0]; const stockSellingPrice = Number(stockRow.selling_price ?? 0); const stockMarkup = Number(stockRow.markup ?? 0); const patch: { selling_price?: number; markup?: number } = {}; // Idempotency: never overwrite a non-zero stock shelf price. if (!(stockSellingPrice > 0)) { patch.selling_price = args.sellingPrice; } if ( !(stockMarkup > 0) && typeof args.markup === "number" && Number.isFinite(args.markup) && args.markup > 0 ) { patch.markup = args.markup; } if (Object.keys(patch).length === 0) return null; try { await pb.collection("stock_items").update(stockRow.id, patch); return { stock_item_id: stockRow.id }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return { stock_item_id: stockRow.id, warning: { sku: args.sku, stock_item_id: stockRow.id, reason: message, }, }; } } export async function addPriceListItems( input: AddPriceListItemsInput, ): Promise { const items = Array.isArray(input.items) ? input.items : []; if (items.length === 0) { return { ok: false, reason: "no_items", message: "No items supplied — pass at least one item in `items[]`.", }; } if (items.length > MAX_ADD_ITEMS) { return { ok: false, reason: "too_many_items", message: `Too many items in one call: ${items.length} (max ${MAX_ADD_ITEMS}). Chunk the list into batches of ${MAX_ADD_ITEMS} or fewer.`, }; } let pb: Awaited>; try { pb = await getAdminPocketBase(); } catch { return { ok: false, reason: "pb_unavailable", message: "PB admin unavailable — check PB_ADMIN_EMAIL/PASSWORD env vars.", }; } const skipped: AddPriceListItemsOk["skipped"] = []; const ids: string[] = []; // Best-effort stock_items sync problems. Always empty when no // sync was needed (no matching stock_item) or every sync // succeeded. Populated when syncPriceListToStockItem returned // a `warning` (lookup failure or PATCH rejection). const syncWarnings: NonNullable = []; for (let index = 0; index < items.length; index += 1) { const raw = items[index]; const sku = (raw.sku ?? "").trim(); const supplier = (raw.supplier_name ?? "").trim(); const description = (raw.description ?? "").trim(); if (!sku) { skipped.push({ index, sku, reason: "missing_sku" }); continue; } if (!supplier) { skipped.push({ index, sku, reason: "missing_supplier" }); continue; } if ( typeof raw.cost_price !== "number" || !Number.isFinite(raw.cost_price) || raw.cost_price <= 0 ) { skipped.push({ index, sku, reason: "invalid_cost_price" }); continue; } const skuNormalised = normaliseSku(sku); const searchText = buildSearchText({ sku, description, brand: raw.brand?.trim(), supplier, category: raw.category?.trim(), subcategory: raw.subcategory?.trim(), }); const sellingPrice = typeof raw.selling_price === "number" && Number.isFinite(raw.selling_price) && raw.selling_price > 0 ? raw.selling_price : computeSellingPrice(raw.cost_price, raw.markup); const row: Record = { sku, sku_normalised: skuNormalised, description: description || sku, category: (raw.category ?? "").trim(), subcategory: (raw.subcategory ?? "").trim(), brand: (raw.brand ?? "").trim(), supplier, cost_price: raw.cost_price, selling_price: sellingPrice, markup: typeof raw.markup === "number" && Number.isFinite(raw.markup) ? raw.markup : 0, unit: (raw.unit ?? "").trim(), stock_qty: typeof raw.stock_qty === "number" && Number.isFinite(raw.stock_qty) ? raw.stock_qty : 0, search_text: searchText, }; if (input.source_file_id) { row.source_file_id = input.source_file_id; } if (input.source_label) { row.source_file = input.source_label; } try { // Upsert by (sku, supplier). PB filter for both fields is // exact-equality on the raw strings — we don't try to // dedupe across normalised SKUs (different suppliers can // legitimately carry the same SKU; dedup is per-supplier). const filter = `sku = "${escapeForPbString(sku)}" && supplier = "${escapeForPbString(supplier)}"`; const existing = await pb.collection("price_list").getList<{ id: string }>( 1, 1, { filter, fields: "id" }, ); if (existing.items.length > 0) { await pb.collection("price_list").update(existing.items[0].id, row); ids.push(existing.items[0].id); } else { const created = await pb.collection("price_list").create(row); ids.push(created.id); } // Mirror selling_price + markup onto the matching stock_items // row when (and only when) the stock row has no price yet. // Errors are captured into `syncWarnings` below — they must // never fail the price_list write. const syncOutcome = await syncPriceListToStockItem(pb, { sku, sellingPrice: sellingPrice, markup: typeof raw.markup === "number" && Number.isFinite(raw.markup) ? raw.markup : undefined, }); if (syncOutcome?.warning) { syncWarnings.push(syncOutcome.warning); } } catch (err) { const message = err instanceof Error ? err.message : String(err); skipped.push({ index, sku, reason: `pb_write_failed: ${message}` }); } } // We don't know per-row whether it was an add or an update from // `ids` alone — count created-vs-updated by re-querying the rows. // For a typical 500-item call this is one PB round-trip with // an `id IN (…)` filter. let added = 0; let updated = 0; if (ids.length > 0) { const filter = ids.map((id) => `id = "${id}"`).join(" || "); const rows = await pb.collection("price_list").getList<{ id: string; created: string; updated: string }>( 1, ids.length, { filter, fields: "id,created,updated" }, ); for (const r of rows.items) { // PB stamps `created` once; `updated` every write. If they // match to the second, the row is brand-new; otherwise it // was an update. if (r.created && r.updated && r.created === r.updated) { added += 1; } else { updated += 1; } } } return { ok: true, added, updated, ids, skipped, ...(syncWarnings.length > 0 ? { warnings: syncWarnings } : {}), }; }