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:
parent
e9ffa6df4e
commit
47d58db0c9
5 changed files with 1034 additions and 1 deletions
|
|
@ -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
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
393
src/lib/server/price-list-service.test.ts
Normal file
393
src/lib/server/price-list-service.test.ts
Normal 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\"");
|
||||
});
|
||||
});
|
||||
330
src/lib/server/price-list-service.ts
Normal file
330
src/lib/server/price-list-service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue