feat(mcp): stock_items.set_quantities

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.
This commit is contained in:
Dave 2026-11-05 23:24:50 +02:00
parent 62faa390d5
commit 2c415d82f1
2 changed files with 179 additions and 1 deletions

View file

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

View file

@ -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<string>();
const duplicates = new Set<string>();
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);