feat(quotes): introduce quotes.submit tool for approval transition

- Added a new `quotes.submit` tool that transitions a quote from `new` to `approval_pending` once all line items are priced. This tool ensures that only fully priced quotes can be submitted for approval, enhancing the integrity of the approval process.
- Updated the API documentation in `docs/api-mcp-layer.md` to include the new `quotes.submit` tool and its usage details.
- Enhanced the `quotes` status enum in `docs/agents/hermes-skill.md` to reflect the new transition and its implications.
- Regenerated documentation to ensure accuracy, increasing the total tool count from 126 to 127.
- Added comprehensive tests for the `submitQuote` function to cover various scenarios, including idempotency and error handling for unpriced items.
This commit is contained in:
Dave 2026-08-15 19:01:52 +02:00
parent c4b53bcc4b
commit 151ae8d67f
14 changed files with 741 additions and 8 deletions

View file

@ -111,8 +111,18 @@ WFDASH has two data layers:
**Quote `status` enum.** The PB `quotes.status` select field has
these values (full state machine):
- `new` — just created from email / template / walk-in.
- `approval_pending` — sent to the customer, awaiting response.
- `new` — just created from email / template / walk-in. Use
`quotes.submit` to push this into the approval queue once
every line item is priced.
- `approval_pending` — every line priced, waiting for the
customer's rep to approve. Reached automatically when
`quotes.submit` succeeds. This is the entry point for the
`quotes.send_email` path (it accepts `approval_pending` in
`RESENDABLE_STATUSES`).
- `partial_approval_pending` — partially priced; some lines
still need `selling_price > 0`. Reached via the operator's
"Submit for partial approval" flow (different from
`quotes.submit` which requires ALL lines priced).
- `emailed` — customer has the PDF; operator is waiting.
- `approved` — customer signed off (full).
- `partial_approved` — customer accepted some lines, rejected
@ -123,6 +133,15 @@ WFDASH has two data layers:
- `problem` — something needs operator attention.
- `cancelled` — abandoned.
The `new → approval_pending` transition is the one most affected
by `quotes.submit` (added 2026-08-15). Walk-in recipes always
end with `submit` before `send_email` — calling `send_email`
directly on a `new` quote fails pre-send validation with
`code: "invalid_status"` because `new` is not in
`RESENDABLE_STATUSES`. `quotes.submit` is idempotent
(re-submitting an `approval_pending` quote returns
`statusChanged: false` and skips the PB write).
Transitions are driven by the operator UI; the MCP tools just set
the field. Don't invent a status — if a transition isn't in this
list, ask the operator before stamping it.

View file

@ -221,7 +221,8 @@ proofs. Find one that's unclaimed and lock it for 30 minutes.
**Goal:** the operator types "make me a quote for John's workshop".
There's no inbound RFQ email — build the quote from scratch with
customer + company + items.
customer + company + items, price it, push it into the approval
queue, then send.
**Sequence:**
@ -238,6 +239,16 @@ customer + company + items.
company: "<id-or-name>" (optional), letterhead: "CNL" |
"ISHERWOOD", validity_days: 30, description: "<free-text
RFQ subject>", items: [...] })`.
4. If you didn't supply prices in step 3, run `quotes.re_price`
to walk the catalogue. Otherwise skip.
5. `quotes.submit({ id: "<quote-id>" })` — flips `status` from
`new` to `approval_pending`. Gated on every line having
`selling_price > 0`; on a 422 (`code: "unpriced_items"`) the
response carries `offendingItemIds` so you can price the gaps
and retry. Idempotent — re-submitting an `approval_pending`
quote is a no-op (`statusChanged: false`).
6. `quotes.send_email({ id: "<quote-id>" })` — the PDF auto-renders
on this call, then n8n takes over the SMTP send.
**Gotchas:**
@ -247,6 +258,12 @@ customer + company + items.
match, a new row is created with the given name and empty
defaults — the operator reassigns it later via the existing
quote-editing form.
- `quotes.submit` is the canonical gate between "priced" and
"ready to send". Bypassing it (e.g. calling `quotes.send_email`
directly on a `new` quote) fails the pre-send validation with
`code: "invalid_status"` because `new` is not in
`RESENDABLE_STATUSES`. Make `submit` part of the walk-in
pipeline.
- The quote auto-renders a PDF on the next `quotes.send_email`
call. Don't pre-render — the send path handles it.
- `quotes.create` is for walk-ins. For inbound RFQ emails use
@ -267,7 +284,7 @@ customer + company + items.
| Bulk pricing | `quote_items.list`, `bulk_update_quote_items`, `quotes.re_price` |
| Partial approve | `quotes.get`, `quotes.partial_approve`, `quotes.split`, `quotes.re_price` |
| Sprint claim | `sprint.list`, `sprint.claim`, `bulk_update_quote_items` |
| Walk-in quote | `customers.list` (resolve), `quotes.create` |
| Walk-in quote | `customers.list` (resolve), `quotes.create`, `quotes.re_price`, `quotes.submit`, `quotes.send_email` |
For the full tool catalogue (auto-generated), see
[`docs/api-mcp-layer.md`](../api-mcp-layer.md).

View file

