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:
parent
c4b53bcc4b
commit
151ae8d67f
14 changed files with 741 additions and 8 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
@ -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 -->
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
159
src/app/api/v1/quotes/[id]/submit/route.test.ts
Normal file
159
src/app/api/v1/quotes/[id]/submit/route.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
80
src/app/api/v1/quotes/[id]/submit/route.ts
Normal file
80
src/app/api/v1/quotes/[id]/submit/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
Loading…
Reference in a new issue