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
2026-08-10 10:45:05 +00:00
|
|
|
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 }>;
|
2026-09-14 11:04:08 +00:00
|
|
|
/**
|
|
|
|
|
* Stock-item sync problems encountered after the price_list write
|
|
|
|
|
* landed. The price_list write itself always succeeds (or lands
|
|
|
|
|
* in `skipped`); the stock_items patch is a best-effort step
|
|
|
|
|
* that fires only when an `addPriceListItems` row matches a
|
|
|
|
|
* `stock_items` row by `sku_normalised` AND that stock row's
|
|
|
|
|
* `selling_price` is zero. Sync failures are collected here so a
|
|
|
|
|
* single broken stock row can never poison a 500-item batch.
|
|
|
|
|
*
|
|
|
|
|
* Empty when no sync attempt was needed (no matching stock_item)
|
|
|
|
|
* or every sync attempt succeeded.
|
|
|
|
|
*/
|
|
|
|
|
warnings?: Array<{
|
|
|
|
|
/** The sku whose sync attempt failed. */
|
|
|
|
|
sku: string;
|
|
|
|
|
/** The stock_items row id the sync was targeting. */
|
|
|
|
|
stock_item_id: string;
|
|
|
|
|
/** Short failure reason (PB error message). */
|
|
|
|
|
reason: string;
|
|
|
|
|
}>;
|
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
2026-08-10 10:45:05 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-14 11:04:08 +00:00
|
|
|
/**
|
|
|
|
|
* After a successful `price_list` upsert, mirror the row's
|
|
|
|
|
* selling_price + markup onto the matching `stock_items` row so
|
|
|
|
|
* the till (which reads `stock_items.selling_price`) sees the new
|
|
|
|
|
* price. The price_list catalogue is the source of truth for
|
|
|
|
|
* items that have *no* shelf price; the POS cashier or catalogue
|
|
|
|
|
* patch is the source once a price has been set — so we never
|
|
|
|
|
* overwrite a non-zero `stock_items.selling_price`.
|
|
|
|
|
*
|
|
|
|
|
* Sync rules:
|
|
|
|
|
* - Match by `sku_normalised` on `stock_items` (single row only;
|
|
|
|
|
* if multiple match, do nothing — ambiguous).
|
|
|
|
|
* - Match only when the stock row's `selling_price` is 0/null AND
|
|
|
|
|
* the price_list row has a positive `selling_price` to seed.
|
|
|
|
|
* - If `stock_items.markup` is 0/null AND `price_list.markup` is
|
|
|
|
|
* positive, also patch `markup`.
|
|
|
|
|
* - Sync failures are returned to the caller, never thrown. A
|
|
|
|
|
* broken sync must not poison a 500-item batch.
|
|
|
|
|
*
|
|
|
|
|
* Returns `null` when no sync was needed (no match, already priced,
|
|
|
|
|
* or missing selling_price on the price_list row); otherwise
|
|
|
|
|
* returns `{ stock_item_id, warnings? }` describing the outcome.
|
|
|
|
|
*/
|
|
|
|
|
async function syncPriceListToStockItem(
|
|
|
|
|
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
|
|
|
|
args: {
|
|
|
|
|
sku: string;
|
|
|
|
|
sellingPrice: number | undefined;
|
|
|
|
|
markup: number | undefined;
|
|
|
|
|
},
|
|
|
|
|
): Promise<
|
|
|
|
|
| { stock_item_id: string; warning?: { sku: string; stock_item_id: string; reason: string } }
|
|
|
|
|
| null
|
|
|
|
|
> {
|
|
|
|
|
// No point syncing if the price_list row doesn't carry a price.
|
|
|
|
|
if (
|
|
|
|
|
typeof args.sellingPrice !== "number" ||
|
|
|
|
|
!Number.isFinite(args.sellingPrice) ||
|
|
|
|
|
args.sellingPrice <= 0
|
|
|
|
|
) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const skuNormalised = normaliseSku(args.sku);
|
|
|
|
|
if (!skuNormalised) return null;
|
|
|
|
|
|
|
|
|
|
// Look up the stock row by canonical SKU. We don't auto-create
|
|
|
|
|
// from price_list — supplier catalogues carry thousands of SKUs
|
|
|
|
|
// that have never been stocked.
|
|
|
|
|
let stockItems: Array<{ id: string; selling_price?: number | null; markup?: number | null }>;
|
|
|
|
|
try {
|
|
|
|
|
stockItems = (
|
|
|
|
|
await pb.collection("stock_items").getList<{
|
|
|
|
|
id: string;
|
|
|
|
|
selling_price?: number | null;
|
|
|
|
|
markup?: number | null;
|
|
|
|
|
}>(1, 2, {
|
|
|
|
|
filter: `sku_normalised = "${escapeForPbString(skuNormalised)}"`,
|
|
|
|
|
fields: "id,selling_price,markup",
|
|
|
|
|
})
|
|
|
|
|
).items;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
|
|
|
return {
|
|
|
|
|
stock_item_id: "",
|
|
|
|
|
warning: { sku: args.sku, stock_item_id: "", reason: `lookup_failed: ${message}` },
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Ambiguous match → skip without error. The price_list still
|
|
|
|
|
// landed; the operator can resolve the dupes in the catalogue.
|
|
|
|
|
if (stockItems.length !== 1) return null;
|
|
|
|
|
|
|
|
|
|
const stockRow = stockItems[0];
|
|
|
|
|
const stockSellingPrice = Number(stockRow.selling_price ?? 0);
|
|
|
|
|
const stockMarkup = Number(stockRow.markup ?? 0);
|
|
|
|
|
|
|
|
|
|
const patch: { selling_price?: number; markup?: number } = {};
|
|
|
|
|
// Idempotency: never overwrite a non-zero stock shelf price.
|
|
|
|
|
if (!(stockSellingPrice > 0)) {
|
|
|
|
|
patch.selling_price = args.sellingPrice;
|
|
|
|
|
}
|
|
|
|
|
if (
|
|
|
|
|
!(stockMarkup > 0) &&
|
|
|
|
|
typeof args.markup === "number" &&
|
|
|
|
|
Number.isFinite(args.markup) &&
|
|
|
|
|
args.markup > 0
|
|
|
|
|
) {
|
|
|
|
|
patch.markup = args.markup;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (Object.keys(patch).length === 0) return null;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await pb.collection("stock_items").update(stockRow.id, patch);
|
|
|
|
|
return { stock_item_id: stockRow.id };
|
|
|
|
|
} catch (err) {
|
|
|
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
|
|
|
return {
|
|
|
|
|
stock_item_id: stockRow.id,
|
|
|
|
|
warning: {
|
|
|
|
|
sku: args.sku,
|
|
|
|
|
stock_item_id: stockRow.id,
|
|
|
|
|
reason: message,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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
2026-08-10 10:45:05 +00:00
|
|
|
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[] = [];
|
2026-09-14 11:04:08 +00:00
|
|
|
// Best-effort stock_items sync problems. Always empty when no
|
|
|
|
|
// sync was needed (no matching stock_item) or every sync
|
|
|
|
|
// succeeded. Populated when syncPriceListToStockItem returned
|
|
|
|
|
// a `warning` (lookup failure or PATCH rejection).
|
|
|
|
|
const syncWarnings: NonNullable<AddPriceListItemsOk["warnings"]> = [];
|
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
2026-08-10 10:45:05 +00:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
2026-09-14 11:04:08 +00:00
|
|
|
// Mirror selling_price + markup onto the matching stock_items
|
|
|
|
|
// row when (and only when) the stock row has no price yet.
|
|
|
|
|
// Errors are captured into `syncWarnings` below — they must
|
|
|
|
|
// never fail the price_list write.
|
|
|
|
|
const syncOutcome = await syncPriceListToStockItem(pb, {
|
|
|
|
|
sku,
|
|
|
|
|
sellingPrice: sellingPrice,
|
|
|
|
|
markup: typeof raw.markup === "number" && Number.isFinite(raw.markup) ? raw.markup : undefined,
|
|
|
|
|
});
|
|
|
|
|
if (syncOutcome?.warning) {
|
|
|
|
|
syncWarnings.push(syncOutcome.warning);
|
|
|
|
|
}
|
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
2026-08-10 10:45:05 +00:00
|
|
|
} 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,
|
2026-09-14 11:04:08 +00:00
|
|
|
...(syncWarnings.length > 0 ? { warnings: syncWarnings } : {}),
|
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
2026-08-10 10:45:05 +00:00
|
|
|
};
|
|
|
|
|
}
|