@ -467,7 +467,7 @@ keys aren't set, so a clean checkout reports `passed, N skipped`.
<!-- BEGIN mcp-doc-gen: tools catalogue — do not edit by hand, regenerate with scripts/mcp-doc-gen.py -->
This catalogue is auto-generated from the `tools` array in `src/app/api/mcp/route.ts` by `scripts/mcp-doc-gen.py`. There are **126 tools** as of this run. Re-run the script after adding or renaming a tool.
This catalogue is auto-generated from the `tools` array in `src/app/api/mcp/route.ts` by `scripts/mcp-doc-gen.py`. There are **127 tools** as of this run. Re-run the script after adding or renaming a tool.
Grouped by resource family. The full description is in the table column; the description text the agent sees is preserved verbatim.
@ -519,6 +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.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. |
@ -705,6 +706,6 @@ Grouped by resource family. The full description is in the table column; the des
### Alphabetical index
`ai_insights.rerun`, `ai_settings.get`, `ai_settings.update`, `attachments.upload_for_quote`, `bulk_update_quote_items`, `buyer_tasks.generate_po`, `buyer_tasks.list`, `buyer_tasks.mark_ready`, `collection_confirm.confirm_order_items`, `collection_confirm.list`, `collection_confirm.upload_invoice`, `collection_pool.list`, `companies.create`, `companies.delete`, `companies.get`, `companies.list`, `companies.update`, `customers.create`, `customers.delete`, `customers.get`, `customers.list`, `customers.update`, `dashboard.daily_summary`, `delivery_notes.cancel`, `delivery_notes.combine`, `delivery_notes.create`, `delivery_notes.create_quote`, `delivery_notes.list`, `delivery_notes.mark_complete`, `delivery_notes.mark_invoiced`, `delivery_notes.regenerate_pdf`, `delivery_notes.set_customer_po`, `delivery_notes.set_item_quantity`, `drivers.list_stops`, `drivers.update_stop_status`, `files.fetch`, `order_items.confirm_collected`, `order_items.get`, `order_items.list`, `order_items.update`, `order_items.update_status`, `orders.add_attachment`, `orders.create`, `orders.create_from_quote`, `orders.find_duplicates`, `orders.get`, `orders.list`, `orders.list_attachments`, `orders.matched_attachments`, `orders.review`, `orders.split`, `orders.sprint_counts`, `price_list.add_items`, `purchase_orders.add_attachment`, `purchase_orders.diff`, `purchase_orders.get`, `purchase_orders.list`, `purchase_orders.record_diff_report`, `purchase_orders.record_send_outcome`, `purchase_orders.retry_email`, `purchase_orders.scan`, `purchase_orders.send_email`, `purchase_orders.stale`, `quote_items.create`, `quote_items.get`, `quote_items.list`, `quote_items.populate_insights`, `quote_items.update`, `quotes.append_reply`, `quotes.clone`, `quotes.create`, `quotes.create_from_email`, `quotes.create_from_template`, `quotes.find_by_message_id`, `quotes.get`, `quotes.get_pdf`, `quotes.ingest_from_email`, `quotes.list`, `quotes.list_attachments`, `quotes.lookup`, `quotes.next_number`, `quotes.partial_approve`, `quotes.process_pending`, `quotes.re_price`, `quotes.re_run_ai`, `quotes.retry_email`, `quotes.save_as_template`, `quotes.send_email`, `quotes.split`, `quotes.update`, `sprint.attachment_counts`, `sprint.claim`, `sprint.item_attachments`, `sprint.list`, `stock.fulfill`, `stock.surplus_book`, `stock_items.adjust`, `stock_items.create`, `stock_items.get`, `stock_items.list`, `stock_items.update`, `stock_purchase_items.assign_driver`, `stock_purchase_items.bulk_update`, `stock_purchase_items.list`, `stock_purchase_items.mark_collected`, `stock_purchase_items.update`, `stock_purchases.add_attachment`, `stock_purchases.create`, `stock_purchases.get`, `stock_purchases.list`, `stock_purchases.match_and_receive_from_invoice`, `stock_purchases.next_number`, `stock_purchases.receive`, `stock_purchases.retry_email`, `stock_purchases.send`, `stock_purchases.send_email`, `stock_quick_draft.add_line`, `stock_quick_draft.search`, `stock_quick_draft.suppliers`, `suppliers.create`, `suppliers.delete`, `suppliers.get`, `suppliers.list`, `suppliers.update`, `tools.discovery`, `users.lookup`
`ai_insights.rerun`, `ai_settings.get`, `ai_settings.update`, `attachments.upload_for_quote`, `bulk_update_quote_items`, `buyer_tasks.generate_po`, `buyer_tasks.list`, `buyer_tasks.mark_ready`, `collection_confirm.confirm_order_items`, `collection_confirm.list`, `collection_confirm.upload_invoice`, `collection_pool.list`, `companies.create`, `companies.delete`, `companies.get`, `companies.list`, `companies.update`, `customers.create`, `customers.delete`, `customers.get`, `customers.list`, `customers.update`, `dashboard.daily_summary`, `delivery_notes.cancel`, `delivery_notes.combine`, `delivery_notes.create`, `delivery_notes.create_quote`, `delivery_notes.list`, `delivery_notes.mark_complete`, `delivery_notes.mark_invoiced`, `delivery_notes.regenerate_pdf`, `delivery_notes.set_customer_po`, `delivery_notes.set_item_quantity`, `drivers.list_stops`, `drivers.update_stop_status`, `files.fetch`, `order_items.confirm_collected`, `order_items.get`, `order_items.list`, `order_items.update`, `order_items.update_status`, `orders.add_attachment`, `orders.create`, `orders.create_from_quote`, `orders.find_duplicates`, `orders.get`, `orders.list`, `orders.list_attachments`, `orders.matched_attachments`, `orders.review`, `orders.split`, `orders.sprint_counts`, `price_list.add_items`, `purchase_orders.add_attachment`, `purchase_orders.diff`, `purchase_orders.get`, `purchase_orders.list`, `purchase_orders.record_diff_report`, `purchase_orders.record_send_outcome`, `purchase_orders.retry_email`, `purchase_orders.scan`, `purchase_orders.send_email`, `purchase_orders.stale`, `quote_items.create`, `quote_items.get`, `quote_items.list`, `quote_items.populate_insights`, `quote_items.update`, `quotes.append_reply`, `quotes.clone`, `quotes.create`, `quotes.create_from_email`, `quotes.create_from_template`, `quotes.find_by_message_id`, `quotes.get`, `quotes.get_pdf`, `quotes.ingest_from_email`, `quotes.list`, `quotes.list_attachments`, `quotes.lookup`, `quotes.next_number`, `quotes.partial_approve`, `quotes.process_pending`, `quotes.re_price`, `quotes.re_run_ai`, `quotes.retry_email`, `quotes.save_as_template`, `quotes.send_email`, `quotes.split`, `quotes.submit`, `quotes.update`, `sprint.attachment_counts`, `sprint.claim`, `sprint.item_attachments`, `sprint.list`, `stock.fulfill`, `stock.surplus_book`, `stock_items.adjust`, `stock_items.create`, `stock_items.get`, `stock_items.list`, `stock_items.update`, `stock_purchase_items.assign_driver`, `stock_purchase_items.bulk_update`, `stock_purchase_items.list`, `stock_purchase_items.mark_collected`, `stock_purchase_items.update`, `stock_purchases.add_attachment`, `stock_purchases.create`, `stock_purchases.get`, `stock_purchases.list`, `stock_purchases.match_and_receive_from_invoice`, `stock_purchases.next_number`, `stock_purchases.receive`, `stock_purchases.retry_email`, `stock_purchases.send`, `stock_purchases.send_email`, `stock_quick_draft.add_line`, `stock_quick_draft.search`, `stock_quick_draft.suppliers`, `suppliers.create`, `suppliers.delete`, `suppliers.get`, `suppliers.list`, `suppliers.update`, `tools.discovery`, `users.lookup`
<!-- END mcp-doc-gen -->

