- Updated the `GET /api/v1/orders` endpoint to support additional query parameters including `customer`, `createdAfter`, `createdBefore`, and `status`, allowing for more granular order filtering. - Implemented logic to resolve customer IDs from names and aggregate order items by their statuses for improved data retrieval. - Introduced new helper functions in `orders-service.ts` for fetching order statistics and daily summaries, enhancing reporting capabilities. - Added comprehensive documentation for the new query parameters and their usage in the API documentation. - Improved error handling for invalid customer and status inputs, ensuring clearer feedback for API consumers.
14 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
inputSchemayou get fromtools.discovery. Output shapes are described indocs/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:
- 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). - Diff first (read-only) —
purchase_orders.diff({ po_reference, document: { document_no, date, lines: [...] }, mode: "invoice" | "grn" }). Returns aMatchReportwith a verdict + per-lineLineComparison[]. - Decide:
verdict === "ready_to_receive"→ commit withstock_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. Neverconfirm: truewith a non-clean verdict.
- For stock POs only — the invoice receive books stock into
stock_items.current_qtyand writes astock_ledgerrow.stock_purchases.receiveis the lower-level tool for partial receives; the diff+match path handles the common case in one call. - For customer POs — the receive path flips
order_items. item_statustocollectedand stampssupplier_invoice_number. Usecollection_confirm.upload_invoiceif the invoice wasn't attached during step 3, thencollection_confirm.confirm_order_itemsto move the lines toready_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_nobecomes thesupplier_invoice_numberon the order_items. Get it right; don't paraphrase. confirm: truewith a non-clean verdict silently refuses — you get a report back but no write. Read the verdict before flippingconfirm.
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:
orders.get({ id, expand: "items" })— confirm which lines areitem_status === "pending"(only pending can be moved).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 innotes.
Gotchas:
- Any item not in
pendingrefuses silently (it would orphan a PO or driver assignment). Filter the candidates down topendingbefore 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:
order_items.get({ id, expand: "order" })— read the currentquantityandqty_collected. Thequantity_collectedparameter is the amount to add, not the new total.- Compute
to_add = received_qty - qty_collected(clamp to ≥ 0). 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_poolorassigned. Anything past the pool refuses withwrong_status. actoris required — it's stamped on every audit entry. Look it up viausers.lookup({ q: "<name>" })if you don't have the id.noteis 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:
quote_items.list({ filter:quote="<quote_id>", expand: "matched_price_list_id" })— fetch every line.- For each line with
matched_price_list_idpopulated, read the catalogue'scost_priceandmarkup. 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 }, ... ]}).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: falseis the safe default — rows withcost_price > 0 AND selling_price > 0are skipped withalready_priced. Passtrueonly when the operator explicitly asked to re-price.pricing_notesis required for the audit trail. The agent identity- date is yours to stamp — the tool does NOT auto-inject it.
selling_priceis auto-derived fromcost_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 toquote_items.update,bulk_update_quote_items, andquote_items.create. Theprevious_*/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 returnsforbidden_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:
quotes.get({ id, expand: "items" })— read all line ids.quotes.partial_approve({ quote_id, decisions: [ { item_id: "<accepted_id>", decision: "approved" }, { item_id: "<rejected_id>", decision: "rejected", note: "Customer preferred a different supplier" }, ... ]}).quotes.split({ source_quote_id, groups: [ { name: "Re-quote rejected lines", item_ids: ["<rej_id1>", "<rej_id2>"] } ]}).quotes.re_price({ id: <new_quote_id>, items: [...] })— adjust pricing on the continuation.
Gotchas:
quotes.partial_approverecords the customer's choice but doesn't move the items. To action them, follow withquotes.split.- Splitting preserves the rejected lines on a new quote with the same
customer + company + letterhead, so a
re_priceround feels like a continuation, not a fresh RFQ. - If the customer rejected everything, prefer
quotes.cloneand 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:
sprint.list({ filter:claim_status=null || claim_expires_at<"now", expand: "quote_item.quote", sort: "-created" })— list unclaimed sprint items.- Pick one — prefer items with
quote_item.quote.customeralready known (faster resolution). 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 tosprint.item_attachments— use it to render paperclip badges in any list view without N round-trips.- After pricing, use
bulk_update_quote_itemsto push the new prices; the sprint item auto-resolves when the linkedquote_item.user_editedflips totrue.
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:
- Resolve customer (and company) names to ids:
customers.list({ search: "John's workshop" })— pick the matching row, take itsid. If no match, thequotes.createtool will create the customer with the supplied name as a new row (defaults for the rest). - Build the items array — each item needs at minimum
description. Addcost_price+markupif you know the pricing (selling_price auto-derives); otherwise leave pricing blank and runquotes.re_priceafter the fact. quotes.create({ customer: "<id-or-name>", company: "<id-or-name>" (optional), letterhead: "CNL" | "ISHERWOOD", validity_days: 30, description: "<free-text RFQ subject>", items: [...] }).- If you didn't supply prices in step 3, run
quotes.re_priceto walk the catalogue. Otherwise skip. quotes.submit({ id: "<quote-id>" })— flipsstatusfromnewtoapproval_pending. Gated on every line havingselling_price > 0; on a 422 (code: "unpriced_items") the response carriesoffendingItemIdsso you can price the gaps and retry. Idempotent — re-submitting anapproval_pendingquote is a no-op (statusChanged: false).quotes.send_email({ id: "<quote-id>" })— the PDF auto-renders on this call, then n8n takes over the SMTP send.
Gotchas:
customerandcompanyaccept EITHER a 15-char PB id (verbatim) OR a name (case-insensitive exact match againstcustomer.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.submitis the canonical gate between "priced" and "ready to send". Bypassing it (e.g. callingquotes.send_emaildirectly on anewquote) fails the pre-send validation withcode: "invalid_status"becausenewis not inRESENDABLE_STATUSES. Makesubmitpart of the walk-in pipeline.- The quote auto-renders a PDF on the next
quotes.send_emailcall. Don't pre-render — the send path handles it. quotes.createis for walk-ins. For inbound RFQ emails usequotes.ingest_from_email(preferred) orquotes.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.
8. "Walk-in order with supplements"
The operator starts a walk-in order (no upstream quote) and then adds one or two supplement lines that came in via a follow-up call.
orders.create({customer, company, items: [...]})— creates the order withpendingitems. Auto-derivesselling_pricefromcost_price × (1 + markup/100)when omitted.orders.get({id, expand: "items"})— confirm the order ispending-only before adding lines.order_items.create({order_id, items: [{description, ...}]})— strict-pending-gated; if any existing item is pastpending, returns 409order_lockedwithoffendingItemIds[]. Surface the lock list to the operator; the operator must useorders.splitto create a continuation order for the supplements.- After dispatch:
orders.mark_delivered({id})— flips every eligible item todelivered(the tool handles idempotency). - After invoicing:
orders.mark_invoiced({id})— flipsorder.invoiced = trueonly when every line hasitem_status === "invoiced". If any line is stilldeliveredor earlier, returns 409 withoffendingItemIds[].
9. "Daily summary review"
The operator wants a single call to answer "how are we doing today?" without chaining five other tools.
orders.daily_summary({date: "<YYYY-MM-DD>"})— returns{placed, delivered, invoiced, pending, totalSell, totalCost, topCustomers[]}for the day. Use this for the morning standup.- If the operator wants the longer trend, follow up with
orders.stats({from, to, groupBy: "day"})for a per-day rollup across the window. UsegroupBy: "customer"to break the same data down by customer (useful for "which customers drove revenue this month?"). - If the operator wants to drill into one day's pipeline, follow
up with
orders.list({createdAfter: "<date>", createdBefore: "<date+1>", status: "pending"})to surface every order with at least one un-priced / not-yet-PO'd line.
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 |
| Walk-in order + supplements | orders.create, order_items.create, orders.mark_delivered, orders.mark_invoiced |
| Daily summary | orders.daily_summary, orders.stats, orders.list (filtered) |
| 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.