From 106af81934e818b46ab5015792c181f34ffabf57 Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Wed, 5 Aug 2026 15:52:30 +0200 Subject: [PATCH] feat(mcp): order_items.update for invoice reconciliation + quotes.update regenerate_pdf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app/api/mcp/route.ts | 144 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 142 insertions(+), 2 deletions(-) diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index 17a67f0..7e994a9 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -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; + const patch: Record = {}; + 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") {