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
This commit is contained in:
Dave 2026-08-10 12:45:05 +02:00
parent e9ffa6df4e
commit 47d58db0c9
5 changed files with 1034 additions and 1 deletions

View file

@ -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
```

View file

@ -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<Record<string, unknown>>;
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

View file

@ -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<Record<string, unknown>>
).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);

View file

@ -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<string, unknown>;
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<string, unknown>;
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\"");
});
});

View file

@ -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<AddPriceListItemsResult> {
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<ReturnType<typeof getAdminPocketBase>>;
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<string, unknown> = {
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,
};
}