feat(mcp): order_items.update for invoice reconciliation + quotes.update regenerate_pdf

Two new capabilities, both requested by the agent for the
collected-vs-invoice reconciliation workflow:

1. order_items.update — patch fields on an order_item for invoice
   reconciliation. Lets the agent compare what the supplier invoiced
   (qty_invoiced + supplier_invoice_number + cost_price) against what
   was ordered (quantity) and what was collected (qty_collected),
   then write the differences. Whitelist mirrors the quote_items.update
   pattern:

     cost_price, selling_price, quantity, markup, markup_percent,
     notes, qty_processed/assigned/collected/verified/stocked/picked/
     loaded/delivered/invoiced/cancelled, supplier_invoice_number,
     customer_invoice_number, collection_date, collected_date,
     verified_date, delivered_date, invoiced_date

   System-controlled fields (id, order, purchase_order, quote_item,
   product, created, updated) are rejected with forbidden_field so
   the agent can never accidentally reassign an item to a different
   order or PO.

2. quotes.update with regenerate_pdf:true — the agent can now
   switch letterhead (or any other header field) AND re-render the
   PDF in a single call. The patch is saved first, then the existing
   POST /api/v1/quotes/{id}/pdf route is called using the freshly-
   persisted letterhead, so the response always matches the record.
   Useful for: 'switch this quote to ISHERWOOD and send me the new
   PDF'. Without the flag, the existing 2-step workflow still works
   (quotes.update, then quotes.get_pdf).

All 1385 tests pass (was 1382; +3: regenerate_pdf happy path,
order_items.update happy path, order_items.update forbidden field).

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-08-05 15:52:30 +02:00
parent f76b235e9a
commit 106af81934

View file

