From 151ae8d67f540625b5166f276bc17dfe79f97d68 Mon Sep 17 00:00:00 2001 From: Dave Date: Sat, 15 Aug 2026 19:01:52 +0200 Subject: [PATCH] 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. --- docs/agents/hermes-skill.md | 23 ++- docs/agents/workflows.md | 21 +- docs/api-mcp-layer.md | 5 +- src/app/(app)/orders/page.tsx | 7 + src/app/api/mcp/FEATURE-REQUESTS.md | 37 ++++ src/app/api/mcp/route.test.ts | 1 + src/app/api/mcp/route.ts | 43 ++++ .../api/v1/quotes/[id]/submit/route.test.ts | 159 ++++++++++++++ src/app/api/v1/quotes/[id]/submit/route.ts | 80 ++++++++ src/components/item-intel-popover.tsx | 2 +- src/lib/server/item-intel-service.ts | 7 +- src/lib/server/quotes-service.test.ts | 194 ++++++++++++++++++ src/lib/server/quotes-service.ts | 156 ++++++++++++++ src/lib/types.ts | 14 ++ 14 files changed, 741 insertions(+), 8 deletions(-) create mode 100644 src/app/api/v1/quotes/[id]/submit/route.test.ts create mode 100644 src/app/api/v1/quotes/[id]/submit/route.ts diff --git a/docs/agents/hermes-skill.md b/docs/agents/hermes-skill.md index b506809..31ed2b3 100644 --- a/docs/agents/hermes-skill.md +++ b/docs/agents/hermes-skill.md @@ -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. diff --git a/docs/agents/workflows.md b/docs/agents/workflows.md index 5e541b8..7382b75 100644 --- a/docs/agents/workflows.md +++ b/docs/agents/workflows.md @@ -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: "" (optional), letterhead: "CNL" | "ISHERWOOD", validity_days: 30, description: "", 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: "" })` — 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: "" })` — 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). \ No newline at end of file diff --git a/docs/api-mcp-layer.md b/docs/api-mcp-layer.md index 739d7dc..77e16c6 100644 --- a/docs/api-mcp-layer.md +++ b/docs/api-mcp-layer.md @@ -467,7 +467,7 @@ keys aren't set, so a clean checkout reports `passed, N skipped`. -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` \ No newline at end of file diff --git a/src/app/(app)/orders/page.tsx b/src/app/(app)/orders/page.tsx index 5ce03d5..ffea85f 100644 --- a/src/app/(app)/orders/page.tsx +++ b/src/app/(app)/orders/page.tsx @@ -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" > + {item.description} ×{item.quantity ?? "—"} diff --git a/src/app/api/mcp/FEATURE-REQUESTS.md b/src/app/api/mcp/FEATURE-REQUESTS.md index 38f529c..dabaf30 100644 --- a/src/app/api/mcp/FEATURE-REQUESTS.md +++ b/src/app/api/mcp/FEATURE-REQUESTS.md @@ -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 diff --git a/src/app/api/mcp/route.test.ts b/src/app/api/mcp/route.test.ts index 7651fbf..01104d0 100644 --- a/src/app/api/mcp/route.test.ts +++ b/src/app/api/mcp/route.test.ts @@ -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", diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index b67f373..ce60c96 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -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)) { diff --git a/src/app/api/v1/quotes/[id]/submit/route.test.ts b/src/app/api/v1/quotes/[id]/submit/route.test.ts new file mode 100644 index 0000000..4e2b782 --- /dev/null +++ b/src/app/api/v1/quotes/[id]/submit/route.test.ts @@ -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/); + }); +}); diff --git a/src/app/api/v1/quotes/[id]/submit/route.ts b/src/app/api/v1/quotes/[id]/submit/route.ts new file mode 100644 index 0000000..590787e --- /dev/null +++ b/src/app/api/v1/quotes/[id]/submit/route.ts @@ -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: } + * + * `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; + 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 }); + } +} diff --git a/src/components/item-intel-popover.tsx b/src/components/item-intel-popover.tsx index cbdba18..3f60231 100644 --- a/src/components/item-intel-popover.tsx +++ b/src/components/item-intel-popover.tsx @@ -189,7 +189,7 @@ export function ItemIntelPopover({
{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}
diff --git a/src/lib/server/item-intel-service.ts b/src/lib/server/item-intel-service.ts index fd118fc..c7a9186 100644 --- a/src/lib/server/item-intel-service.ts +++ b/src/lib/server/item-intel-service.ts @@ -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({ diff --git a/src/lib/server/quotes-service.test.ts b/src/lib/server/quotes-service.test.ts index 3a290f7..45b8615 100644 --- a/src/lib/server/quotes-service.test.ts +++ b/src/lib/server/quotes-service.test.ts @@ -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).submitted_by).toBe( + "Hermes", + ); + expect((result.quote as Record).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(); + }); +}); diff --git a/src/lib/server/quotes-service.ts b/src/lib/server/quotes-service.ts index 9fb6c2e..18d5ac7 100644 --- a/src/lib/server/quotes-service.ts +++ b/src/lib/server/quotes-service.ts @@ -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 { + 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(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 = { + 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) // ────────────────────────────────────────────────────────────────────────────── diff --git a/src/lib/types.ts b/src/lib/types.ts index c3db342..bb57f0b 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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. */