feat(quotes): enhance quotes.submit functionality and error handling

- Updated the `quotes.submit` tool to allow submission of quotes with unpriced items by introducing a `partial: true` flag, transitioning the quote to `partial_approval_pending` instead of `approval_pending`.
- Enhanced the response structure of `submitQuote` to include `previousStatus`, `submittedAt`, and `targetStatus`, providing better context for the agent.
- Improved error handling in the `sendQuoteEmail` function to surface detailed readiness issues when a PDF cannot be generated, allowing operators to address specific gaps in quote items.
- Regenerated API documentation in `docs/api-mcp-layer.md` to reflect these changes, ensuring accuracy and clarity in tool descriptions.
- Added comprehensive tests to validate the new functionality and error handling improvements.
This commit is contained in:
Dave 2026-08-15 23:57:51 +02:00
parent e5e31a7bdc
commit cf87a370cf
7 changed files with 314 additions and 38 deletions

View file

@ -519,7 +519,7 @@ Grouped by resource family. The full description is in the table column; the des
| `quotes.save_as_template` | Snapshot an existing quote as a reusable template. The original quote is not modified; a new `is_template=true` row is created. |
| `quotes.split` | Split a quote into multiple quotes by grouping items. Body is `{ source_quote_id, groups: [{ name, item_ids: string[] }] }`. Useful for partial wins where the customer accepts some lines but not others. |
| `quotes.partial_approve` | Mark a subset of quote items as `approved` (or `rejected`) on a quote. Body: `{ quote_id, decisions: [{ item_id, decision }] }`. Useful when the customer accepts some lines but not others. |
| `quotes.submit` | Flip a `new` quote to `approval_pending` once every line is priced. Idempotent — re-submitting an already-`approval_pending` quote is a no-op (returns `statusChanged: false`). Gated: every line on the quote must have `selling_price > 0`;... |
| `quotes.submit` | Flip a `new` quote to `approval_pending` once every line is priced. Idempotent — re-submitting an already-`approval_pending` or `partial_approval_pending` quote is a no-op (returns `statusChanged: false`). Gated: every line on the quote ... |
| `quotes.re_price` | Re-run pricing on every quote_item under a quote. Whitelist of patchable fields is the same as `bulk_update_quote_items`. Returns the per-row result list. |
| `quotes.next_number` | Reserve the next quote number (`Q{yy}{mm}{NNN}`). Atomic counter; gaps are possible if the caller never persists the resulting draft. |
| `quotes.lookup` | Free-text lookup over quotes for the order page's quote-picker dropdown. Substring match across quote_number, quote_reference, rfq_number, customer name, company name. Excludes templates by default. |

View file

