WFDASH-WEBAPP/docs/agents/workflows.md
Dave 151ae8d67f 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.
2026-08-15 19:01:52 +02:00

12 KiB

MCP Workflow Cookbook

End-to-end recipes for the most common operator requests that come through the WFDASH MCP. Each recipe lists the goal, the tool calls in order, and the gotchas — so an agent that has never seen the business before can drive the workflow to completion without further prompting.

Tool names below are the MCP tools. Inputs match the inputSchema you get from tools.discovery. Output shapes are described in docs/api-mcp-layer.md §3.3.


1. "I have a supplier invoice — what do I do?"

Goal: take a supplier's invoice PDF (or delivery note), match it to the right PO, and book the goods into stock.

Sequence:

  1. Identify the PO — read "Your Ref" / "Order No" from the document. It's either a stock PO (PO{yy}{6d-seq}STK) or a customer PO (PO-YYYY-MM-NNN).
  2. Diff first (read-only) — purchase_orders.diff({ po_reference, document: { document_no, date, lines: [...] }, mode: "invoice" | "grn" }). Returns a MatchReport with a verdict + per-line LineComparison[].
  3. Decide:
    • verdict === "ready_to_receive" → commit with stock_purchases.match_and_receive_from_invoice({ po_reference, document, attachment, confirm: true }). The same call attaches the scanned invoice to the PO.
    • Any other verdict (cost_mismatch, qty_mismatch, unmatched_po_lines, unmatched_invoice_lines, supplier_mismatch, wrong_status, po_not_found) → stop and report. Never confirm: true with a non-clean verdict.
  4. For stock POs only — the invoice receive books stock into stock_items.current_qty and writes a stock_ledger row. stock_purchases.receive is the lower-level tool for partial receives; the diff+match path handles the common case in one call.
  5. For customer POs — the receive path flips order_items. item_status to collected and stamps supplier_invoice_number. Use collection_confirm.upload_invoice if the invoice wasn't attached during step 3, then collection_confirm.confirm_order_items to move the lines to ready_invoice.

Gotchas:

  • mode: "grn" is delivery-note mode — skips price validation, accepts missing/zero prices. Use when the supplier sends a packing slip before the invoice.
  • The matched document_no becomes the supplier_invoice_number on the order_items. Get it right; don't paraphrase.
  • confirm: true with a non-clean verdict silently refuses — you get a report back but no write. Read the verdict before flipping confirm.

2. "Split this order into a continuation"

Goal: some lines on the order are stuck (waiting on a back-ordered part); the rest can ship. Move the ready lines onto a fresh ORD number.

Sequence:

  1. orders.get({ id, expand: "items" }) — confirm which lines are item_status === "pending" (only pending can be moved).
  2. orders.split({ order_id, item_ids: [<the lines to move>] }). Returns the new continuation order's ORD number. The parent order keeps its number; each moved item gets a breadcrumb in notes.

Gotchas:

  • Any item not in pending refuses silently (it would orphan a PO or driver assignment). Filter the candidates down to pending before splitting — order_items.list({ filter: order="" && item_status="pending" }).
  • The continuation order inherits customer, company, rep, contact — no need to re-state them.
  • If you only have one line to move, splitting is overkill. Just update the parent.

3. "Confirm collection on a partial delivery"

Goal: the customer received 8 of 10 units today; 2 are on a back-order. Mark the row collected with the right qty_collected without losing the partial quantity.

Sequence:

  1. order_items.get({ id, expand: "order" }) — read the current quantity and qty_collected. The quantity_collected parameter is the amount to add, not the new total.
  2. Compute to_add = received_qty - qty_collected (clamp to ≥ 0).
  3. order_items.confirm_collected({ item_ids: ["<id>"], quantity_collected: { "<id>": <to_add> }, note: "Partial delivery 8 of 10, 2 back-ordered", actor: { id: "<user_id>", name: "<display_name>" } }).

Gotchas:

  • Eligibility: row must be in collection_pool or assigned. Anything past the pool refuses with wrong_status.
  • actor is required — it's stamped on every audit entry. Look it up via users.lookup({ q: "<name>" }) if you don't have the id.
  • note is optional but recommended for partial receives — the default "agent confirmed" note loses information about why only part was received.

4. "Bulk-price a quote that just landed"

Goal: the AI has extracted items but didn't price them. Fill in cost + selling prices from the catalogue in one MCP round-trip.

Sequence:

  1. quote_items.list({ filter: quote="<quote_id>", expand: "matched_price_list_id" }) — fetch every line.
  2. For each line with matched_price_list_id populated, read the catalogue's cost_price and markup.
  3. bulk_update_quote_items({ items: [ { id, patch: { cost_price, selling_price, pricing_source: "ai", pricing_notes: "<date> by <agent>: matched catalogue row" }, force_overwrite: false }, ... ]}).
  4. quotes.re_price({ id: <quote_id>, items: [...] }) — same call shape, scoped to one quote; useful when the agent wants to keep the diff inside the quote context.

