From 2c415d82f10297a7f0c97b66f6386cae48d520aa Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 5 Nov 2026 23:24:50 +0200 Subject: [PATCH] feat(mcp): stock_items.set_quantities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One call sets the stock quantity on up to 100 pasted lines. Each line is resolved by exact SKU, then by description, and reports what it could not resolve instead of guessing. The batch is validated up front — a duplicate SKU is a malformed paste, not a per-row failure — while a line that simply does not match is reported and the rest of the batch still applies. --- src/app/api/mcp/route.test.ts | 74 +++++++++++++++++++++++- src/app/api/mcp/route.ts | 106 ++++++++++++++++++++++++++++++++++ 2 files changed, 179 insertions(+), 1 deletion(-) diff --git a/src/app/api/mcp/route.test.ts b/src/app/api/mcp/route.test.ts index 9e75022..cddc749 100644 --- a/src/app/api/mcp/route.test.ts +++ b/src/app/api/mcp/route.test.ts @@ -144,7 +144,7 @@ vi.mock("@/lib/server/price-list-service", () => ({ addPriceListItems: (...args: unknown[]) => addPriceListItemsMock(...args), })); -import { POST } from "./route"; +import { GET, POST } from "./route"; function mcpRequest(body: unknown) { return new NextRequest("http://localhost/api/mcp", { @@ -326,6 +326,7 @@ const toolNames = body.result.tools.map((t: { name: string }) => t.name); "stock_items.update", // POS pricing (cost / selling price / markup) for existing stock_items "stock_items.set_pricing", + "stock_items.set_quantities", "stock_purchases.list", // po-agent workflow (added 2026-08-14 — covers stock + customer // POs in a single tool namespace. See @@ -4288,3 +4289,74 @@ describe("MCP structured upstream errors", () => { expect(e.data.hint).toMatch(/Connection refused|reachable/i); }); }); + +// --------------------------------------------------------------------------- +// stock_items.set_quantities — the batch stock-quantity write +// --------------------------------------------------------------------------- +describe("MCP tool stock_items.set_quantities", () => { + beforeEach(() => { + process.env.AGENT_API_KEY = "t"; + }); + + it("is declared in the tool catalogue", async () => { + const res = await GET( + new NextRequest("http://localhost/api/mcp", { + headers: { authorization: `Bearer ${process.env.AGENT_API_KEY}` }, + }), + ); + const body = (await res.json()) as { tools: Array<{ name: string }> }; + expect(body.tools.some((t) => t.name === "stock_items.set_quantities")).toBe(true); + }); + + it("rejects a batch with a duplicate SKU", async () => { + const res = await POST( + new NextRequest("http://localhost/api/mcp", { + method: "POST", + headers: { + authorization: `Bearer ${process.env.AGENT_API_KEY}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "stock_items.set_quantities", + arguments: { + mode: "set", + lines: [ + { sku: "ABC-1", qty: 1 }, + { sku: "abc 1", qty: 2 }, + ], + }, + }, + }), + }), + ); + const body = await res.json(); + expect(JSON.stringify(body)).toMatch(/duplicate SKU/i); + }); + + it("rejects an unknown mode", async () => { + const res = await POST( + new NextRequest("http://localhost/api/mcp", { + method: "POST", + headers: { + authorization: `Bearer ${process.env.AGENT_API_KEY}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "stock_items.set_quantities", + arguments: { mode: "sideways", lines: [{ sku: "ABC-1", qty: 1 }] }, + }, + }), + }), + ); + const body = await res.json(); + expect(JSON.stringify(body)).toMatch(/mode must be/i); + }); +}); diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index a19634b..8e7be28 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -79,6 +79,8 @@ import { } from "@/lib/server/mcp-upstream-fetch"; import { isLowStockItem } from "@/lib/stock-status"; import { applyDefaultMarkupToPatch } from "@/lib/server/stock-default-markup-service"; +import { setStockQuantitiesBulk } from "@/lib/server/stock-quantities-bulk-service"; +import { normaliseSku } from "@/lib/server/price-list-match"; import { STOCK_REASON_VALUES } from "@/lib/types"; import { createCompany, @@ -2202,6 +2204,55 @@ const tools = [ }, }, }, + { + name: "stock_items.set_quantities", + description: + "Set or change the stock quantity on one or more POS/stock items, matched by SKU and description. " + + 'Use mode "set" when the number is the new quantity on hand (the stocktake case), and mode "add" when it is a change to add to the current quantity. ' + + "Each line is resolved by exact SKU first, then by a description match (flagged as such). A line that cannot be resolved is reported, never guessed. " + + "Pass dry_run true first to preview the before/after plan. Quantities may be negative — the till oversells on purpose and the stocktake corrects it. " + + "Writes a stock_ledger row against the acting user and mirrors the new quantity onto the price-list catalogue.", + inputSchema: { + type: "object", + properties: { + mode: { + type: "string", + enum: ["set", "add"], + description: + '"set" = the number is the new quantity on hand. "add" = the number is added to the current quantity.', + }, + lines: { + type: "array", + minItems: 1, + maxItems: 100, + description: + "One line per stock item. Give the sku when you have it; the description is the fallback when the SKU does not match.", + items: { + type: "object", + properties: { + sku: { type: "string", description: "The stock item's SKU." }, + description: { + type: "string", + description: "Free-text description, used when the SKU does not match.", + }, + qty: { + type: "number", + description: + "The quantity. In set mode this is the new quantity on hand; in add mode it is the change.", + }, + }, + required: ["qty"], + }, + }, + dry_run: { + type: "boolean", + description: + "Plan the change without writing anything. Returns the same result shape with the same before/after numbers.", + }, + }, + required: ["mode", "lines"], + }, + }, { name: "stock_purchases.list", description: @@ -6483,6 +6534,61 @@ async function handleMcpPost(req: NextRequest) { return jsonRpcOk(body.id, result); } + if (toolName === "stock_items.set_quantities") { + const mode = String(args.mode ?? ""); + if (mode !== "set" && mode !== "add") { + throw new Error('mode must be "set" or "add"'); + } + + const rawLines = Array.isArray(args.lines) ? (args.lines as unknown[]) : []; + if (rawLines.length < 1 || rawLines.length > 100) { + throw new Error("lines must contain between 1 and 100 entries"); + } + + const lines = rawLines.map((raw) => { + const l = (raw ?? {}) as { sku?: unknown; description?: unknown; qty?: unknown }; + const qty = + typeof l.qty === "number" && Number.isFinite(l.qty) ? Math.trunc(l.qty) : NaN; + return { + sku: typeof l.sku === "string" ? l.sku : undefined, + description: typeof l.description === "string" ? l.description : undefined, + qty, + }; + }); + + if (lines.some((l) => !Number.isFinite(l.qty))) { + throw new Error("every line needs a finite numeric qty"); + } + + // One SKU is one stock row, so two lines for the same SKU cannot + // both be true. That is a malformed paste, not a per-row outcome. + const seen = new Set(); + const duplicates = new Set(); + for (const l of lines) { + const key = normaliseSku(l.sku ?? ""); + if (!key) continue; + if (seen.has(key)) duplicates.add(l.sku ?? key); + seen.add(key); + } + if (duplicates.size > 0) { + throw new Error(`duplicate SKU in one batch: ${[...duplicates].join(", ")}`); + } + + const actorRaw = (args.actor ?? {}) as { id?: string; name?: string }; + const result = await setStockQuantitiesBulk({ + pb, + lines, + mode, + dryRun: args.dry_run === true, + actor: { + id: typeof actorRaw.id === "string" ? actorRaw.id : "", + name: + typeof actorRaw.name === "string" && actorRaw.name ? actorRaw.name : "Giellie", + }, + }); + return jsonRpcOk(body.id, result); + } + // ─── Sprint + buyer tasks ────────────────────────────────────── if (toolName === "sprint.list") { const page = Number(args.page || 1);