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:
parent
f76b235e9a
commit
106af81934
1 changed files with 142 additions and 2 deletions
|
|
@ -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") {
|
||||
|
|
|
|||
Loading…
Reference in a new issue