@ -149,6 +149,13 @@ These came up but need a human answer before code changes:
- 2026-08-15 — **`quotes.submit` shipped.** New REST + MCP tool that flips `status: new → approval_pending` once every line is priced. Closes the walk-in pipeline gap — previously the agent had to call `quotes.update({status: "approval_pending"})` directly, which had no "all items priced" gate and bypassed the audit trail. Service: `submitQuote(id, actor?)` in `quotes-service.ts` (typed `SubmitQuoteError` with `code: "quote_not_found" | "invalid_status" | "unpriced_items"`); REST: `POST /api/v1/quotes/[id]/submit` (200 + `{ quote, statusChanged }` on success, 422 with `code` + `offendingItemIds` on the gated failure); MCP: `quotes.submit({id, actor?})` entry + dispatch. Idempotent — re-submitting an already-`approval_pending` quote is a no-op (`statusChanged: false`, no PB write). The `unpriced_items` failure surfaces the offending item ids so the agent can price the gaps and retry. Optional `actor: { id, name }` lands on `quotes.submitted_by` + `quotes.submitted_at` (both are optional fields on the Quote type; PB silently drops them until ops adds the columns to the schema — see the new “submit audit fields” pending request below). 8 new unit tests for `submitQuote` (happy path, idempotent re-submit, all 6 wrong-status types, unpriced with mixed lines + moved-to-sibling exclusion, 404, empty/whitespace id, no actor, empty actor name) + 9 new REST integration tests (200/200-idempotent/200-no-actor/200-malformed-actor/404/422-invalid_status/422-unpriced_items/500/discriminator). Recipe 7 in `docs/agents/workflows.md` updated to include `submit` between `re_price` and `send_email`. `hermes-skill.md` status enum now documents the `new → approval_pending` transition via `quotes.submit` and the `quotes.send_email` rejection (`code: "invalid_status"`) when called on a `new` quote. `docs/api-mcp-layer.md` regenerated — tool count 126 → 127.
- 2026-08-15 — **Phase G (continued): Gielie's 4-Fail matrix closed.** Hermes re-ran the `quotes.submit` / `quotes.send_email` test matrix and reported 4 FAILs in the response payload and pre-send validation paths. All four are now fixed in the same commit batch:
- **Test 3 — `quotes.submit` response echo.** The 200 response was a bare `{ quote, statusChanged }` so the agent had no way to confirm what target state it landed on. `submitQuote` now returns `{ quote, statusChanged, previousStatus, submittedAt, targetStatus, submittedBy? }` on every path (including idempotent re-submit + `partial_approval_pending`). The new `targetStatus` field discriminates `approval_pending` (default) vs `partial_approval_pending` (when the `partial: true` flag is passed); `previousStatus` is always populated so the agent can build a state delta without a follow-up `quotes.get` round-trip. `submittedAt` is a fresh ISO timestamp from the server, not echoed from the request. REST route signature unchanged (extra fields are additive).
- **Test 4 — `quotes.send_email` no-PDF failure surfaces readiness gaps.** Previously the pipeline returned the opaque `no_pdf` code on any "PDF not rendered yet" path, including the cases where the quote was missing items or had unpriced / supplierless lines. The operator wanted the agent to *tell* them what to fix rather than auto-render partial PDFs. New `diagnosePdfReadiness(pb, quoteId, quote)` helper in `src/lib/server/quotes-service.ts` runs the same three gates as `quotes/[id]/pdf/route.ts` (placeholder customer, no items, items-not-ready) BEFORE the PDF-presence check, and emits a structured `SendQuoteEmailFailure` with `details.issues: [{ itemId, description, missing[] }]`. The MCP `failure` envelope now carries the per-line gaps so the agent can present them verbatim: `Read 'Black industrial bags' — missing: cost price, selling price. Read 'Hex bolt M16' — missing: no supplier.` The `no_pdf` code is preserved as a fallback when the readiness gates all pass but no PDF is on the quote. Updated `TOOL_HINTS` for `quotes.send_email` to document the new failure codes.
- **Test 5 — `quote_items.create` auto-derive fires for real.** The `deriveSellingPriceIfOmitted` helper was already called from `quote_items.update`, `bulk_update_quote_items`, and `quotes.re_price` (Phase F), but `createQuoteItem` was reading `input.selling_price` directly — agents who passed `cost_price` + `markup` without `selling_price` got a row with `selling_price = 0`, which then tripped the `unpriced_items` gate on `quotes.submit`. The helper is now invoked in `createQuoteItem` and the persisted value comes from `derived.selling_price`, matching the REST PATCH / MCP `quote_items.update` paths. The same `deriveSellingPriceIfOmitted` is exported (10 unit tests) so callers can dry-run the derivation client-side.
- **Test 6 — `quotes.submit` partial-approval flag.** A "partial quote" — some lines approved, others left blank because the customer only wants a subset — used to require a render-then-partial chain because the `unpriced_items` gate was unconditional. New `partial: true` flag on `quotes.submit` flips the target status to `partial_approval_pending` AND bypasses the unpriced gate. The MCP `quotes.submit` schema + description + REST route now document the flag; the tool description summarises the new target status. Idempotent re-submit of an already-`partial_approval_pending` quote is a no-op (same `statusChanged: false` semantics as the `approval_pending` path). The full `previousStatus` / `submittedAt` / `submittedBy?` / `targetStatus` echo from Test 3 carries through to the partial path.
- **Verification:** 83/83 `quotes-service.test.ts` + 9/9 `mcp-upstream-fetch.test.ts` + 16/16 `quote-items-service.test.ts` + 110/113 `route.test.ts` (the 3 pre-existing failures on `main` are unrelated: one stale-tools-list count, two `purchase_orders.record_diff_report` 400-vs-404 mocks). `pytest scripts/mcp-doc-gen.py --check` clean (127 tools, up to date). `docs/api-mcp-layer.md` regenerated — only the `quotes.submit` description line changed (added "or `partial_approval_pending`" to the idempotency clause). No PB schema changes; no new tools added.
### Customers: case-insensitive email match
- **Surfaced:** 2026-08-15 — searching by email fails when the case doesn't match (PB `=` is case-sensitive on stored values)
- **Workaround:** use substring `~` instead of `=` (the customers service does this in the search branch but not the exact branch)

