WFDASH-WEBAPP/docs/agents/workflows.md
Dave 1bc80dc686 feat(orders): enhance order retrieval and statistics functionality
- 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.
2026-08-16 00:51:24 +02:00

337 lines
No EOL
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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).