feat(mcp): order_items.create accepts exclude_from_invoice + doc updates

Task 9 of the non-invoiceable-order-extras plan.

order_items.create MCP tool:
- Input schema gains an optional boolean 'exclude_from_invoice'.
- Tool description rewritten to explain the supplier-side component
  use case, the PO + driver + collection flow, the silent drop from
  the customer invoice and delivery note, and the force-zero selling
  price.
- operationData hint / nextActions block updated — the prior text
  documented a '409 order_locked' refusal that no longer fires
  (Task 2 removed the pending gate).
- Handler forwards the boolean to createOrderItemForOrder (which
  itself forces selling_price=0 when the flag is set — Task 2).

Also fixed a TypeScript strict-mode complaint in the T7 test that
the previous commit left behind (vi.fn mock-call tuple type
needed an explicit parameter so mock.calls[0] is typed).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Dave 2026-08-30 17:46:06 +02:00
parent f7a79e671a
commit 12657d126b
2 changed files with 22 additions and 11 deletions

View file

@ -14,7 +14,7 @@ const {
// Spy on the PDF renderer so the test can assert which lines the
// route passes in. Default returns a tiny PDF buffer so the upload
// step in the route has something to upload.
const renderDeliveryNotePdfMock = vi.fn(async () =>
const renderDeliveryNotePdfMock = vi.fn(async (_input: unknown) =>
Buffer.from("%PDF-1.4\n"),
);
return {
@ -391,7 +391,7 @@ describe("POST /api/invoicing/delivery-note", () => {
// sees them. Capturing this directly via the render mock is
// what catches the load-bearing mutation in this split.
expect(renderDeliveryNotePdfMock).toHaveBeenCalledTimes(1);
const renderInput = renderDeliveryNotePdfMock.mock.calls[0][0] as {
const renderInput = renderDeliveryNotePdfMock.mock.calls[0]?.[0] as unknown as {
lines: Array<{ description?: string }>;
};
expect(renderInput.lines).toHaveLength(2);

View file

@ -472,10 +472,10 @@ const TOOL_HINTS: Record<string, { hint: string; nextActions: string[] }> = {
],
},
"order_items.create": {
hint: "Create can fail with 409 order_locked if any line item on the parent order is past 'pending'. Returns per-row created/failed arrays.",
hint: "Create returns per-row created/failed arrays. Pass exclude_from_invoice: true to mark the line as supplier-side (gets a PO + driver + collection but never appears on the customer invoice or delivery note); the server forces selling_price to 0 when the flag is set.",
nextActions: [
"For orders with POs in flight, split first via orders.split and add the line to the continuation order",
"selling_price auto-derives from cost_price × (1 + markup/100) when omitted",
"selling_price auto-derives from cost_price × (1 + markup/100) when omitted (and is forced to 0 when exclude_from_invoice is true)",
"For non-invoiceable extras — the supplier-side component of a customer-approved 'one-line' quote — set exclude_from_invoice: true so the line rides along for PO/driver/collection but drops out of the customer invoice and delivery note",
],
},
"orders.add_attachment": {
@ -993,12 +993,15 @@ const tools = [
name: "order_items.create",
description:
"Add a new line item to an existing order. Mirrors the REST POST /api/v1/orders/{id}/items endpoint.\n\n" +
"**Strict pending gate**: refuses with 409 (`order_locked`) if any line item on the order is past " +
"`item_status === \"pending\"`. Adding a line to an order with a PO in flight would orphan the " +
"PO's pricing / supplier reference. Operator must split the order (use `orders.split` to create a " +
"continuation order) or finish the existing PO first.\n\n" +
"`selling_price` auto-derives from `cost_price * (1 + markup / 100)` when omitted. " +
"Per-row failures never abort the whole call (returns `created[]` + `failed[]`).",
"Per-row failures never abort the whole call (returns `created[]` + `failed[]`).\n\n" +
"Set `exclude_from_invoice: true` for a supplier-side component line — the supplier-side split of a " +
"customer-approved single-line quote (e.g. the customer bought 'M20 bolt, nut and washer' as one priced " +
"line, but we have to raise three PO lines to actually buy it). The line gets a PO + driver + collection " +
"like any other row, rides along for the n8n status flip, but is dropped from the QuickBooks `items` array " +
"and the customer delivery-note `lines:` so the customer never sees it. When the flag is set, `selling_price` " +
"is forced to 0 regardless of what the caller passes (the flag is authoritative — the price column is " +
"not consulted downstream).",
inputSchema: {
type: "object",
required: ["order_id", "description"],
@ -1008,11 +1011,15 @@ const tools = [
quantity: { type: "number", description: "Order quantity. Default 1." },
cost_price: { type: "number", description: "Cost price (supplier cost). Default 0." },
markup: { type: "number", description: "Margin %. Default 0." },
selling_price: { type: "number", description: "Customer-facing unit price. Auto-derived from cost_price × (1 + markup/100) when omitted." },
selling_price: { type: "number", description: "Customer-facing unit price. Auto-derived from cost_price × (1 + markup/100) when omitted. Forced to 0 when exclude_from_invoice is true." },
sku: { type: "string" },
unit: { type: "string" },
supplier: { type: "string", description: "PB supplier relation id (15-char)." },
notes: { type: "string" },
exclude_from_invoice: {
type: "boolean",
description: "Mark this line as supplier-side only. See the description above for the full contract. Default false.",
},
},
},
},
@ -4250,6 +4257,10 @@ export async function POST(req: NextRequest) {
unit: typeof args.unit === "string" ? args.unit : undefined,
supplier: typeof args.supplier === "string" ? args.supplier : undefined,
notes: typeof args.notes === "string" ? args.notes : undefined,
exclude_from_invoice:
typeof args.exclude_from_invoice === "boolean"
? args.exclude_from_invoice
: undefined,
},
);
return NextResponse.json({