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);