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
393 lines
No EOL
11 KiB
TypeScript
393 lines
No EOL
11 KiB
TypeScript
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\"");
|
|
});
|
|
}); |