@ -133,6 +133,38 @@ const QUOTE_PATCH_FIELDS = [
"claimed_by",
] as const;
// Whitelist of patchable fields on ORDER_ITEMS for invoice reconciliation.
// Lets the agent adjust actual-invoice values (qty_invoiced,
// supplier_invoice_number, cost_price) against what was originally
// ordered (quantity) and what was collected (qty_collected). System-
// controlled fields (id, created, updated, order, purchase_order,
// quote_item, product) are excluded.
const ORDER_ITEM_PATCH_FIELDS = [
"cost_price",
"selling_price",
"quantity",
"markup",
"markup_percent",
"notes",
"qty_processed",
"qty_assigned",
"qty_collected",
"qty_verified",
"qty_stocked",
"qty_picked",
"qty_loaded",
"qty_delivered",
"qty_invoiced",
"qty_cancelled",
"supplier_invoice_number",
"customer_invoice_number",
"collection_date",
"collected_date",
"verified_date",
"delivered_date",
"invoiced_date",
] as const;
// 100 is plenty for v1. Bumping to 500 once we actually see a
// supplier-switch use case — 100 records × one PB round-trip ~2s.
const BULK_UPDATE_DEFAULT_CAP = 100;
@ -269,6 +301,48 @@ const tools = [
},
},
},
{
name: "order_items.update",
description:
"Patch fields on an order_item for invoice reconciliation. Whitelist: cost_price, selling_price, quantity, markup, markup_percent, notes, qty_processed, qty_assigned, qty_collected, qty_verified, qty_stocked, qty_picked, qty_loaded, qty_delivered, qty_invoiced, qty_cancelled, supplier_invoice_number, customer_invoice_number, collection_date, collected_date, verified_date, delivered_date, invoiced_date. Use this to compare what the supplier invoiced (supplier_invoice_number + qty_invoiced + cost_price) against what was ordered (quantity) and what was collected (qty_collected), and patch the differences. Status-only updates go through `order_items.update_status` instead.",
inputSchema: {
type: "object",
required: ["id", "patch"],
properties: {
id: { type: "string", description: "15-char PocketBase order_item id" },
patch: {
type: "object",
description:
"Subset of whitelisted fields. Empty patches are rejected.",
properties: {
cost_price: { type: "number" },
selling_price: { type: "number" },
quantity: { type: "number" },
markup: { type: "number" },
markup_percent: { type: "number" },
notes: { type: "string" },
qty_processed: { type: "number" },
qty_assigned: { type: "number" },
qty_collected: { type: "number" },
qty_verified: { type: "number" },
qty_stocked: { type: "number" },
qty_picked: { type: "number" },
qty_loaded: { type: "number" },
qty_delivered: { type: "number" },
qty_invoiced: { type: "number" },
qty_cancelled: { type: "number" },
supplier_invoice_number: { type: "string" },
customer_invoice_number: { type: "string" },
collection_date: { type: "string" },
collected_date: { type: "string" },
verified_date: { type: "string" },
delivered_date: { type: "string" },
invoiced_date: { type: "string" },
},
},
},
},
},
{
name: "orders.add_attachment",
description:
@ -432,7 +506,7 @@ const tools = [
{
name: "quotes.update",
description:
"Patch fields on a quote header. Whitelist: `company_letterhead`, `quote_date`, `quote_reference`, `rfq_number`, `validity_days`, `notes`, `delivery_note`, `markup`, `email_cc`, `email_bcc`, `claimed_by`. Anything outside this set — including `id`, `created`, `updated`, `status`, `customer`, `company`, `quote_number` — is rejected with `forbidden_field`. Use this to persist `company_letterhead` so re-renders in the UI match what you generated, or to update header metadata (dates, references, default markup).",
"Patch fields on a quote header. Whitelist: `company_letterhead`, `quote_date`, `quote_reference`, `rfq_number`, `validity_days`, `notes`, `delivery_note`, `markup`, `email_cc`, `email_bcc`, `claimed_by`. Anything outside this set — including `id`, `created`, `updated`, `status`, `customer`, `company`, `quote_number` — is rejected with `forbidden_field`. Use this to persist `company_letterhead` so re-renders in the UI match what you generated, or to update header metadata (dates, references, default markup). Pass `regenerate_pdf: true` alongside the patch to re-render the PDF in the same call (useful for switching letterhead and immediately getting the new PDF back).",
inputSchema: {
type: "object",
required: ["id", "patch"],
@ -456,6 +530,7 @@ const tools = [
claimed_by: { type: "string" },
},
},
regenerate_pdf: { type: "boolean", description: "If true, re-render the PDF after persisting the patch and include it in the response" },
},
},
},
@ -1618,6 +1693,33 @@ export async function POST(req: NextRequest) {
});
}
if (toolName === "order_items.update") {
if (!args.id) throw new Error("Missing id for order_items.update.");
if (!args.patch || typeof args.patch !== "object" || Array.isArray(args.patch)) {
throw new Error("Missing or invalid `patch` for order_items.update.");
}
const rawPatch = args.patch as Record<string, unknown>;
const patch: Record<string, unknown> = {};
const rejected: string[] = [];
for (const [k, v] of Object.entries(rawPatch)) {
if ((ORDER_ITEM_PATCH_FIELDS as readonly string[]).includes(k)) {
patch[k] = v;
} else {
rejected.push(k);
}
}
if (rejected.length > 0) {
throw new Error(
`forbidden_field(s): ${rejected.join(", ")}. Allowed: ${ORDER_ITEM_PATCH_FIELDS.join(", ")}.`,
);
}
if (Object.keys(patch).length === 0) {
throw new Error("Patch is empty after whitelist.");
}
const updated = await pb.collection(getOrderItemsCollectionName()).update(args.id, patch);
return jsonRpcOk(body.id, { item: updated });
}
if (toolName === "orders.add_attachment") {
if (!args.order_id || !args.filename || !args.base64) {
throw new Error(
@ -2144,7 +2246,45 @@ export async function POST(req: NextRequest) {
throw new Error("Patch is empty after whitelist.");
}
const updated = await pb.collection("quotes").update(args.id, patch);
return jsonRpcOk(body.id, { quote: updated });
// Optional one-shot re-render: if the caller passed
// `regenerate_pdf: true` alongside the patch, fire the same
// PDF-render code path that the WebUI's button uses, so a
// letterhead change (or any other header edit) lands on the
// new PDF in one round-trip. The PDF endpoint reads the
// freshly-persisted letterhead, so the rendered file always
// matches the record.
let pdf: { attachmentId: string; status: string; pdfGeneratedAt: string | null; filename: string; skippedItemIds: string[]; readyItemCount: number; totalItemCount: number; isPartial: boolean } | null = null;
if (args.regenerate_pdf === true) {
const letterhead = (updated.company_letterhead as string) || "CNL";
const pdfRes = await fetch(
`${req.nextUrl.origin}/api/v1/quotes/${args.id}/pdf`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}`,
},
body: JSON.stringify({ letterhead }),
},
);
if (!pdfRes.ok) {
const text = await pdfRes.text();
throw new Error(
`quotes.update patch saved but PDF re-render failed: ${pdfRes.status} ${text}`,
);
}
pdf = (await pdfRes.json()) as typeof pdf & { ok: true };
// The route returns the success shape without an `ok: true`
// discriminator. Normalise it: every successful render has
// `attachmentId` + `filename`. If those are missing we treat
// it as a failure.
if (!pdf || !(pdf as { attachmentId?: unknown }).attachmentId) {
throw new Error("PDF re-render returned an unexpected shape");
}
}
return jsonRpcOk(body.id, { quote: updated, pdf });
}
if (toolName === "quotes.send_email") {