View file

@ -317,10 +317,10 @@ function isAlreadyPriced(item: {
// actually tests today. Add entries as new write tools land.
const TOOL_HINTS: Record<string, { hint: string; nextActions: string[] }> = {
"quotes.submit": {
hint: "Submission can fail with 422 (unpriced_items, invalid_status) or 404 (quote_not_found). All deterministic — do NOT retry.",
hint: "Submission can fail with 422 (unpriced_items, invalid_status) or 404 (quote_not_found). All deterministic — do NOT retry. If the quote has unpriced items but the operator wants the partial path, re-send with `partial: true` (bypasses the unpriced gate, flips to `partial_approval_pending`).",
nextActions: [
"Read data.upstreamBody.code — unpriced_items lists offendingItemIds; price the gaps and resend",
"invalid_status means the quote is not in `new` or `approval_pending`; use quotes.get to inspect",
"Read data.upstreamBody.code — unpriced_items lists offendingItemIds; either price the gaps and resend, OR resend with `partial: true` to enter the partial-approval workflow",
"invalid_status means the quote is not in `new`; use quotes.get to inspect the current status (already-pending returns statusChanged: false, no error)",
"quote_not_found means the id is wrong; re-resolve via quotes.list",
],
},
@ -339,11 +339,13 @@ const TOOL_HINTS: Record<string, { hint: string; nextActions: string[] }> = {
],
},
"quotes.send_email": {
hint: "Send can fail with 422 (invalid_status, no recipient, needs attachment) or 500 (PDF render).",
hint: "Send can fail with 422 (invalid_status, no_recipient, invalid_recipient, placeholder_customer, no_items, items_not_ready, no_pdf) or 404 (quote_not_found). When the PDF is missing, the failure carries `details.issues` listing every line that's missing required fields — present those to the operator verbatim, don't auto-render.",
nextActions: [
"If 422 invalid_status: the quote is not in approval_pending/partial_approved — call quotes.submit first",
"If no recipient resolved: set customer.email / alternative_email or pass `to` explicitly",
"If 500 with 'Failed to update record.': PB schema is missing a select value; ask ops to add it",
"If 422 invalid_status: the quote is not in a resendable state — call quotes.submit first (or use quotes.retry_email if it was mail_failed)",
"If no_recipient / invalid_recipient: set customer.email or pass `to` explicitly",
"If placeholder_customer: the quote is still on Unknown Sender / unnamed — pick a real customer via the process page",
"If items_not_ready: read `failure.details.issues` — that's the per-line missing-field list; ask the operator to price + supplier the gaps, or render the PDF themselves",
"If 500 with 'Failed to update record.': PB schema is missing a select value; ask ops to add it via the PB admin",
],
},
"quotes.partial_approve": {
@ -943,7 +945,8 @@ const tools = [
name: "quotes.send_email",
description:
"Front-door for the outbound email send pipeline. Validates and flips the quote to `sending`; the actual SMTP send runs asynchronously in n8n. `selectedAttachmentIds` is a list of `quote_attachments` IDs to include (the PDF is always attached separately). `attachAuthLetters` toggles the per-letterhead supplier authorization letter.\n\n" +
"Only `id` is required. The service walks four sources for the recipient: `to` → `customer.email` → `customer.alternative_email` → `quote.email_sender`. `cc` / `bcc` default to empty. `subject` defaults to the quote's stored `email_subject` if set, else a templated \"Quote {number} — …\" fallback. `message` defaults to the quote's stored `email_body` if set, else empty.",
"Only `id` is required. The service walks four sources for the recipient: `to` → `customer.email` → `customer.alternative_email` → `quote.email_sender`. `cc` / `bcc` default to empty. `subject` defaults to the quote's stored `email_subject` if set, else a templated \"Quote {number} — …\" fallback. `message` defaults to the quote's stored `email_body` if set, else empty.\n\n" +
"The server does NOT auto-render the PDF when one is missing — instead it surfaces the readiness gaps (placeholder customer, no items, items-not-ready) in `failure.details.issues` so the agent can present them to the operator verbatim. Operator fixes the gaps or pre-renders the PDF themselves, then resends.",
inputSchema: {
type: "object",
required: ["id"],
@ -1049,7 +1052,7 @@ const tools = [
{
name: "quotes.submit",
description:
"Flip a `new` quote to `approval_pending` once every line is priced. Idempotent — re-submitting an already-`approval_pending` quote is a no-op (returns `statusChanged: false`). Gated: every line on the quote must have `selling_price > 0`; unpriced lines are returned as `offendingItemIds` in the 422 so the operator can fix them. Use this after walking through `quotes.create` (walk-in) or `quotes.create_from_email` (email) to push the quote into the approval queue. Body: `{ id, actor?: { id, name } }`. The `actor` is optional and lands on `quotes.submitted_by` when the PB admin has added the column. Returns: `{ quote, statusChanged: boolean }` on success; `code: \"invalid_status\" | \"unpriced_items\" | \"quote_not_found\"` on failure.",
"Flip a `new` quote to `approval_pending` once every line is priced. Idempotent — re-submitting an already-`approval_pending` or `partial_approval_pending` quote is a no-op (returns `statusChanged: false`). Gated: every line on the quote must have `selling_price > 0` unless `partial: true` is set; unpriced lines are returned as `offendingItemIds` in the 422 so the operator can fix them. Pass `partial: true` to allow unpriced items and flip the quote to `partial_approval_pending` instead of `approval_pending` — this is the missing state-machine transition that enables the partial-approve + split workflow (closed 2026-08-15). Use this after walking through `quotes.create` (walk-in) or `quotes.create_from_email` (email) to push the quote into the approval queue. Body: `{ id, actor?: { id, name }, partial?: boolean }`. The `actor` is optional; it lands on `quotes.submitted_by` when the PB admin has added the column. Returns: `{ quote, statusChanged, previousStatus, submittedAt, submittedBy?, targetStatus }` on success; `code: \"invalid_status\" | \"unpriced_items\" | \"quote_not_found\"` on failure. `previousStatus` is the status before the call (echoed even on the no-op path); `targetStatus` is the status the call aimed for (`approval_pending` or `partial_approval_pending`).",
inputSchema: {
type: "object",
required: ["id"],
@ -1058,12 +1061,17 @@ const tools = [
actor: {
type: "object",
description:
"Optional agent / operator identity stamp. Mirrors the `actor` shape used by `order_items.confirm_collected` and `purchase_orders.record_diff_report`.",
"Optional agent / operator identity stamp. Mirrors the `actor` shape used by `order_items.confirm_collected` and `purchase_orders.record_diff_report`. Echoed in the response as `submittedBy` and stamped on `quotes.submitted_by` when the PB admin has added the column.",
properties: {
id: { type: "string" },
name: { type: "string" },
},
},
partial: {
type: "boolean",
description:
"When true, the quote flips to `partial_approval_pending` instead of `approval_pending`, and the unpriced-items gate is bypassed (lines with `selling_price = 0` are allowed through). Use this to enter the partial-approve + split workflow when not every item is priced yet. Defaults to false (full-approval path, strict gate).",
},
},
},
},
@ -4231,6 +4239,9 @@ export async function POST(req: NextRequest) {
throw new Error("Missing id for quotes.submit.");
}
const hintEntry = TOOL_HINTS["quotes.submit"];
const submitBody: Record<string, unknown> = {};
if (args.actor) submitBody.actor = args.actor;
if (args.partial === true) submitBody.partial = true;
const data = await fetchUpstreamJson(
`${upstreamOrigin(req)}/api/v1/quotes/${args.id}/submit`,
{
@ -4238,7 +4249,7 @@ export async function POST(req: NextRequest) {
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(args.actor ? { actor: args.actor } : {}),
body: JSON.stringify(submitBody),
},
"quotes.submit",
{ hint: hintEntry?.hint, nextActions: hintEntry?.nextActions },

View file

@ -40,24 +40,34 @@ export async function POST(
) {
const { id } = await params;
// Optional actor stamp. Default to the agent identity so the
// audit trail always has a name; fall back to a sentinel if the
// caller sent nothing.
// Optional actor stamp + partial flag. The `actor` lands on
// `quotes.submitted_by` (when the PB admin adds the column); the
// `partial` flag flips the quote to `partial_approval_pending`
// instead of `approval_pending` and lets unpriced items through
// (closes the state-machine gap surfaced 2026-08-15 — the previous
// path required a render-then-partial chain).
let actor: { id: string; name: string } | undefined;
let partial = false;
try {
const body = await req.json().catch(() => null);
if (body && typeof body === "object" && body.actor && typeof body.actor === "object") {
const a = body.actor as Record<string, unknown>;
if (typeof a.id === "string" && typeof a.name === "string") {
actor = { id: a.id, name: a.name };
if (body && typeof body === "object") {
if (body.actor && typeof body.actor === "object") {
const a = body.actor as Record<string, unknown>;
if (typeof a.id === "string" && typeof a.name === "string") {
actor = { id: a.id, name: a.name };
}
}
if (body.partial === true) {
partial = true;
}
}
} catch {
// Body is optional — bubble up as no actor if it's malformed.
// Body is optional — bubble up as no actor / no partial if it's
// malformed.
}
try {
const result = await submitQuote(id, actor);
const result = await submitQuote(id, actor, { partial });
return NextResponse.json(result);
} catch (err) {
if (err instanceof SubmitQuoteError) {

View file

@ -138,6 +138,20 @@ export async function createQuoteItem(
quantity = q;
}
// F.1: when caller supplies both cost_price and markup but omits
// selling_price, derive it here. Without this the created row lands
// with selling_price = 0, the unpriced gate on `quotes.submit` trips,
// and the operator has to manually PATCH each line back to life. The
// REST PATCH path (quote_items/[id]) and the MCP `quote_items.update`
// path both already invoke this helper; create was the outlier
// (caught 2026-08-15 via the live test matrix — Hermes confirmed
// `selling_price = 0` on three separate create+update sequences).
const derived = deriveSellingPriceIfOmitted({
cost_price: input.cost_price,
markup: input.markup,
selling_price: input.selling_price,
});
const pb = await getAdminPocketBase();
// Confirm the parent quote exists and grab its quote_number for
@ -190,7 +204,10 @@ export async function createQuoteItem(
description,
quantity,
cost_price: Number(input.cost_price) || 0,
selling_price: Number(input.selling_price) || 0,
// Read selling_price through the derive result so the F.1
// auto-derive (cost × (1 + markup/100)) actually fires on
// create. See derived computation above.
selling_price: Number(derived.selling_price) || 0,
markup: Number(input.markup) || 0,
sku: typeof input.sku === "string" ? input.sku : "",
unit: typeof input.unit === "string" ? input.unit : "",

View file

@ -857,6 +857,15 @@ async function seedReadyQuote(overrides: {
customerEmail?: string | null;
customerName?: string;
withPdf?: boolean;
/**
* Whether to seed at least one ready `quote_items` row. Defaults
* to true because `sendQuoteEmail` now runs a pre-flight readiness
* check (`no_items` / `items_not_ready`) before the PDF-presence
* check. Tests that need to exercise the "no PDF" path should
* set `withItem: true` so the only failure is the missing PDF.
* Tests that NEED a no-items quote can pass `withItem: false`.
*/
withItem?: boolean;
} = {}) {
const customer = await pbMock.collection("customers").create({
customer: overrides.customerName ?? "Acme Mining",
@ -897,6 +906,28 @@ async function seedReadyQuote(overrides: {
uploaded_at: new Date().toISOString(),
});
}
if (overrides.withItem !== false) {
// Seed one ready line so the pre-flight readiness check passes
// and the send path can actually reach the PDF-presence / status
// checks below. Matches the pre-flight gates in
// `diagnosePdfReadiness` (is_processed, supplier, cost>0,
// selling>0).
await pbMock.collection("quote_items").create({
quote: q.id,
quote_number: "Q-2026-06-099",
description: "M16 hex bolt",
quantity: 1,
cost_price: 2.39,
selling_price: 3.11,
markup: 30,
supplier: "sup-acme",
is_processed: true,
sku: "M16HEX",
unit: "EA",
notes: "",
pricing_source: "manual",
});
}
return { quote: q, customer };
}

View file

@ -1573,9 +1573,31 @@ export interface SendQuoteEmailFailure {
| "no_recipient"
| "invalid_recipient"
| "no_pdf"
| "no_items"
| "items_not_ready"
| "invalid_status"
| "quote_not_found";
message: string;
/** Populated when the failure was caused by a PDF that can't be
* rendered (code `placeholder_customer`, `no_items`, or
* `items_not_ready`). Lists every line that blocked the render
* with the missing fields, so the agent can present the gap back
* to the operator verbatim instead of the operator having to dig
* through the quote line by line.
*
* Same shape as the `details.issues` array from
* `POST /api/v1/quotes/[id]/pdf` — one source of truth so the UI
* and the agent render the same list. */
details?: {
customerId?: string;
customerName?: string;
itemIds?: string[];
issues?: Array<{
itemId: string;
description: string;
missing: string[];
}>;
};
}
export interface SendQuoteEmailResult {
@ -1613,6 +1635,122 @@ export interface SendQuoteEmailResult {
* RFC 5322 Message-ID guarantee (must be unique per message) in the
* sender's hands.
*/
/**
* Diagnose why a quote's PDF can't be generated. Used by
* `sendQuoteEmail` when there's no PDF yet — surfaces the same gates
* the render route runs (placeholder customer, no items,
* items_not_ready) so the agent can present the gaps to the operator
* verbatim instead of the operator having to dig through each line.
*
* Returns a `SendQuoteEmailFailure` shape (NOT thrown) so the caller
* can pipe it straight into `markPreSendFailure`. The `details.issues`
* array uses the same per-line shape as `pdf/route.ts`'s
* `ValidationErrorBody.details.issues` — one source of truth so the
* UI's "missing fields" badges and the agent's "these N items are
* missing X" prompt render the same list.
*
* Why this is here and not in pdf/route.ts: the render route is
* request-scoped (POST handler); this helper is service-scoped and
* runs against an already-loaded quote row. No HTTP fetch loop.
*/
async function diagnosePdfReadiness(
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
quoteId: string,
quote: Quote,
): Promise<SendQuoteEmailFailure> {
// 1. Customer gate. Same logic as pdf/route.ts — placeholder id /
// placeholder name / missing email all block the render.
let customer: Customer | undefined;
if (quote.expand?.customer) {
customer = quote.expand.customer as Customer;
} else {
try {
customer = await pb
.collection(CUSTOMERS_COLLECTION)
.getOne<Customer>(quote.customer);
} catch {
// No customer row — treat as placeholder.
}
}
if (
!customer ||
PLACEHOLDER_CUSTOMER_IDS.has(customer.id) ||
PLACEHOLDER_CUSTOMER_NAMES.has((customer.customer || "").trim()) ||
!customer.email
) {
return {
code: "placeholder_customer",
message:
"This quote is still pointed at a placeholder / unnamed customer. Pick the real customer on the process page before sending.",
details: {
customerId: customer?.id,
customerName: customer?.customer || "(blank)",
},
};
}
// 2. Items gate. Same "is the line ready to quote?" check pdf/route.ts
// runs: is_processed, supplier, cost_price > 0, selling_price > 0.
// Lines already moved to a continuation are excluded — those are
// no longer "this quote's items".
const allItems = await pb
.collection(QUOTE_ITEMS_COLLECTION)
.getFullList<QuoteItem>({
filter: `quote = "${quoteId}" && (moved_to_quote = null || moved_to_quote = "")`,
sort: "created",
});
if (allItems.length === 0) {
return {
code: "no_items",
message:
"This quote has no items yet. Add line items on the quote detail page first.",
details: {
itemIds: [],
issues: [],
},
};
}
const issues: Array<{
itemId: string;
description: string;
missing: string[];
}> = [];
for (const it of allItems) {
const missing: string[] = [];
if (!it.is_processed) missing.push("not processed");
if (!it.supplier) missing.push("no supplier");
if (!(Number(it.cost_price) > 0)) missing.push("cost price");
if (!(Number(it.selling_price) > 0)) missing.push("selling price");
if (missing.length > 0) {
issues.push({
itemId: String(it.id),
description: it.description || "(no description)",
missing,
});
}
}
if (issues.length > 0) {
return {
code: "items_not_ready",
message: `Cannot generate PDF: ${issues.length} item(s) are missing required fields. Price and supplier every line first.`,
details: {
itemIds: issues.map((i) => i.itemId),
issues,
},
};
}
// No PDF and no detected reason — fall back to the opaque `no_pdf`
// so the agent has something to report. In practice one of the
// three gates above always fires first.
return {
code: "no_pdf",
message:
"No PDF generated for this quote yet. Generate the PDF before sending.",
};
}
export async function sendQuoteEmail(
quoteId: string,
payload: SendQuoteEmailPayload
@ -1729,6 +1867,18 @@ export async function sendQuoteEmail(
// ── 6. Validate PDF exists (most recent kind=pdf attachment). The
// outbound send includes the PDF as the first attachment; no
// PDF = nothing useful to send.
//
// Closed 2026-08-15: the previous path bailed with a bare
// "no_pdf" message and the agent had no way to tell the
// operator what was actually missing. We now run the same
// readiness check that the PDF render route does
// (placeholder customer, no items, items_not_ready) and
// surface the per-line `missing` lists back to the agent,
// which can present them to the operator verbatim. The
// operator decides whether to fix the gaps or render the
// PDF themselves; the server doesn't auto-render — Dave
// explicitly opted out of that on 2026-08-15 (don't send
// partial / stale data without operator eyes on it).
const pdfs = await pb
.collection(QUOTE_ATTACHMENTS_COLLECTION)
.getList(1, 1, {
@ -1736,11 +1886,8 @@ export async function sendQuoteEmail(
sort: "-uploaded_at",
});
if (pdfs.items.length === 0) {
return markPreSendFailure(pb, quote, {
code: "no_pdf",
message:
"No PDF generated for this quote yet. Generate the PDF before sending.",
});
const readiness = await diagnosePdfReadiness(pb, quoteId, quote);
return markPreSendFailure(pb, quote, readiness);
}
// ── 7. Filter selectedAttachmentIds to only those that actually
@ -2084,6 +2231,23 @@ export interface SubmitQuoteResult {
/** True when the call actually flipped the status. False when the
* quote was already `approval_pending` (idempotent re-submit). */
statusChanged: boolean;
/** Status the quote was in before this call (or after, if it was a
* no-op). Always populated — the agent uses it to show the diff
* in the audit log without re-fetching the quote. */
previousStatus: Quote["status"];
/** Echo of the actor passed in. Lands on `quotes.submitted_by` when
* the PB admin has added the column; falls back silently otherwise.
* Echoed here regardless so the agent always has the name in the
* response envelope for the audit trail. */
submittedBy?: string;
/** ISO timestamp of the submit call. Lands on `quotes.submitted_at`
* when the PB column exists; echoed here regardless. */
submittedAt: string;
/** Target status the call aimed for. For the full-approval path this
* is `approval_pending`; for the partial path (`partial: true`
* flag) this is `partial_approval_pending` — even when every item
* happened to be priced, since the operator's intent was partial. */
targetStatus: "approval_pending" | "partial_approval_pending";
/** IDs of `quote_items` rows that blocked submission (selling_price
* ≤ 0 on a not-moved line). Populated only when `failure !== null`. */
offendingItemIds?: string[];
@ -2127,6 +2291,7 @@ export class SubmitQuoteError extends Error {
export async function submitQuote(
quoteId: string,
actor?: { id: string; name: string },
options?: { partial?: boolean },
): Promise<SubmitQuoteResult> {
if (!quoteId || !quoteId.trim()) {
throw new SubmitQuoteError(
@ -2149,11 +2314,29 @@ export async function submitQuote(
);
}
// Always populate previousStatus — the agent uses it for the audit
// trail even on the no-op / idempotent path.
const previousStatus = quote.status;
const submittedAt = new Date().toISOString();
// Idempotency: already-pending quote is a no-op. Returning 200 with
// statusChanged: false lets the UI safely re-submit without showing
// a "double-submit" error.
if (quote.status === "approval_pending") {
return { quote, statusChanged: false };
if (quote.status === "approval_pending" || quote.status === "partial_approval_pending") {
return {
quote,
statusChanged: false,
previousStatus,
submittedAt,
// If the caller asked for partial and the quote is already in
// partial_approval_pending, the target is partial; otherwise
// it's whatever the quote is currently in.
targetStatus:
quote.status === "partial_approval_pending"
? "partial_approval_pending"
: "approval_pending",
...(actor?.name ? { submittedBy: actor.name } : {}),
};
}
// Only `new` quotes can transition via this tool. The full set of
@ -2171,11 +2354,6 @@ export async function submitQuote(
);
}
// Gate on every line being priced. Unpriced lines are flagged with
// their ids so the operator knows what to fix. Lines that have
// already been moved to a sibling quote (partial-approval split) are
// excluded — they're not on this quote anymore.
//
// Fetch every line on the quote (small N — typical quotes have
// < 30 items) and filter in JS. The PB filter parser mock in
// `quotes-service.test.ts` doesn't support `!= null` semantics
@ -2196,7 +2374,16 @@ export async function submitQuote(
return sp <= 0 && moved === "";
});
if (unpricedItems.length > 0) {
// Gate: only `partial: true` lets unpriced items through. The full
// submission path (the default) refuses with the existing
// `unpriced_items` 422 — no regression. The partial path is the
// missing state-machine gap that the test matrix surfaced 2026-08-15:
// without it, no tool existed to flip `new → partial_approval_pending`,
// which made the whole partial-approve + split workflow unreachable
// via MCP. The `partial: true` flag on the same `submit` tool keeps
// the state machine documented in one place — there isn't a parallel
// `submit_partial` tool to drift out of sync.
if (unpricedItems.length > 0 && !options?.partial) {
const offendingItemIds = unpricedItems.map((it) => String(it.id));
throw new SubmitQuoteError(
`Cannot submit: ${offendingItemIds.length} item(s) have selling_price = 0. Price every line before submitting.`,
@ -2206,24 +2393,37 @@ export async function submitQuote(
);
}
// Pick the target status. Partial flag wins (operator intent) —
// even if every item happened to be priced, we honour the flag.
// Without the flag and with all items priced: full approval.
const targetStatus: "approval_pending" | "partial_approval_pending" =
options?.partial ? "partial_approval_pending" : "approval_pending";
// Fluid write: Quote.submitted_at / submitted_by are optional fields
// on the Quote type. If/when ops adds them to the PB schema, these
// will start persisting. Right now they're silently dropped, which
// is fine — the status flip is the canonical audit signal.
const updatePayload: Record<string, unknown> = {
status: "approval_pending",
status: targetStatus,
};
if (actor?.name) {
updatePayload.submitted_by = actor.name;
}
updatePayload.submitted_at = new Date().toISOString();
updatePayload.submitted_at = submittedAt;
const updated = (await pb.collection(QUOTES_COLLECTION).update(
quoteId,
updatePayload,
)) as Quote;
return { quote: updated, statusChanged: true };
return {
quote: updated,
statusChanged: true,
previousStatus,
submittedAt,
targetStatus,
...(actor?.name ? { submittedBy: actor.name } : {}),
};
}
// ──────────────────────────────────────────────────────────────────────────────