View file

@ -29,6 +29,7 @@ import type {
OrdersDashboardData,
OrderWithItems,
} from "@/lib/server/orders-service";
import { AiItemHistoryTrigger } from "@/components/ai-item-history-trigger";
import { ItemStatusPill } from "@/components/item-status-pill";
import {
TAB_GROUP_VISUAL,
@ -1047,6 +1048,12 @@ function OrderCard({
className="flex items-center gap-2 text-sm py-2 first:pt-0 last:pb-0"
>
<Truck className="h-3 w-3 text-muted-foreground shrink-0" />
<AiItemHistoryTrigger
itemId={item.id}
collection="order_items"
intel={null}
className="-my-1"
/>
<span className="flex-1 truncate">{item.description}</span>
<span className="text-muted-foreground shrink-0">×{item.quantity ?? "—"}</span>
<span className="shrink-0 tabular-nums">

View file

@ -142,6 +142,8 @@ These came up but need a human answer before code changes:
- 2026-08-15 — **Phase G: gap-fill (4 commits).** (a) `customers.list` now accepts a `rep` query param / arg accepting either a 15-char PB user id (verbatim) OR a user name / email (case-insensitive exact match against `users.name` / `users.email`). Unresolvable input is silently dropped so a bad client param doesn't 500 the whole list call. New MCP `rep` input property added to `customers.list`'s schema + REST route at `/api/v1/customers?rep=...`. Closes the "all of David's customers" workflow that previously had to scroll every page. (b) New `quotes.create` MCP + REST tool for walk-in quotes (no source email). `POST /api/v1/quotes/create` and the MCP `quotes.create` entry both accept `{customer (id or name), company (id or name, optional), letterhead ("CNL"|"ISHERWOOD"), validity_days, description, items: [{description, quantity, unit, sku, supplier, category, cost_price, selling_price (auto-derives), markup, notes, pricing_source, pricing_notes, matched_price_list_id}]}`. Customer/company accept EITHER an id (verbatim) or a name (case-insensitive exact match); on name-miss a new row is created with the given name + empty defaults so the operator can reassign later via the existing quote-editing form. Recipe 7 in `docs/agents/workflows.md` covers the workflow. (c) `quote_items.create` idempotency: dedupes on `(quote_id, description, sku)` within the last 60 seconds via a new `findRecentDuplicateQuoteItem` helper. Duplicate hit returns the existing row with `idempotent: true` on the response (REST returns HTTP 200 instead of 201; MCP response carries the `idempotent` flag). The 60-second window means network retries, double-clicks, and "AI auto-fill ran twice" all dedupe cleanly while a deliberate operator re-add 10 minutes later is still permitted. 6 new unit tests for the helper + 2 new REST integration tests for the dedupe path. (d) Doc polish: `docs/agents/hermes-skill.md` got the auto-derive rule + idempotency rule + `pricing_source` default note + `unit` field quirk + full quote `status` enum + quote number format (`Q{yyyy}{mm}{NNN}`, no dashes) + new "PDF lifecycle" section (rendered on `quotes.send_email`, operator "Regenerate PDF" click, and `quotes.get_pdf` MCP tool). `docs/agents/workflows.md` got Recipe 7 (walk-in quote). `docs/api-mcp-layer.md` regenerated from the live `tools` array — tool count 125 → 126.
- 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.
### 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)
@ -160,3 +162,38 @@ These came up but need a human answer before code changes:
- **Workaround:** none — the agent's MCP config is opaque to me
- **Severity:** now — every "500" report risks being chased as a code bug when it's actually a port-mismatch / stale-deploy issue
- **Suggested tool:** no code change — drop a one-page "Hermes MCP setup checklist" doc (`docs/agents/hermes-mcp-setup.md`) covering config URL, bearer token source, CSP frame-ancestors, and a port-mismatch debug recipe so this class of bug doesn't recur
### Quotes: trim/normalise customer + company names on `quotes.create`
- **Surfaced:** 2026-08-15 — `quotes.create({ customer: " Acme Holdings " })` created a duplicate customer row because the name resolver only trims/exact-matches the input. Whitespace and casing differences slip through.
- **Workaround:** manually clean the input string before calling `quotes.create`. The customer/company resolver already does case-insensitive exact match on `customer.customer` / `company.company`, but does NOT collapse internal whitespace or strip trailing punctuation.
- **Severity:** nice-to-have — only fires when the operator types with stray whitespace
- **Suggested tool:** no new tool — run `normalizeCompanyName` (the existing helper) on both fields before the lookup, mirroring what `findOrCreateCompany` already does for the company path. Or unify the resolver: the customer path uses inline case-insensitive lookup while the company path has a `findOrCreate` helper; bring the two onto the same code path.
- **Schema needs:** none
### Quotes: pre-send validation errors vs SMTP failures don't share an envelope
- **Surfaced:** 2026-08-15 — `quotes.send_email` returns `{ failure: { code: "..." } }` when the operator passes a bad recipient (`placeholder_customer`, `no_recipient`, `invalid_recipient`, `no_pdf`, `invalid_status`, `quote_not_found`). But the n8n SMTP workflow separately writes `mail_failed` + `email_error` when the actual send fails. The agent can't tell from the response shape alone whether a failure was a pre-send validation issue (safe to retry after editing) or a transient SMTP issue (safe to retry now).
- **Workaround:** the agent inspects the `failure.code` for the pre-send path and the `email_error` string for the SMTP path. Different shapes, different code paths.
- **Severity:** now — every "did the email go?" question has to read both fields to decide whether to retry
- **Suggested tool:** no new tool — add a `kind: "validation" | "smtp"` discriminator to the `failure` object (or split into two fields: `pre_send_validation: SendQuoteEmailFailure` and `smtp_failure: { message: string }`). The agent then branches on one field.
- **Schema needs:** none
### Standard error envelope with `hint` / `next_actions` / `allowed_tools`
- **Surfaced:** 2026-08-15 — failed MCP calls return `{ error: { code: -32xxx, message: "..." } }`. The agent has to string-match the message to figure out what to do next (e.g. "uncaught error: quote_not_found" → "look up the quote id first via quotes.list"; "code: unpriced_items" → "bulk-update the offending ids and retry").
- **Workaround:** hardcoded hint strings per tool, maintained in the agent's prompt. New error codes ship without agent-side hints until we update the prompt.
- **Severity:** nice-to-have — every new error code ships without a hint for a few days/weeks
- **Suggested tool:** no new tool — extend the JSON-RPC error envelope to include `hint: string` (one-line action), `next_actions: [{ tool: "...", arguments: {...} }]` (callable next steps), and `allowed_tools: ["quotes.list", "quotes.get"]` (whitelist of tools the agent can use to recover). Tool handlers opt in by extending the success-or-failure path. The MCP `result` carries the structured payload so the agent can branch on the typed fields.
- **Schema needs:** none — JSON-RPC error object has arbitrary extra fields
### `quotes.send_email.attach_auth_letters` per-letterhead semantics undocumented
- **Surfaced:** 2026-08-15 — the `attachAuthLetters` checkbox is a single boolean; the agent doesn't know which letter is attached for which letterhead (CNL → ? supplier auth letter, ISHERWOOD → ?). The send path picks the letter off `company_letterhead` but the operator's intent is implicit.
- **Workaround:** the agent reads `quotes.company_letterhead` first and only sets `attachAuthLetters: true` when the letterhead matches the auth letter it has a copy of. Brittle.
- **Severity:** nice-to-have — only fires on outbound emails that need a supplier auth letter (CNL only today)
- **Suggested tool:** no new tool — replace the boolean with an enum `attach_auth_letters: "none" | "csl" | "isherwood"` (or keep the boolean and document the per-letterhead mapping in the `quotes.send_email` description). The agent then passes the explicit value.
- **Schema needs:** none
### `quote_items.create` vs `quote_items.update` whitelist drift
- **Surfaced:** 2026-08-15 — `quote_items.create` accepts `category` and `matched_price_list_id` but `quote_items.update`'s MCP `patch` schema only lists them in the description (no strict schema). The `bulk_update_quote_items` whitelist also drops the two fields added in Phase F.2. The three writes have three different whitelists.
- **Workaround:** the agent uses `bulk_update_quote_items` for `category` and `matched_price_list_id` since it has the strictest list. Read the route source before patching to confirm the whitelist.
- **Severity:** nice-to-have — only fires when the agent needs to set `category` on an existing line
- **Suggested tool:** no new tool — derive the whitelist from a single shared constant (`QUOTE_ITEM_PATCH_FIELDS`) so all three writes stay in sync. Move the constant into the service layer (`src/lib/server/quote-items-service.ts`) rather than per-route.
- **Schema needs:** none

View file

@ -231,6 +231,7 @@ const toolNames = body.result.tools.map((t: { name: string }) => t.name);
"quotes.save_as_template",
"quotes.split",
"quotes.partial_approve",
"quotes.submit",
"quotes.re_price",
"quotes.next_number",
"quotes.lookup",

View file

@ -898,6 +898,27 @@ 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.",
inputSchema: {
type: "object",
required: ["id"],
properties: {
id: { type: "string", description: "quote id" },
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`.",
properties: {
id: { type: "string" },
name: { type: "string" },
},
},
},
},
},
{
name: "quotes.re_price",
description:
@ -4074,6 +4095,28 @@ export async function POST(req: NextRequest) {
return jsonRpcOk(body.id, await res.json());
}
if (toolName === "quotes.submit") {
if (!args.id) {
throw new Error("Missing id for quotes.submit.");
}
const res = await fetch(
`${req.nextUrl.origin}/api/v1/quotes/${args.id}/submit`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}`,
},
body: JSON.stringify(args.actor ? { actor: args.actor } : {}),
},
);
if (!res.ok) {
const text = await res.text();
throw new Error(`quotes.submit upstream ${res.status}: ${text}`);
}
return jsonRpcOk(body.id, await res.json());
}
if (toolName === "quotes.re_price") {
if (!args.id) throw new Error("Missing id for quotes.re_price.");
if (!Array.isArray(args.items)) {

View file

@ -0,0 +1,159 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { NextRequest } from "next/server";
// Mock the service so the route tests focus on HTTP behaviour. The
// service itself has its own unit tests at
// `src/lib/server/quotes-service.test.ts` (next to this file in the
// `submitQuote` PR).
const { submitQuote, SubmitQuoteErrorMock } = vi.hoisted(() => {
const submitQuote = vi.fn();
class SubmitQuoteErrorMock extends Error {
constructor(
message: string,
public status: number,
public code: string,
public offendingItemIds: string[] = [],
) {
super(message);
this.name = "SubmitQuoteError";
}
}
return { submitQuote, SubmitQuoteErrorMock };
});
vi.mock("@/lib/server/quotes-service", () => ({
submitQuote: (...args: unknown[]) => submitQuote(...args),
SubmitQuoteError: SubmitQuoteErrorMock,
}));
import { POST } from "./route";
const SubmitQuoteError = SubmitQuoteErrorMock;
function makeReq(id: string, body?: unknown) {
return new NextRequest(`http://localhost/api/v1/quotes/${id}/submit`, {
method: "POST",
headers: body ? { "content-type": "application/json" } : {},
body: body ? JSON.stringify(body) : undefined,
});
}
describe("POST /api/v1/quotes/[id]/submit", () => {
beforeEach(() => {
submitQuote.mockReset();
});
it("200s with statusChanged: true on a happy-path submit", async () => {
const updatedQuote = { id: "q1", status: "approval_pending" };
submitQuote.mockResolvedValueOnce({
quote: updatedQuote,
statusChanged: true,
});
const res = await POST(makeReq("q1"), { params: Promise.resolve({ id: "q1" }) });
expect(res.status).toBe(200);
const body = await res.json();
expect(body.statusChanged).toBe(true);
expect(body.quote.status).toBe("approval_pending");
});
it("200s with statusChanged: false when the quote is already approval_pending", async () => {
submitQuote.mockResolvedValueOnce({
quote: { id: "q1", status: "approval_pending" },
statusChanged: false,
});
const res = await POST(makeReq("q1"), { params: Promise.resolve({ id: "q1" }) });
expect(res.status).toBe(200);
const body = await res.json();
expect(body.statusChanged).toBe(false);
});
it("forwards the actor to the service when supplied", async () => {
submitQuote.mockResolvedValueOnce({
quote: { id: "q1", status: "approval_pending" },
statusChanged: true,
});
await POST(
makeReq("q1", { actor: { id: "u1", name: "Hermes" } }),
{ params: Promise.resolve({ id: "q1" }) },
);
expect(submitQuote).toHaveBeenCalledWith(
"q1",
{ id: "u1", name: "Hermes" },
);
});
it("forwards no actor when the body is empty", async () => {
submitQuote.mockResolvedValueOnce({
quote: { id: "q1", status: "approval_pending" },
statusChanged: true,
});
await POST(makeReq("q1"), { params: Promise.resolve({ id: "q1" }) });
expect(submitQuote).toHaveBeenCalledWith("q1", undefined);
});
it("ignores malformed actor fields silently (defaults to undefined)", async () => {
submitQuote.mockResolvedValueOnce({
quote: { id: "q1", status: "approval_pending" },
statusChanged: true,
});
// actor.id is a number, not a string — should be treated as no actor.
await POST(
makeReq("q1", { actor: { id: 42, name: "x" } }),
{ params: Promise.resolve({ id: "q1" }) },
);
expect(submitQuote).toHaveBeenCalledWith("q1", undefined);
});
it("404s when the quote doesn't exist", async () => {
submitQuote.mockRejectedValueOnce(
new SubmitQuoteError("Quote missing not found", 404, "quote_not_found"),
);
const res = await POST(makeReq("missing"), { params: Promise.resolve({ id: "missing" }) });
expect(res.status).toBe(404);
const body = await res.json();
expect(body.code).toBe("quote_not_found");
});
it("422s with code: invalid_status when the quote is in a wrong state", async () => {
submitQuote.mockRejectedValueOnce(
new SubmitQuoteError(
"Cannot submit a quote in status \"cancelled\".",
422,
"invalid_status",
),
);
const res = await POST(makeReq("q1"), { params: Promise.resolve({ id: "q1" }) });
expect(res.status).toBe(422);
const body = await res.json();
expect(body.code).toBe("invalid_status");
expect(body.offendingItemIds).toBeUndefined();
});
it("422s with code: unpriced_items and surfaces offendingItemIds", async () => {
submitQuote.mockRejectedValueOnce(
new SubmitQuoteError(
"2 item(s) have selling_price = 0.",
422,
"unpriced_items",
["qi1", "qi2"],
),
);
const res = await POST(makeReq("q1"), { params: Promise.resolve({ id: "q1" }) });
expect(res.status).toBe(422);
const body = await res.json();
expect(body.code).toBe("unpriced_items");
expect(body.offendingItemIds).toEqual(["qi1", "qi2"]);
});
it("500s on a non-SubmitQuoteError failure", async () => {
submitQuote.mockRejectedValueOnce(new Error("PB down"));
const res = await POST(makeReq("q1"), { params: Promise.resolve({ id: "q1" }) });
expect(res.status).toBe(500);
const body = await res.json();
expect(body.error).toMatch(/PB down/);
});
});

View file

@ -0,0 +1,80 @@
import { NextRequest, NextResponse } from "next/server";
import {
submitQuote,
SubmitQuoteError,
} from "@/lib/server/quotes-service";
/**
* POST /api/v1/quotes/[id]/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 (200, `statusChanged: false`).
*
* Why this exists: the walk-in quote recipe (`quotes.create` → price
* each line → `quotes.send_email`) used to require the agent to call
* `quotes.update({status: "approval_pending"})` manually, which had
* no "all items priced" gate and broke the audit trail. This route
* surfaces the gate as a typed 422 with the offending item ids so the
* agent can branch on the response.
*
* Body: none required. `actor: { id, name }` is optional — when
* supplied, it lands on `quotes.submitted_by` (provided the PB admin
* has added the column; falls back silently otherwise).
*
* Returns:
* 200 — { quote, statusChanged: boolean }
* 404 — { error, code: "quote_not_found" }
* 422 — { error, code: "invalid_status" | "unpriced_items",
* offendingItemIds?: string[] }
* 500 — { error: <message> }
*
* `runtime = "nodejs"` because PocketBase admin auth requires Node.
*/
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
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.
let actor: { id: string; name: string } | undefined;
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 };
}
}
} catch {
// Body is optional — bubble up as no actor if it's malformed.
}
try {
const result = await submitQuote(id, actor);
return NextResponse.json(result);
} catch (err) {
if (err instanceof SubmitQuoteError) {
return NextResponse.json(
{
error: err.message,
code: err.code,
...(err.offendingItemIds.length > 0
? { offendingItemIds: err.offendingItemIds }
: {}),
},
{ status: err.status },
);
}
const message =
err instanceof Error ? err.message : "Submit failed";
console.error("[quote-submit] failed:", err);
return NextResponse.json({ error: message }, { status: 500 });
}
}

