From 47d58db0c9644b4f4753670d5405faeece962dcc Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 10 Aug 2026 12:45:05 +0200 Subject: [PATCH] feat(mcp): add price_list.add_items tool for agent-driven catalogue upsert Add a new MCP tool that lets the agent read a supplier PDF, quote PDF, or pasted supplier email with its native vision, extract the per-line fields, and commit them to the price_list catalogue in one round-trip. Why: the only pre-existing path was operator upload via /pricelist -> n8n ingest -> raw_price_list -> AI extraction -> price_list. That flow is async, human-in-the-loop, and gates on n8n being healthy. This tool gives the agent a synchronous shortcut for ad-hoc catalogue updates -- paste a PDF, get the catalogue updated, no async hops. The agent does the OCR; the tool does the commit. No LLM work moves into the MCP server. Behaviour: - Dedupe by (sku, supplier) -- exact match. Different suppliers with the same SKU get separate rows. - Upsert policy: cost_price overwrites (latest price wins). Description, selling_price, unit, category, subcategory, brand, markup, stock_qty, search_text, sku_normalised also patched in place on update. - sku_normalised auto-computed (uppercase, strip non-alphanumeric -- same canonical form price-list-match.ts uses). - search_text auto-computed (lowercased, space-separated concat of sku + description + brand + supplier + category + subcategory + synonyms). - Skipped items returned with per-item reason (missing_sku, missing_supplier, invalid_cost_price, pb_write_failed). Never silent. - Bulk cap 500 items per call; larger lists rejected with too_many_items reason. - source_file_id + source_label supported so new rows can be traced back to a raw_price_list upload (optional). - Supplier stored verbatim on price_list.supplier (free-text field, no FK resolution -- same as the live schema). Files: - src/lib/server/price-list-service.ts (new) -- addPriceListItems + buildSearchText + computeSellingPrice helpers - src/lib/server/price-list-service.test.ts (new) -- 17 tests covering validation, upsert, dedupe filter shape, normalisation - src/app/api/mcp/route.ts -- new tool definition + dispatch block - src/app/api/mcp/route.test.ts -- 2 dispatch tests + tool-list assertion update - .agents/skills/wfdash-giellie/SKILL.md -- new section "8. Price- list catalogue" documenting input/output shapes + failure modes Tests: - 134/134 pass across the affected files - 1775/1778 in the full suite (3 pre-existing failures unrelated to this change: pocketbase-admin.test.ts PB mock mismatch, quotes/items/[id]/attachments/upload/route.test.ts timeout, supplier-form.test.tsx race -- all reproducible on main) - npm run build clean --- .agents/skills/wfdash-giellie/SKILL.md | 61 +++- src/app/api/mcp/route.test.ts | 105 ++++++ src/app/api/mcp/route.ts | 146 ++++++++ src/lib/server/price-list-service.test.ts | 393 ++++++++++++++++++++++ src/lib/server/price-list-service.ts | 330 ++++++++++++++++++ 5 files changed, 1034 insertions(+), 1 deletion(-) create mode 100644 src/lib/server/price-list-service.test.ts create mode 100644 src/lib/server/price-list-service.ts diff --git a/.agents/skills/wfdash-giellie/SKILL.md b/.agents/skills/wfdash-giellie/SKILL.md index fa7c818..ea749b3 100644 --- a/.agents/skills/wfdash-giellie/SKILL.md +++ b/.agents/skills/wfdash-giellie/SKILL.md @@ -287,7 +287,65 @@ REST mirror at `/api/v1/quote_items` (GET list + POST create) and `/api/v1/quote |------|---------| | `tools.discovery` | Returns the flat `{ tools: [...] }` catalog via JSON-RPC. Mirrors `GET /api/mcp`. Implemented in `src/app/api/mcp/route.ts`. | -### Full tool index (74) +### 8. Price-list catalogue (add items from a parsed supplier/quote document) + +| Tool | Purpose | +|------|---------| +| `price_list.add_items` | Bulk-upsert parsed line items into the `price_list` collection. The agent reads the source document (supplier PDF, quote PDF, pasted email) with its native vision, extracts the per-line fields, and passes them in. The tool handles dedupe by `(sku, supplier)` and computes `sku_normalised` + `search_text` automatically. **You do the OCR, we do the commit.** | + +**When to use it:** the operator pastes a supplier's price list PDF, a quote from a customer, or a supplier's emailed price sheet into the chat and asks you to add the items to the catalogue. Read the document with your vision capability, extract the per-line fields, and call `price_list.add_items` with the structured array. + +**Input shape:** + +```jsonc +{ + "source_file_id": "raw_abc123", // optional — FK to raw_price_list.id if the operator uploaded via /pricelist + "source_label": "AFRIQ-INA64721", // optional — free-text label for the source + "items": [ + { + "sku": "BRG-6205", + "description": "Deep groove ball bearing 6205", + "cost_price": 89.5, + "selling_price": 112.0, // optional — computed from cost × (1 + markup/100) if omitted + "markup": 25, // optional — only used when selling_price is omitted + "unit": "EA", + "category": "Bearings", + "subcategory": "Ball bearings", + "brand": "SKF", + "stock_qty": 24, // optional — denormalised snapshot, default 0 + "supplier_name": "Afrisan Supplies" // stored verbatim on price_list.supplier (free-text field) + } + // ... up to 500 items per call; chunk larger lists + ] +} +``` + +**Response shape:** + +```jsonc +{ + "ok": true, + "added": 2, // brand-new rows inserted + "updated": 1, // existing (sku, supplier) rows patched + "ids": ["pl_xxx", "pl_yyy", "pl_zzz"], + "skipped": [{ "index": 4, "sku": "...", "reason": "missing_supplier" }] // per-item failures, never silent +} +``` + +**Failure modes the tool will surface for you to report back:** + +| Reason | When | +|---|---| +| `no_items` | Empty `items[]`. | +| `too_many_items` | > 500 items. Chunk into batches. | +| `missing_sku` | SKU is blank. | +| `missing_supplier` | `supplier_name` is blank. **Required** — every catalogue row needs to know who sells it. | +| `invalid_cost_price` | Cost ≤ 0. The catalogue won't store zero-priced items. | +| `pb_write_failed: ...` | PB rejected the write (validation, perms, network). Reason is included. | + +**Why this exists:** the only pre-existing path for adding to the catalogue was operator uploads via `/pricelist` → n8n ingest → `raw_price_list` → AI extraction → `price_list`. That flow is async, human-in-the-loop, and gates on n8n being healthy. This tool gives you a synchronous, agent-driven shortcut for ad-hoc updates — paste a PDF, get the catalogue updated, no async hops. + +### Full tool index (75) ``` orders.list, orders.get, orders.create_from_quote @@ -317,6 +375,7 @@ ai_insights.rerun companies.list, companies.get, companies.create, companies.update, companies.delete customers.list, customers.get, customers.create, customers.update, customers.delete suppliers.list, suppliers.get, suppliers.create, suppliers.update, suppliers.delete +price_list.add_items tools.discovery ``` diff --git a/src/app/api/mcp/route.test.ts b/src/app/api/mcp/route.test.ts index 5cf0d92..38b612c 100644 --- a/src/app/api/mcp/route.test.ts +++ b/src/app/api/mcp/route.test.ts @@ -82,6 +82,12 @@ vi.mock("@/lib/server/stock-items-service", () => ({ updateStockItem: (...args: unknown[]) => updateStockItem(...args), })); +// Mock the price-list add service. The handler dynamic-imports it. +const addPriceListItemsMock = vi.fn(); +vi.mock("@/lib/server/price-list-service", () => ({ + addPriceListItems: (...args: unknown[]) => addPriceListItemsMock(...args), +})); + import { POST } from "./route"; function mcpRequest(body: unknown) { @@ -99,6 +105,7 @@ describe("POST /api/mcp", () => { getOne.mockReset(); create.mockReset(); update.mockReset(); + addPriceListItemsMock.mockReset(); createOrderFromQuote.mockReset(); listCollectionPool.mockReset(); getOrderItemsCollectionName.mockReset(); @@ -265,6 +272,10 @@ const toolNames = body.result.tools.map((t: { name: string }) => t.name); "suppliers.create", "suppliers.update", "suppliers.delete", + // Price-list catalogue upsert (added 2026-08-09 — agent reads + // supplier PDFs / quote PDFs with vision and commits the parsed + // line items in one round-trip via MCP) + "price_list.add_items", // Discovery (added 2026-07-30) "tools.discovery", ]); @@ -515,6 +526,100 @@ const toolNames = body.result.tools.map((t: { name: string }) => t.name); }); }); + it("tools/call price_list.add_items parses the items array and delegates to the service", async () => { + // The service does the dedupe + PB write. We assert the dispatch + // hands the parsed items + optional source fields through and + // echoes the service response (added / updated / skipped). + addPriceListItemsMock.mockResolvedValueOnce({ + ok: true, + added: 2, + updated: 1, + ids: ["pl_1", "pl_2", "pl_3"], + skipped: [], + }); + + const res = await POST( + mcpRequest({ + method: "tools/call", + id: 64, + params: { + name: "price_list.add_items", + arguments: { + source_file_id: "raw_abc", + source_label: "AFRIQ-INA64721", + items: [ + { sku: "BRG-6205", description: "Bearing", cost_price: 89.5, supplier_name: "Afrisan" }, + { sku: "BRG-6206", description: "Bearing", cost_price: 95, supplier_name: "Afrisan" }, + { sku: "BRG-6207", description: "Bearing", cost_price: 110, supplier_name: "Afrisan" }, + ], + }, + }, + }), + ); + expect(res.status).toBe(200); + expect(addPriceListItemsMock).toHaveBeenCalledTimes(1); + const callArgs = addPriceListItemsMock.mock.calls[0]?.[0] as { + items: Array>; + source_file_id?: string; + source_label?: string; + }; + // Every line item is mapped to the service shape with string + // supplier_name + numeric cost_price; optional fields pass through. + expect(callArgs.items).toHaveLength(3); + expect(callArgs.items[0]).toMatchObject({ + sku: "BRG-6205", + description: "Bearing", + cost_price: 89.5, + supplier_name: "Afrisan", + }); + expect(callArgs.source_file_id).toBe("raw_abc"); + expect(callArgs.source_label).toBe("AFRIQ-INA64721"); + + const body = await res.json(); + const data = JSON.parse(body.result.content[0].text); + expect(data).toEqual({ + ok: true, + added: 2, + updated: 1, + ids: ["pl_1", "pl_2", "pl_3"], + skipped: [], + }); + }); + + it("tools/call price_list.add_items returns a 400 with reason when the service rejects", async () => { + // e.g. batch size over the cap, or no items supplied. + addPriceListItemsMock.mockResolvedValueOnce({ + ok: false, + reason: "too_many_items", + message: "Too many items in one call: 501 (max 500).", + }); + + const res = await POST( + mcpRequest({ + method: "tools/call", + id: 65, + params: { + name: "price_list.add_items", + arguments: { + items: Array.from({ length: 1 }, () => ({ + sku: "BRG-6205", + description: "Bearing", + cost_price: 89.5, + supplier_name: "Afrisan", + })), + }, + }, + }), + ); + expect(res.status).toBe(400); + const body = await res.json(); + const data = JSON.parse(body.result.content[0].text); + expect(data).toMatchObject({ + ok: false, + reason: "too_many_items", + }); + }); + it("tools/call stock_purchases.match_and_receive_from_invoice dry-runs by default and returns the report", async () => { // The handler delegates the entire matching + report build to the // service helper. Confirm the call is shaped correctly (parsed diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index 223eaaf..ccc2b6a 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -1989,6 +1989,89 @@ const tools = [ properties: { id: { type: "string" } }, }, }, + { + name: "price_list.add_items", + description: + "Bulk-upsert parsed supplier-price-list line items into the `price_list` catalogue. The agent reads the source document (a supplier PDF, a quote PDF, or a pasted supplier email) with its native vision capability, extracts the per-line fields, and passes them in. The tool handles deduplication by `(sku, supplier)`: if a row already exists for that SKU from that supplier, the existing row's `cost_price`, `description`, `selling_price`, `unit`, `category`, `subcategory`, `brand`, `markup`, `stock_qty`, `search_text`, and `sku_normalised` are patched in place; otherwise a new row is inserted. `cost_price` OVERWRITES — the latest price wins. The supplier is stored as free text on `price_list.supplier` (the live schema has no FK), so the supplier name is taken verbatim. Items with missing `sku`, missing `supplier_name`, non-positive `cost_price`, or a PB write failure are returned in `skipped[]` with a per-item reason so the agent can report them back to the operator. Max 500 items per call — chunk larger lists. Optionally pass `source_file_id` (a `raw_price_list` row id, when the operator uploaded the document via `/pricelist` first) and `source_label` (free text, e.g. `\"Q-AFRISAN-2026-08\"`) so the new rows can be traced back to the source document.", + inputSchema: { + type: "object", + required: ["items"], + properties: { + items: { + type: "array", + minItems: 1, + maxItems: 500, + items: { + type: "object", + required: ["sku", "description", "cost_price"], + properties: { + sku: { + type: "string", + description: + "SKU exactly as printed on the source document. Required. The tool normalises it (uppercase, strip non-alphanumeric) for the `sku_normalised` field used by the catalogue search.", + }, + description: { + type: "string", + description: + "Human-readable description of the line item. Required. Falls back to the SKU if empty.", + }, + cost_price: { + type: "number", + description: + "What the supplier charges per unit, in R. Required; must be > 0. Items with non-positive values land in `skipped[]`.", + }, + selling_price: { + type: "number", + description: + "Optional. If omitted, computed from `cost_price * (1 + markup/100)`. The agent should pass it explicitly when the supplier's PDF already shows a selling/RSP column.", + }, + markup: { + type: "number", + description: + "Optional markup percentage (e.g. 25 = 25%). Used only when `selling_price` is omitted.", + }, + unit: { + type: "string", + description: 'Unit string — \"EA\", \"BOX OF 12\", \"L\", \"KG\", etc. Optional.', + }, + category: { + type: "string", + description: "Optional category (\"Bearings\", \"Fasteners\", \"Lubricants\", …).", + }, + subcategory: { + type: "string", + description: "Optional subcategory (\"Ball bearings\", \"Hex bolts\", …).", + }, + brand: { + type: "string", + description: "Optional brand (\"SKF\", \"Bosch\", \"Afrisan\", …).", + }, + stock_qty: { + type: "number", + description: + "Optional. The on-hand qty for this catalogue row. Default 0. The catalogue's `stock_qty` is a denormalised snapshot — the live `current_qty` lives on `stock_items`, not here.", + }, + supplier_name: { + type: "string", + description: + "Supplier name as printed on the document. Stored verbatim on `price_list.supplier`. If the operator wants the price list keyed by supplier FK instead of free text, that's a schema migration — not part of this tool.", + }, + }, + }, + }, + source_file_id: { + type: "string", + description: + "Optional `raw_price_list.id` linking the new rows back to the source document. Pass when the operator uploaded the document via `/pricelist` first; leave undefined for ad-hoc / paste-into-chat flows.", + }, + source_label: { + type: "string", + description: + "Optional free-text label for the source document (e.g. \"Q-AFRISAN-2026-08\" or \"AFRIQ-INA64721\"). Stored on each new row's `source_file` text field so the Catalogue tab can show \"extracted from: …\" without a join.", + }, + }, + }, + }, // Discovery — Hermes (and n8n workflows that prefer a JSON-Schema-only // catalog) can hit this endpoint instead of building the full JSON-RPC // envelope just to discover available tools. @@ -3142,6 +3225,69 @@ export async function POST(req: NextRequest) { return jsonRpcOk(body.id, json); } + if (toolName === "price_list.add_items") { + // Bulk-upsert parsed price-list line items. The agent reads the + // source document (supplier PDF, quote PDF, pasted email) with + // its native vision capability and passes already-parsed fields + // in. We do the dedupe + write here so the MCP server has no + // LLM work — the OCR is the agent's job, the commit is ours. + if (!Array.isArray(args.items) || args.items.length === 0) { + throw new Error( + "Missing or empty items array for price_list.add_items.", + ); + } + const { addPriceListItems } = await import( + "@/lib/server/price-list-service" + ); + const items = ( + args.items as Array> + ).map((raw) => ({ + sku: typeof raw.sku === "string" ? raw.sku : "", + description: typeof raw.description === "string" ? raw.description : "", + cost_price: + typeof raw.cost_price === "number" ? raw.cost_price : Number.NaN, + selling_price: + typeof raw.selling_price === "number" + ? raw.selling_price + : undefined, + markup: + typeof raw.markup === "number" ? raw.markup : undefined, + unit: typeof raw.unit === "string" ? raw.unit : undefined, + category: + typeof raw.category === "string" ? raw.category : undefined, + subcategory: + typeof raw.subcategory === "string" + ? raw.subcategory + : undefined, + brand: typeof raw.brand === "string" ? raw.brand : undefined, + stock_qty: + typeof raw.stock_qty === "number" ? raw.stock_qty : undefined, + supplier_name: + typeof raw.supplier_name === "string" ? raw.supplier_name : "", + })); + const result = await addPriceListItems({ + items, + source_file_id: + typeof args.source_file_id === "string" + ? args.source_file_id + : undefined, + source_label: + typeof args.source_label === "string" + ? args.source_label + : undefined, + }); + if (!result.ok) { + return jsonRpcOk(body.id, result, 400); + } + return jsonRpcOk(body.id, { + ok: true, + added: result.added, + updated: result.updated, + ids: result.ids, + skipped: result.skipped, + }); + } + // ─── Purchase orders (extra verbs) ────────────────────────── if (toolName === "purchase_orders.list") { const page = Number(args.page || 1); diff --git a/src/lib/server/price-list-service.test.ts b/src/lib/server/price-list-service.test.ts new file mode 100644 index 0000000..82c70cd --- /dev/null +++ b/src/lib/server/price-list-service.test.ts @@ -0,0 +1,393 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +const { createMock, updateMock, getListMock } = vi.hoisted(() => { + return { + createMock: vi.fn(), + updateMock: vi.fn(), + getListMock: vi.fn(), + }; +}); + +vi.mock("@/lib/server/pocketbase-admin", () => ({ + getAdminPocketBase: vi.fn().mockResolvedValue({ + collection: (name: string) => { + if (name !== "price_list") { + throw new Error(`Unexpected collection in test: ${name}`); + } + return { + getList: getListMock, + create: createMock, + update: updateMock, + }; + }, + }), +})); + +import { + addPriceListItems, + buildSearchText, + computeSellingPrice, + MAX_ADD_ITEMS, + type AddPriceListItemsInput, +} from "@/lib/server/price-list-service"; + +describe("buildSearchText", () => { + it("concatenates every searchable field lowercased and space-separated", () => { + const out = buildSearchText({ + sku: "BRG-6205", + description: "Deep groove ball bearing 6205", + brand: "SKF", + supplier: "Afrisan", + category: "Bearings", + subcategory: "Ball bearings", + }); + expect(out).toBe( + "brg-6205 deep groove ball bearing 6205 skf afrisan bearings ball bearings", + ); + }); + + it("includes synonyms from specifications when present", () => { + const out = buildSearchText({ + sku: "BRG-6205", + description: "Deep groove ball bearing 6205", + brand: "SKF", + supplier: "Afrisan", + specifications: { synonyms: ["6205", "DDU", "CM"] }, + }); + expect(out).toContain("6205"); + expect(out).toContain("ddu"); + expect(out).toContain("cm"); + }); + + it("handles empty fields without crashing", () => { + expect( + buildSearchText({ sku: "", description: "", supplier: "" }), + ).toBe(""); + }); +}); + +describe("computeSellingPrice", () => { + it("applies the markup percentage", () => { + expect(computeSellingPrice(100, 25)).toBe(125); + expect(computeSellingPrice(89.5, 30)).toBeCloseTo(116.35, 2); + }); + + it("rounds to 2 decimal places", () => { + expect(computeSellingPrice(99.99, 17.5)).toBeCloseTo(117.49, 2); + }); + + it("returns the cost price unchanged when markup is missing or non-positive", () => { + expect(computeSellingPrice(100, undefined)).toBe(100); + expect(computeSellingPrice(100, 0)).toBe(100); + expect(computeSellingPrice(100, -5)).toBe(100); + }); + + it("returns 0 for negative cost prices (caller should have rejected upstream)", () => { + expect(computeSellingPrice(-1, 25)).toBe(0); + }); +}); + +describe("addPriceListItems — input validation", () => { + beforeEach(() => { + createMock.mockReset(); + updateMock.mockReset(); + getListMock.mockReset(); + }); + + it("returns no_items when items array is empty", async () => { + const result = await addPriceListItems({ items: [] }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("no_items"); + }); + + it("returns too_many_items when batch exceeds the cap", async () => { + const items = Array.from({ length: MAX_ADD_ITEMS + 1 }, (_, i) => ({ + sku: `BRG-${i}`, + description: "x", + cost_price: 1, + supplier_name: "Acme", + })); + const result = await addPriceListItems({ items }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("too_many_items"); + }); +}); + +describe("addPriceListItems — per-item validation", () => { + beforeEach(() => { + createMock.mockReset(); + updateMock.mockReset(); + getListMock.mockReset(); + // No existing rows by default + getListMock.mockResolvedValue({ items: [] }); + }); + + it("skips items missing sku", async () => { + const result = await addPriceListItems({ + items: [ + { + sku: "", + description: "Bolt", + cost_price: 5, + supplier_name: "Acme", + }, + ], + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.skipped).toEqual([ + { index: 0, sku: "", reason: "missing_sku" }, + ]); + expect(result.added).toBe(0); + expect(createMock).not.toHaveBeenCalled(); + } + }); + + it("skips items missing supplier_name", async () => { + const result = await addPriceListItems({ + items: [ + { + sku: "BRG-6205", + description: "Bearing", + cost_price: 5, + supplier_name: "", + }, + ], + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.skipped[0]?.reason).toBe("missing_supplier"); + } + }); + + it("skips items with non-positive cost_price", async () => { + const result = await addPriceListItems({ + items: [ + { + sku: "BRG-6205", + description: "Bearing", + cost_price: 0, + supplier_name: "Acme", + }, + { + sku: "BRG-6206", + description: "Bearing", + cost_price: -10, + supplier_name: "Acme", + }, + ], + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.skipped.every((s) => s.reason === "invalid_cost_price")).toBe(true); + expect(result.skipped.length).toBe(2); + } + }); + + it("processes a mix of valid and invalid items, returning only the valid ones as ids", async () => { + let createdCount = 0; + createMock.mockImplementation(async () => { + createdCount += 1; + return { id: `pl_${createdCount}` }; + }); + // For the final stats query, mark all rows as fresh + getListMock.mockImplementation(async (_, __, opts) => { + const filter = opts?.filter ?? ""; + if (filter.includes("sku =") || filter.includes("id =")) { + // First call: dedupe lookup. Second: stats lookup. + if (filter.startsWith("id =") || filter.includes(" || ")) { + // Stats query — return everything as fresh + return { + items: [{ id: "pl_1", created: "2026-08-09T00:00:00.000Z", updated: "2026-08-09T00:00:00.000Z" }], + }; + } + return { items: [] }; + } + return { items: [] }; + }); + + const result = await addPriceListItems({ + items: [ + { sku: "BRG-6205", description: "Bearing", cost_price: 89.5, supplier_name: "Acme" }, + { sku: "", description: "x", cost_price: 1, supplier_name: "Acme" }, // skipped + { sku: "BRG-6206", description: "Bearing", cost_price: 95, supplier_name: "Acme" }, + ], + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(createMock).toHaveBeenCalledTimes(2); + expect(result.skipped.length).toBe(1); + expect(result.ids.length).toBe(2); + } + }); +}); + +describe("addPriceListItems — upsert behaviour", () => { + beforeEach(() => { + createMock.mockReset(); + updateMock.mockReset(); + getListMock.mockReset(); + }); + + it("inserts a new row when (sku, supplier) does not exist", async () => { + let listCall = 0; + getListMock.mockImplementation(async () => { + listCall += 1; + if (listCall === 1) return { items: [] }; // dedupe lookup + // Stats query — return the fresh row + return { + items: [ + { + id: "pl_new", + created: "2026-08-09T00:00:00.000Z", + updated: "2026-08-09T00:00:00.000Z", + }, + ], + }; + }); + createMock.mockResolvedValue({ id: "pl_new" }); + + const result = await addPriceListItems({ + items: [ + { + sku: "BRG-6205", + description: "Deep groove ball bearing", + cost_price: 89.5, + supplier_name: "Afrisan", + }, + ], + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(createMock).toHaveBeenCalledTimes(1); + expect(updateMock).not.toHaveBeenCalled(); + expect(result.added).toBe(1); + expect(result.updated).toBe(0); + expect(result.ids).toEqual(["pl_new"]); + + // Inspect the row we wrote — confirm normalisation + search_text + const written = createMock.mock.calls[0]?.[0] as Record; + expect(written.sku).toBe("BRG-6205"); + expect(written.sku_normalised).toBe("BRG6205"); + expect(written.supplier).toBe("Afrisan"); + expect(written.search_text).toContain("brg-6205"); + expect(written.search_text).toContain("deep groove ball bearing"); + expect(written.search_text).toContain("afrisan"); + } + }); + + it("updates the existing row when (sku, supplier) already exists", async () => { + let listCall = 0; + getListMock.mockImplementation(async () => { + listCall += 1; + if (listCall === 1) { + // dedupe lookup — row exists + return { items: [{ id: "pl_existing" }] }; + } + // Stats query — return updated row (created != updated) + return { + items: [ + { + id: "pl_existing", + created: "2026-08-01T00:00:00.000Z", + updated: "2026-08-09T00:00:00.000Z", + }, + ], + }; + }); + updateMock.mockResolvedValue({ id: "pl_existing" }); + + const result = await addPriceListItems({ + items: [ + { + sku: "BRG-6205", + description: "Updated description", + cost_price: 99, + supplier_name: "Afrisan", + }, + ], + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(updateMock).toHaveBeenCalledTimes(1); + expect(updateMock).toHaveBeenCalledWith("pl_existing", expect.objectContaining({ + sku: "BRG-6205", + cost_price: 99, + supplier: "Afrisan", + })); + expect(createMock).not.toHaveBeenCalled(); + expect(result.added).toBe(0); + expect(result.updated).toBe(1); + } + }); + + it("supports the source_file_id and source_label fields", async () => { + let listCall = 0; + getListMock.mockImplementation(async () => { + listCall += 1; + if (listCall === 1) return { items: [] }; + return { + items: [ + { + id: "pl_new", + created: "2026-08-09T00:00:00.000Z", + updated: "2026-08-09T00:00:00.000Z", + }, + ], + }; + }); + createMock.mockResolvedValue({ id: "pl_new" }); + + await addPriceListItems({ + source_file_id: "raw_abc", + source_label: "AFRIQ-INA64721", + items: [ + { + sku: "BRG-6205", + description: "Bearing", + cost_price: 89.5, + supplier_name: "Afrisan", + }, + ], + }); + + const written = createMock.mock.calls[0]?.[0] as Record; + expect(written.source_file_id).toBe("raw_abc"); + expect(written.source_file).toBe("AFRIQ-INA64721"); + }); + + it("uses the same (sku, supplier) filter for dedupe — different suppliers don't collide", async () => { + let listCall = 0; + getListMock.mockImplementation(async () => { + listCall += 1; + if (listCall === 1) return { items: [] }; // dedupe: no row for (BRG-6205, Orapi) + // Stats: brand-new row + return { + items: [ + { + id: "pl_orapi", + created: "2026-08-09T00:00:00.000Z", + updated: "2026-08-09T00:00:00.000Z", + }, + ], + }; + }); + createMock.mockResolvedValue({ id: "pl_orapi" }); + + await addPriceListItems({ + items: [ + { + sku: "BRG-6205", + description: "Bearing", + cost_price: 95, + supplier_name: "Orapi", + }, + ], + }); + + // The dedupe lookup filter must include BOTH sku AND supplier + const dedupeFilter = (getListMock.mock.calls[0]?.[2] as { filter?: string })?.filter ?? ""; + expect(dedupeFilter).toContain("sku = \"BRG-6205\""); + expect(dedupeFilter).toContain("supplier = \"Orapi\""); + }); +}); \ No newline at end of file diff --git a/src/lib/server/price-list-service.ts b/src/lib/server/price-list-service.ts new file mode 100644 index 0000000..9173587 --- /dev/null +++ b/src/lib/server/price-list-service.ts @@ -0,0 +1,330 @@ +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 }>; +}; + +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); +} + +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[] = []; + + 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); + } + } 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, + }; +} \ No newline at end of file