- 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.
337 lines
No EOL
14 KiB
Markdown
337 lines
No EOL
14 KiB
Markdown
# 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="<id>" &&
|
||
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.
|
||
|
||
---
|
||
|
||
## 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.
|
||
|
||
1. `orders.create({customer, company, items: [...]})` — creates
|
||
the order with `pending` items. Auto-derives `selling_price`
|
||
from `cost_price × (1 + markup/100)` when omitted.
|
||
2. `orders.get({id, expand: "items"})` — confirm the order is
|
||
`pending`-only before adding lines.
|
||
3. `order_items.create({order_id, items: [{description, ...}]})`
|
||
— strict-pending-gated; if any existing item is past
|
||
`pending`, returns 409 `order_locked` with
|
||
`offendingItemIds[]`. Surface the lock list to the operator;
|
||
the operator must use `orders.split` to create a continuation
|
||
order for the supplements.
|
||
4. After dispatch: `orders.mark_delivered({id})` — flips every
|
||
eligible item to `delivered` (the tool handles idempotency).
|
||
5. After invoicing: `orders.mark_invoiced({id})` — flips
|
||
`order.invoiced = true` only when every line has
|
||
`item_status === "invoiced"`. If any line is still
|
||
`delivered` or earlier, returns 409 with `offendingItemIds[]`.
|
||
|
||
---
|
||
|
||
## 9. "Daily summary review"
|
||
|
||
The operator wants a single call to answer "how are we doing
|
||
today?" without chaining five other tools.
|
||
|
||
1. `orders.daily_summary({date: "<YYYY-MM-DD>"})` — returns
|
||
`{placed, delivered, invoiced, pending, totalSell, totalCost,
|
||
topCustomers[]}` for the day. Use this for the morning standup.
|
||
2. If the operator wants the longer trend, follow up with
|
||
`orders.stats({from, to, groupBy: "day"})` for a per-day rollup
|
||
across the window. Use `groupBy: "customer"` to break the same
|
||
data down by customer (useful for "which customers drove
|
||
revenue this month?").
|
||
3. 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`](../api-mcp-layer.md). |