View file

@ -189,7 +189,7 @@ export function ItemIntelPopover({
</div>
<div className="text-[10px] font-mono text-muted-foreground truncate">
{intel.item
? `${intel.item.collection} · qty ${intel.item.quantity}${intel.item.sku ? ` · sku ${intel.item.sku}` : " · no sku"}`
? `${intel.item.collection} · qty ${intel.item.quantity}${intel.item.sku ? ` · sku ${intel.item.sku}` : " · no sku"}${intel.item.supplier_name ? ` · ${intel.item.supplier_name}` : ""}`
: collection}
</div>
</div>

View file

@ -542,7 +542,8 @@ export async function populateItemIntel(
const target = await pb.collection(collection).getOne<{
id: string; sku: string; description: string; quantity: number;
}>(itemId, { fields: "id,sku,description,quantity" });
supplier: string;
}>(itemId, { fields: "id,sku,description,quantity,supplier" });
const supplierNameById = await loadSuppliersById(pb);
const generated_at = new Date().toISOString();
@ -562,6 +563,10 @@ export async function populateItemIntel(
description: target.description || "",
sku: target.sku || "",
quantity: target.quantity || 0,
supplier: target.supplier || "",
supplier_name: target.supplier
? supplierNameById.get(target.supplier) || ""
: "",
};
const summary = composeSummary({

View file

@ -236,6 +236,8 @@ import {
clearQuoteStatusTabCountsCache,
QUOTE_TAB_STATUSES,
QUOTE_TAB_ORDER,
submitQuote,
SubmitQuoteError,
} from "./quotes-service";
beforeEach(() => {
@ -1479,3 +1481,195 @@ describe("getQuotesArchiveData", () => {
expect(result.counts.completed).toBe(1);
});
});
describe("submitQuote", () => {
it("flips a new quote to approval_pending when every line is priced", async () => {
const quote = await pbMock.collection("quotes").create({
id: "q-1",
quote_number: "Q-2026-08-001",
status: "new",
});
await pbMock.collection("quote_items").create({
id: "qi-1",
quote: "q-1",
description: "M16 bolt",
selling_price: 25,
});
await pbMock.collection("quote_items").create({
id: "qi-2",
quote: "q-1",
description: "Hex nut",
selling_price: 8,
});
const result = await submitQuote("q-1", { id: "u1", name: "Hermes" });
expect(result.statusChanged).toBe(true);
expect(result.quote.status).toBe("approval_pending");
expect(result.quote.submitted_by).toBe("Hermes");
expect(typeof result.quote.submitted_at).toBe("string");
// PB row updated in place.
const stored = await pbMock.collection("quotes").getOne("q-1");
expect(stored.status).toBe("approval_pending");
});
it("is idempotent — re-submitting an approval_pending quote is a no-op", async () => {
const original = await pbMock.collection("quotes").create({
id: "q-1",
status: "approval_pending",
submitted_by: "Hermes",
submitted_at: "2026-08-14T10:00:00.000Z",
});
await pbMock.collection("quote_items").create({
id: "qi-1",
quote: "q-1",
selling_price: 25,
});
const result = await submitQuote("q-1");
expect(result.statusChanged).toBe(false);
expect(result.quote.status).toBe("approval_pending");
// Stamps unchanged — the no-op path MUST NOT re-stamp.
expect((result.quote as Record<string, unknown>).submitted_by).toBe(
"Hermes",
);
expect((result.quote as Record<string, unknown>).submitted_at).toBe(
"2026-08-14T10:00:00.000Z",
);
// PB row not touched.
const stored = await pbMock.collection("quotes").getOne("q-1");
expect(stored).toEqual(original);
});
it("rejects a quote in any non-new, non-pending status with code: invalid_status", async () => {
for (const status of [
"cancelled",
"approved",
"emailed",
"rejected",
"partial_approved",
"claimed",
]) {
await pbMock.collection("quotes").create({
id: `q-${status}`,
status,
});
await pbMock.collection("quote_items").create({
id: `qi-${status}`,
quote: `q-${status}`,
selling_price: 10,
});
await expect(submitQuote(`q-${status}`)).rejects.toMatchObject({
name: "SubmitQuoteError",
status: 422,
code: "invalid_status",
});
}
});
it("rejects a quote with unpriced lines and returns the offending item ids", async () => {
await pbMock.collection("quotes").create({
id: "q-1",
status: "new",
});
await pbMock.collection("quote_items").create({
id: "qi-priced",
quote: "q-1",
description: "M16 bolt",
selling_price: 25,
});
await pbMock.collection("quote_items").create({
id: "qi-zero",
quote: "q-1",
description: "Hex nut",
selling_price: 0,
});
await pbMock.collection("quote_items").create({
id: "qi-null",
quote: "q-1",
description: "Washer",
selling_price: null,
});
// A line moved to a sibling quote (partial-approve split) should
// be excluded — it's not on this quote anymore.
await pbMock.collection("quote_items").create({
id: "qi-moved",
quote: "q-1",
description: "Moved line",
selling_price: 0,
moved_to_quote: "q-other",
});
try {
await submitQuote("q-1");
throw new Error("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(SubmitQuoteError);
const e = err as SubmitQuoteError;
expect(e.status).toBe(422);
expect(e.code).toBe("unpriced_items");
expect(e.offendingItemIds.sort()).toEqual(["qi-null", "qi-zero"]);
}
// Status NOT flipped on a rejected submit.
const stored = await pbMock.collection("quotes").getOne("q-1");
expect(stored.status).toBe("new");
});
it("404s with code: quote_not_found when the quote id doesn't exist", async () => {
await expect(submitQuote("missing")).rejects.toMatchObject({
name: "SubmitQuoteError",
status: 404,
code: "quote_not_found",
});
});
it("400s when the quote id is empty / whitespace", async () => {
await expect(submitQuote("")).rejects.toMatchObject({
name: "SubmitQuoteError",
status: 400,
code: "quote_not_found",
});
await expect(submitQuote(" ")).rejects.toMatchObject({
name: "SubmitQuoteError",
status: 400,
code: "quote_not_found",
});
});
it("omits submitted_by when no actor is supplied", async () => {
await pbMock.collection("quotes").create({
id: "q-1",
status: "new",
});
await pbMock.collection("quote_items").create({
id: "qi-1",
quote: "q-1",
selling_price: 25,
});
const result = await submitQuote("q-1");
expect(result.statusChanged).toBe(true);
// The type doesn't expose submitted_by because it was never set,
// but the underlying field should be absent from the PB update.
expect(result.quote.submitted_by).toBeUndefined();
});
it("omits submitted_by when actor is supplied but has no name", async () => {
await pbMock.collection("quotes").create({
id: "q-1",
status: "new",
});
await pbMock.collection("quote_items").create({
id: "qi-1",
quote: "q-1",
selling_price: 25,
});
const result = await submitQuote("q-1", { id: "u1", name: "" });
expect(result.statusChanged).toBe(true);
expect(result.quote.submitted_by).toBeUndefined();
});
});

View file

@ -2070,6 +2070,162 @@ export async function markPricedItemsApprovedInBatch(
return toFlip.length;
}
// ──────────────────────────────────────────────────────────────────────────────
// Submit-for-approval (new → approval_pending)
// ──────────────────────────────────────────────────────────────────────────────
export type SubmitQuoteFailureCode =
| "quote_not_found"
| "invalid_status"
| "unpriced_items";
export interface SubmitQuoteResult {
quote: Quote;
/** True when the call actually flipped the status. False when the
* quote was already `approval_pending` (idempotent re-submit). */
statusChanged: boolean;
/** IDs of `quote_items` rows that blocked submission (selling_price
* ≤ 0 on a not-moved line). Populated only when `failure !== null`. */
offendingItemIds?: string[];
}
export class SubmitQuoteError extends Error {
constructor(
message: string,
public readonly status: number,
public readonly code: SubmitQuoteFailureCode,
public readonly offendingItemIds: string[] = [],
) {
super(message);
this.name = "SubmitQuoteError";
}
}
/**
* Flip a quote from `new` to `approval_pending` once every line is
* priced. Idempotent — re-submitting an already-`approval_pending`
* quote is a no-op (no PB write, returns `statusChanged: false`).
*
* Why this is its own tool (and not just `quotes.update({status: "approval_pending"})`):
* - The "all items priced" gate is the operator's safety net — a
* quote with `selling_price = 0` lines must surface a clear
* "fix these ids" error, not silently go to the approval queue.
* - Idempotency matters because the walk-in recipe (create → price
* → submit) is fuzzy: the agent may call submit twice after
* verifying with the operator, and the UI re-fires it on retry.
* - The status flip is the canonical audit signal. We don't stamp
* `submitted_at` / `submitted_by` here because the PB schema
* doesn't have those columns yet (see FEATURE-REQUESTS.md "submit
* audit fields"); the added `Quote.submitted_at` / `submitted_by`
* fields will start persisting the moment ops adds them via PB
* admin.
*
* Returns `SubmitQuoteError` (typed) for the three failure modes so
* the REST route can map to clean HTTP 404/422 + `code` discriminators
* the agent can branch on.
*/
export async function submitQuote(
quoteId: string,
actor?: { id: string; name: string },
): Promise<SubmitQuoteResult> {
if (!quoteId || !quoteId.trim()) {
throw new SubmitQuoteError(
"quoteId is required",
400,
"quote_not_found",
);
}
const pb = await getAdminPocketBase();
let quote: Quote;
try {
quote = await pb.collection(QUOTES_COLLECTION).getOne<Quote>(quoteId);
} catch {
throw new SubmitQuoteError(
`Quote ${quoteId} not found`,
404,
"quote_not_found",
);
}
// 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 };
}
// Only `new` quotes can transition via this tool. The full set of
// valid paths to the approval queue is:
// - `new` → here (via `submit`)
// - `partial_approval_pending` → `partial_approved` (via `partial_approve`)
// - `partial_approved` → `approved` (via the operator's review flow)
// Quotes that are already `approved`, `rejected`, `cancelled`,
// `emailed`, etc. have to be re-quoted rather than re-submitted.
if (quote.status !== "new") {
throw new SubmitQuoteError(
`Cannot submit a quote in status "${quote.status}". Only "new" quotes can be submitted for approval. Re-quote if the customer needs a fresh approval cycle.`,
422,
"invalid_status",
);
}
// 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
// cleanly, and the extra round-trip saved by a complex filter isn't
// worth the brittle test surface. The shape returned is
// `{ id, selling_price, moved_to_quote }` — we use `fields` to keep
// the payload small.
const allItems = await pb
.collection(QUOTE_ITEMS_COLLECTION)
.getFullList({
filter: `quote = "${quoteId}"`,
fields: "id,selling_price,moved_to_quote",
});
const unpricedItems = allItems.filter((it) => {
const sp = Number(it.selling_price) || 0;
const moved = String(it.moved_to_quote || "").trim();
return sp <= 0 && moved === "";
});
if (unpricedItems.length > 0) {
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.`,
422,
"unpriced_items",
offendingItemIds,
);
}
// 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",
};
if (actor?.name) {
updatePayload.submitted_by = actor.name;
}
updatePayload.submitted_at = new Date().toISOString();
const updated = (await pb.collection(QUOTES_COLLECTION).update(
quoteId,
updatePayload,
)) as Quote;
return { quote: updated, statusChanged: true };
}
// ──────────────────────────────────────────────────────────────────────────────
// Walk-in quote creation (no source email)
// ──────────────────────────────────────────────────────────────────────────────

View file

@ -190,6 +190,14 @@ export interface Quote extends BaseRecord {
description: string;
status: QuoteStatus;
approved_by: string;
/** Identity of the operator / agent that flipped the quote to
* `approval_pending`. Stored conditionally — if the PB schema
* doesn't have the column yet, the value is silently dropped
* during the update. See `submitQuote` in `quotes-service.ts`. */
submitted_by?: string;
/** ISO timestamp of the `new → approval_pending` transition. Same
* soft-write semantics as `submitted_by`. */
submitted_at?: string;
notes: string;
email_sender: string;
email_subject: string;
@ -1633,6 +1641,12 @@ export interface ItemIntelTarget {
description: string;
sku: string;
quantity: number;
/** PB id of the supplier bound to the parent item. NULL on
* pre-PO order_items and on supplier-agnostic quote_items. */
supplier?: string;
/** Resolved supplier display name. Empty when no supplier is set
* (or the supplier id no longer resolves). */
supplier_name?: string;
}
/** One row in the Stock List — a fuzzy-matched stock_items row. */