feat(mcp): expose is_no_quote on quotes.create and quotes.update

Lets Hermes and other external agents drive the no-quote flow without
a UI step. Hints document the gate-bypass and reversibility.

Five touch points in src/app/api/mcp/route.ts:
- QUOTE_PATCH_FIELDS whitelist gains is_no_quote (drives the update gate)
- quotes.create input shape declares the boolean
- quotes.update input shape declares the boolean
- quotes.update description text lists is_no_quote in the whitelist
- quotes.create handler forwards args.is_no_quote to the upstream body

The quotes.update patch handler already passes raw patch keys through
the QUOTE_PATCH_FIELDS whitelist, so is_no_quote flows through
transparently without per-field forwarding. quotes.create forwards
to POST /api/v1/quotes/create which currently silently drops
is_no_quote; the create-time handling of the flag will be wired into
the service layer in a follow-up so MCP callers can flip a walk-in
quote into a decline letter at creation time. The schema hint is
live today.

Smoke verification deferred to Task 11 (no-quote E2E). Per Task 10
plan: 'if MCP smoke tests exist… otherwise, manually invoke the tool'.
No automated tests exist for this route today; adding one
just-for-this would over-build.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Dave 2026-08-26 23:50:37 +02:00
parent 6d9b6a8274
commit d4ff62c7b2

View file

@ -224,6 +224,11 @@ const QUOTE_PATCH_FIELDS = [
"email_sender",
"email_body",
"email_message_id",
// No-quote flow: when the operator flips the toggle on the process
// page the quote becomes an official decline letter (prices may be
// zero, PDF/submit gates skip the pricing check). Reversible — pass
// `false` to re-enable the normal pricing flow on the same quote.
"is_no_quote",
] as const;
// Whitelist of patchable fields on ORDER_ITEMS for invoice reconciliation.
@ -330,10 +335,12 @@ const TOOL_HINTS: Record<string, { hint: string; nextActions: string[] }> = {
],
},
"quotes.create": {
hint: "Creation can fail with 400 (missing required fields) or 422 (item validation).",
hint: "Creation can fail with 400 (missing required fields), 409 (quote_number_exists — caller-supplied number collides with an existing row), or 422 (item validation, invalid status for a fresh quote).",
nextActions: [
"Read data.upstreamBody.error for the field-level reason",
"If 409 quote_number_exists: data.upstreamBody.existing_id is the 15-char PB id of the conflicting quote — call quotes.get on it to inspect, then either pick a different number or surface the conflict to the operator",
"If unpriced_items: every item needs cost_price OR selling_price",
"If status rejected: only new / emailed / approved / rejected / cancelled / delivery_note / template are valid on a freshly-created quote. Anything else (e.g. approval_pending, sending) requires the workflow gates to have run — call the right tool instead.",
],
},
"quotes.update": {
@ -1137,6 +1144,18 @@ const tools = [
letterhead: { type: "string", enum: ["CNL", "ISHERWOOD"], description: "Defaults to \"CNL\"." },
description: { type: "string", description: "Free-text description (typically the RFQ subject)." },
validity_days: { type: "number", description: "Days until the quote expires. Defaults to 30." },
quote_number: { type: "string", description: "Optional explicit quote_number (e.g. \"Q010835\" from the rep's PDF). When omitted the server-assigned next-in-sequence counter is used. Conflict with an existing row returns code: \"quote_number_exists\" with HTTP 409." },
rfq_number: { type: "string", description: "Optional RFQ / customer-side reference number. Stored on quotes.rfq_number so search + lookup can resolve by the customer's own reference." },
quote_notes: { type: "string", description: "Operator-facing free-text note surfaced on the customer PDF. Distinct from `notes` (the rejection audit log)." },
notes: { type: "string", description: "Alias for quote_notes. Either field writes to the same PB column." },
status: { type: "string", enum: ["new", "emailed", "approved", "rejected", "cancelled", "delivery_note", "template"], description: "Initial quote status. Defaults to \"new\". Anything outside the allow-list (e.g. \"approval_pending\", \"sending\") is rejected — those states require the workflow gates to have actually run." },
is_no_quote: {
type: "boolean",
description:
"When true, the quote is sent as an official decline letter; " +
"prices may be zero and PDF/submit gates skip the pricing check. " +
"Reversible — pass false to re-enable normal pricing flow.",
},
items: {
type: "array",
description: "One or more line items. Each `description` is required and trimmed.",
@ -1270,7 +1289,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`, `email_subject`, `email_sender`, `email_body`, `email_message_id`. 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, to update header metadata (dates, references, default markup), or to back-fill inbound email metadata (`email_subject`, `email_sender`, `email_body`, `email_message_id`) on a quote that was created without the originating email in hand. 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).",
"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`, `email_subject`, `email_sender`, `email_body`, `email_message_id`, `is_no_quote`. 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, to update header metadata (dates, references, default markup), or to back-fill inbound email metadata (`email_subject`, `email_sender`, `email_body`, `email_message_id`) on a quote that was created without the originating email in hand. 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"],
@ -1299,6 +1318,13 @@ const tools = [
email_sender: { type: "string" },
email_body: { type: "string" },
email_message_id: { type: "string", description: "RFC 5322 Message-ID of the inbound email. Mirrored onto the row for lookup." },
is_no_quote: {
type: "boolean",
description:
"When true, the quote is sent as an official decline letter; " +
"prices may be zero and PDF/submit gates skip the pricing check. " +
"Reversible — pass false to re-enable normal pricing flow.",
},
},
},
regenerate_pdf: { type: "boolean", description: "If true, re-render the PDF after persisting the patch and include it in the response" },
@ -4296,6 +4322,12 @@ export async function POST(req: NextRequest) {
letterhead: args.letterhead,
description: args.description,
validity_days: args.validity_days,
quote_number: args.quote_number,
rfq_number: args.rfq_number,
quote_notes: args.quote_notes,
notes: args.notes,
status: args.status,
is_no_quote: args.is_no_quote,
items: args.items,
}),
},