Gotchas:

  • force_overwrite: false is the safe default — rows with cost_price > 0 AND selling_price > 0 are skipped with already_priced. Pass true only when the operator explicitly asked to re-price.
  • pricing_notes is required for the audit trail. The agent identity
    • date is yours to stamp — the tool does NOT auto-inject it.
  • selling_price is auto-derived from cost_price * (1 + markup/100) if omitted (rounded to 2 decimal places). Pass it explicitly when the catalogue has a stored selling price you want to preserve. Applies to quote_items.update, bulk_update_quote_items, and quote_items.create. The previous_* / new_* surfaces in the bulk response reflect the derived values.
  • Whitelist (both bulk and re-price): cost_price, selling_price, description, sku, unit, supplier, category, markup, pricing_source, pricing_notes, matched_price_list_id, user_edited. Anything else returns forbidden_field.

5. "Customer rejected some lines — partial approve + split"

Goal: the customer accepts 6 of 8 lines. Approve the 6 on the original quote, clone the rejected 2 into a fresh quote for re-pricing.

Sequence:

  1. quotes.get({ id, expand: "items" }) — read all line ids.
  2. quotes.partial_approve({ quote_id, decisions: [ { item_id: "<accepted_id>", decision: "approved" }, { item_id: "<rejected_id>", decision: "rejected", note: "Customer preferred a different supplier" }, ... ]}).
  3. quotes.split({ source_quote_id, groups: [ { name: "Re-quote rejected lines", item_ids: ["<rej_id1>", "<rej_id2>"] } ]}).
  4. quotes.re_price({ id: <new_quote_id>, items: [...] }) — adjust pricing on the continuation.

Gotchas:

  • quotes.partial_approve records the customer's choice but doesn't move the items. To action them, follow with quotes.split.
  • Splitting preserves the rejected lines on a new quote with the same customer + company + letterhead, so a re_price round feels like a continuation, not a fresh RFQ.
  • If the customer rejected everything, prefer quotes.clone and starting over — splitting into a single empty group is a no-op.

6. "Find sprint-pricing items I can claim"

Goal: the buyer-side sprint shows items waiting for pricing proofs. Find one that's unclaimed and lock it for 30 minutes.

Sequence:

  1. sprint.list({ filter: claim_status=null || claim_expires_at<"now", expand: "quote_item.quote", sort: "-created" }) — list unclaimed sprint items.
  2. Pick one — prefer items with quote_item.quote.customer already known (faster resolution).
  3. sprint.claim({ id: <sprint_item_id>, claimed_by: "<agent_name>" }). Returns 409 if another agent already owns it.

Gotchas:

  • The claim is 30-min default — finish your work before it expires, or re-claim to extend.
  • sprint.attachment_counts({ item_ids: [...] }) is the bulk counterpart to sprint.item_attachments — use it to render paperclip badges in any list view without N round-trips.
  • After pricing, use bulk_update_quote_items to push the new prices; the sprint item auto-resolves when the linked quote_item.user_edited flips to true.

7. "Create a walk-in quote (no inbound email)"

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, price it, push it into the approval queue, then send.

Sequence:

  1. Resolve customer (and company) names to ids: customers.list({ search: "John's workshop" }) — pick the matching row, take its id. If no match, the quotes.create tool will create the customer with the supplied name as a new row (defaults for the rest).
  2. Build the items array — each item needs at minimum description. Add cost_price + markup if you know the pricing (selling_price auto-derives); otherwise leave pricing blank and run quotes.re_price after the fact.
  3. quotes.create({ customer: "<id-or-name>", 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:

  • customer and company accept EITHER a 15-char PB id (verbatim) OR a name (case-insensitive exact match against customer.customer / company.company). When the name doesn't 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 quotes.ingest_from_email (preferred) or quotes.create_from_email (legacy, caller-supplied items).
  • After creation, follow Recipe 4 to bulk-price from the catalogue, or Recipe 6 if any line lands in the sprint.

Quick reference: tool families by workflow

Workflow Primary tools
Supplier invoice purchase_orders.diff, stock_purchases.match_and_receive_from_invoice, stock_purchases.receive (partial)
Order split orders.get, order_items.list, orders.split
Partial collection order_items.get, order_items.confirm_collected, users.lookup (for actor)
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, quotes.re_price, quotes.submit, quotes.send_email

For the full tool catalogue (auto-generated), see docs/api-mcp-layer.md.