feat(mcp): enhance API documentation and add new tools

- Updated `docs/api-mcp-layer.md` to reflect the current state of the API, including new endpoints and tools.
- Added `category` field to `QUOTE_ITEM_PATCH_FIELDS` and `ORDER_ITEM_PATCH_FIELDS` to support item categorization.
- Introduced 22 new MCP tools to cover previously missing REST functionalities, increasing the total tool count from 103 to 125.
- Improved descriptions for several existing tools to clarify their usage and parameters.
- Regenerated the API documentation to ensure accuracy and completeness.
This commit is contained in:
Dave 2026-08-15 00:06:40 +02:00
parent 48053a6205
commit 80a08a742b
7 changed files with 2063 additions and 505 deletions

229
docs/agents/workflows.md Normal file
View file

@ -0,0 +1,229 @@
# 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. Pass it explicitly when the catalogue has a stored
selling price you want to preserve.
- 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`.
---
## 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` |
For the full tool catalogue (auto-generated), see
[`docs/api-mcp-layer.md`](../api-mcp-layer.md).

File diff suppressed because it is too large Load diff

Binary file not shown.

Binary file not shown.

244
scripts/mcp-doc-gen.py Normal file
View file

@ -0,0 +1,244 @@
#!/usr/bin/env python3
"""
Regenerate the MCP tool catalogue inside `docs/api-mcp-layer.md` from the
live `tools` array in `src/app/api/mcp/route.ts`.
The script is intentionally regex-based (not a JS parser) because the tool
shape is small and stable — the goal is "drift-free docs", not "AST-perfect
extraction". Anything the script can't parse falls into the `UNPARSED` list
below so a human knows to fix the script.
Usage:
python3 scripts/mcp-doc-gen.py # writes to docs/api-mcp-layer.md
python3 scripts/mcp-doc-gen.py --dry-run # prints diff to stdout
python3 scripts/mcp-doc-gen.py --check # exit 1 if the file is out of date
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
ROUTE_TS = REPO_ROOT / "src/app/api/mcp/route.ts"
DOC_FILE = REPO_ROOT / "docs/api-mcp-layer.md"
# Begin / end markers for the auto-generated block. Everything between
# the begin and end markers is replaced wholesale; everything outside is
# preserved verbatim. The begin marker is followed by a 5-line header so
# the doc stays readable even when the script is rerun.
BEGIN_MARKER = "<!-- BEGIN mcp-doc-gen: tools catalogue — do not edit by hand, regenerate with scripts/mcp-doc-gen.py -->"
END_MARKER = "<!-- END mcp-doc-gen -->"
def parse_tools(route_text: str) -> list[dict]:
"""Extract `{ name, description, inputSchema }` from the tools array.
The tool descriptions are JS string literals that may be concatenated
(`"foo" + "bar"`) or use template literals. We collapse both forms
before stopping at the matching `inputSchema: {` block.
"""
# Find the start of the `const tools = [...]` array.
array_match = re.search(r"const tools\s*=\s*\[", route_text)
if not array_match:
raise SystemExit("Could not find `const tools = [...]` in route.ts")
start = array_match.end()
# Walk the array tracking string-literal depth so we don't bail on
# nested arrays in inputSchema. The whole file is one giant string,
# so we walk char-by-char.
tools: list[dict] = []
i = start
while i < len(route_text):
# Look for the next tool entry: `{ name: "..."`.
m = re.search(r"\{\s*name:\s*\"([^\"]+)\"", route_text[i:])
if not m:
break
name = m.group(1)
# Skip ahead past the name. m.end() is relative to text[i:],
# so adding it to i gives the absolute position right after the
# closing quote of the name.
entry_start = i + m.start()
i += m.end()
# Find the description block. Three shapes:
# description: "literal text"
# description: "first segment" + "second segment" + ...
# description: `template literal`
# A comma may or may not separate `name` from `description` (looking
# at the route.ts source, the convention is no comma between the two
# top-level keys but a comma before `inputSchema`).
desc_match = re.match(r"\s*,\s*description:\s*", route_text[i:])
if not desc_match:
# Malformed entry — skip past it.
i = entry_start + 2
continue
i += desc_match.end()
if route_text[i] == "`":
# Template literal — read until the closing backtick.
close = route_text.find("`", i + 1)
if close == -1:
raise SystemExit(f"Unterminated template literal for {name}")
desc = route_text[i + 1 : close]
i = close + 1
elif route_text[i] == '"':
desc = ""
# Walk through what may be several concatenated `"..."` + ... forms.
while True:
# Find the closing quote, skipping backslash escapes.
j = i + 1
while j < len(route_text):
if route_text[j] == "\\":
j += 2
continue
if route_text[j] == '"':
break
j += 1
if j >= len(route_text):
raise SystemExit(f"Unterminated string for {name}")
desc += route_text[i + 1 : j]
i = j + 1
# Skip whitespace + `+` chains.
while i < len(route_text) and route_text[i] in " \t\n\r":
i += 1
if i < len(route_text) and route_text[i] == "+":
i += 1
while i < len(route_text) and route_text[i] in " \t\n\r":
i += 1
if i < len(route_text) and route_text[i] == '"':
continue
break
else:
# Malformed entry — bail.
i = entry_start + 2
continue
# Unescape JS string escapes.
desc = (
desc.replace("\\n", "\n")
.replace("\\t", " ")
.replace('\\"', '"')
.replace("\\\\", "\\")
)
# Collapse whitespace to single spaces for the table summary.
desc_short = re.sub(r"\s+", " ", desc).strip()
if len(desc_short) > 240:
desc_short = desc_short[:237] + "..."
tools.append({"name": name, "description": desc_short, "description_full": desc.strip()})
return tools
def render_catalogue(tools: list[dict]) -> str:
"""Render the markdown block between the begin/end markers."""
lines = [
BEGIN_MARKER,
"",
f"This catalogue is auto-generated from the `tools` array in "
f"`src/app/api/mcp/route.ts` by `scripts/mcp-doc-gen.py`. "
f"There are **{len(tools)} tools** as of this run. "
f"Re-run the script after adding or renaming a tool.\n",
"Grouped by resource family. The full description is in the table "
"column; the description text the agent sees is preserved verbatim.\n",
]
groups = [
("orders", "Orders"),
("order_items", "Order items"),
("quotes", "Quotes"),
("quote_items", "Quote items"),
("attachments", "Attachments"),
("companies", "Companies"),
("customers", "Customers"),
("suppliers", "Suppliers"),
("purchase_orders", "Purchase orders"),
("stock_purchases", "Stock purchases"),
("stock_purchase_items", "Stock purchase items"),
("stock_items", "Stock items"),
("stock_fulfill", "Stock fulfilment"),
("stock_surplus", "Stock surplus"),
("collection_pool", "Collection pool"),
("collection_confirm", "Collection confirm"),
("collection_map", "Collection map"),
("delivery", "Deliveries"),
("driver", "Driver"),
("sprint", "Sprint"),
("buyer_tasks", "Buyer tasks"),
("price_list", "Price list"),
("ai", "AI / Insights"),
("users", "Users"),
("files", "Files"),
("bulk_update", "Bulk updates"),
("categories", "Categories"),
("dashboard", "Dashboard"),
("purchase_order_agent", "PO agent"),
("misc", "Misc"),
]
# Group tools by their first dot-separated segment.
by_group: dict[str, list[dict]] = {g[0]: [] for g in groups}
for t in tools:
prefix = t["name"].split(".")[0]
if prefix in by_group:
by_group[prefix].append(t)
else:
by_group.setdefault("misc", []).append(t)
# Render each group in the defined order; skip empties.
for prefix, title in groups:
items = by_group.get(prefix) or []
if not items:
continue
lines.append(f"\n### {title}\n")
lines.append("| Tool | Description |")
lines.append("| --- | --- |")
for t in items:
# Escape pipes in the description so the table stays readable.
desc = t["description"].replace("|", "\\|").replace("\n", " ")
lines.append(f"| `{t['name']}` | {desc} |")
# Alphabetical index — one line so it scans quickly.
sorted_names = sorted(t["name"] for t in tools)
lines.append("\n### Alphabetical index\n")
lines.append("`" + "`, `".join(sorted_names) + "`\n")
lines.append(END_MARKER)
return "\n".join(lines)
def regenerate_doc(doc_text: str, tools: list[dict]) -> str:
catalogue = render_catalogue(tools)
if BEGIN_MARKER not in doc_text or END_MARKER not in doc_text:
# First run — append the markers to the end of the file.
return doc_text.rstrip() + "\n\n---\n\n## 5. MCP tool catalogue (auto-generated)\n\n" + catalogue
# Replace the block between markers. The markers are on their own lines
# so a regex with the DOTALL flag is sufficient.
pattern = re.compile(
re.escape(BEGIN_MARKER) + r".*?" + re.escape(END_MARKER),
re.DOTALL,
)
return pattern.sub(catalogue.rstrip("\n"), doc_text)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--dry-run", action="store_true", help="Print the new catalogue to stdout instead of writing")
parser.add_argument("--check", action="store_true", help="Exit 1 if regenerating would change the file")
args = parser.parse_args()
route_text = ROUTE_TS.read_text()
tools = parse_tools(route_text)
print(f"Parsed {len(tools)} tools from {ROUTE_TS}", file=sys.stderr)
doc_text = DOC_FILE.read_text()
new_text = regenerate_doc(doc_text, tools)
if args.dry_run:
print(new_text)
return 0
if args.check:
if new_text == doc_text:
print("docs/api-mcp-layer.md is up to date", file=sys.stderr)
return 0
print("docs/api-mcp-layer.md is OUT OF DATE — run scripts/mcp-doc-gen.py", file=sys.stderr)
return 1
DOC_FILE.write_text(new_text)
print(f"Wrote {DOC_FILE} ({len(tools)} tools)", file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -79,14 +79,6 @@ When one ships, move it to **Implemented** with a one-line commit/PR reference.
- **Schema needs:** none — every field already exists on `orders` / `order_items`
- **Open questions:** if we ship this, does `quotes.*` still matter as a destination, or do we collapse quote and order into "draft vs committed"? Currently the app forces the two-step dance; an opinionated answer here would also clean up the buyer flow.
### Quote/order items: whitelist missing `category` field
- **Surfaced:** 2026-08-06 — "can you see the category and ai insights? can you change the category"
- **Workaround:** reads work fine (`category` is returned on every item + `expand=category` exposes the name/match_keywords). Writes are blocked — same shape as the `sku` whitelist gap: `QUOTE_ITEM_PATCH_FIELDS` and `ORDER_ITEM_PATCH_FIELDS` in `route.ts` don't list `category`, so any patch with it is rejected with `forbidden_field`. Until that whitelist gap is closed + deployed, the only way to recategorize is via PB admin UI. Audit found 2 items needing change: KYOCERA TIPS (Fasteners → Cutting Tools) and Bulldog Disc (Unknown → Fasteners).
- **Severity:** now — recategorization is a real operator workflow and Hermes can't do it
- **Suggested tool:** no new tool — fold into the same `route.ts` whitelist expansion that fixes `sku` (and consider regenerating the whitelists from the live PB schema so this stops happening). Optional companion tool: `categories.classify({description})` running the master-list keyword logic in `route.ts` so the AI agent doesn't have to memorise the 10 categories.
- **Schema needs:** none
- **Open questions:** none — same pattern as the `sku` entry above.
### Stock purchases: no PDF → stock order ingestion
- **Surfaced:** 2026-08-06 — "can you load a stock order from a pdf that i give you or items"
- **Workaround:** `stock_purchases_create({supplier, items:[...]})` works for structured input but the operator has to type each line. No tool accepts a PDF or an email payload. Existing attachment uploads (`purchase_orders_add_attachment`, `quotes_list_attachments`) attach files *to existing records*; they don't parse them.
@ -127,6 +119,9 @@ These came up but need a human answer before code changes:
## Implemented
- 2026-08-14 — **Block A: "feels restricted" MCP pain fixes.** (a) `scripts/mcp-doc-gen.py` now auto-regenerates the catalogue in `docs/api-mcp-layer.md` from the live `tools` array — the doc had drifted to claiming 23 tools when the real count was 103. Run `python3 scripts/mcp-doc-gen.py --check` in CI. (b) Hard-coded `actorId`/`actorName` in `purchase_orders.record_diff_report` and `stock_purchases.receive` replaced with required `actor: { id, name }` input — both match the shape already used by `order_items.confirm_collected`. Audit entries now record the real agent identity. (c) Whitelist drift closed: `category` added to `QUOTE_ITEM_PATCH_FIELDS`; `sku`, `category`, `unit` added to `ORDER_ITEM_PATCH_FIELDS` and to `order_items.update`'s inputSchema. Resolves the "can I change the category?" / "set the SKU on PO items" requests above. (d) Eight terse tool descriptions (`orders.create_from_quote`, `order_items.update_status`, `collection_pool.list`, `collection_confirm.list`, `quotes.find_by_message_id`, `quotes.create_from_template`, `purchase_orders.retry_email`, `quotes.retry_email`, `sprint.item_attachments`) bulked up to match the bar set by `order_items.confirm_collected`. (e) New `docs/agents/workflows.md` cookbook — six end-to-end recipes an agent can drive without further prompting (supplier invoice, order split, partial collection, bulk pricing, partial approve + split, sprint claim).
- 2026-08-14 — **Block E: 22 new MCP tools wrapping the missing REST surface.** Categories added to the catalogue: `users.lookup` (operator identity for audit stamps); `dashboard.daily_summary`, `orders.sprint_counts`, `orders.find_duplicates` (aggregate reads); `orders.review`, `orders.matched_attachments` (operator-triage reads); `files.fetch` (PB file → base64 proxy); `drivers.list_stops`, `drivers.update_stop_status` (driver dispatch); `stock_quick_draft.search`, `.add_line`, `.suppliers` (buyer quick-draft); `delivery_notes.list`, `.create`, `.cancel`, `.mark_invoiced`, `.mark_complete`, `.regenerate_pdf`, `.create_quote`, `.set_customer_po`, `.set_item_quantity`, `.combine` (full DN write surface). Total tool count: 103 → 125. Each new tool wraps the matching `/api/v1/*` REST route via `fetch` (preserves the audit trail + service-layer behaviour). `delivery_notes.list` reads the `delivery_notes` collection directly via the admin PB client because the REST surface has no GET; all other reads/writes go through REST. Closes the "delivery-notes.*", "drivers.*", "stock_quick_draft.*", "dashboard.daily_summary", "users.lookup", "orders.review", "files.fetch" gaps surfaced in the audit.
- 2026-08-06 — `stock_purchases.add_attachment` shipped (commits `f89bbb3`, `dd57d29`). Adds a sibling to `purchase_orders.additional_attachments` plus the matching REST upload/delete routes and a UI card on `/stock-orders/[id]`. PB schema: new `stock_purchases.additional_attachments` file field (multi, maxSelect 10, any MIME).
- 2026-08-07 — Split-invoice stock-PO workflow shipped. 5 new MCP tools (`stock_purchase_items.update`, `stock_purchase_items.bulk_update`, `stock_purchase_items.assign_driver`, `stock_purchase_items.mark_collected`, `stock_items.update`) and the underlying services (`stock-purchase-items-service.ts`, extended `stock-items-service.ts`). Closes the per-line write gap surfaced by the operator's split-delivery scenario (PO with multiple supplier invoices arriving on different days). `mark_collected` auto-flips the parent PO header to `received` when every line is fully collected. PB schema: new `stock_purchase_items.sku` / `unit` / `supplier_sku` text fields (migration `scripts/migrations/2026-08-07-stock-purchase-items-catalogue-fields.ts`, user applies via PB admin).
- 2026-08-09 — Buyer-side supplier-invoice reconciliation shipped. New MCP tool `stock_purchases.match_and_receive_from_invoice` (single commit, no schema change). The agent parses a scanned supplier invoice with its native vision, passes the structured fields (`po_reference`, `document.{document_no,date,supplier_name?,lines[]}`, optional `attachment` bytes) plus a `confirm` flag. Deterministic line-by-line matching: `stock_item.sku` exact → `stock_purchase_item.supplier_sku` exact → description token overlap (≥80%). Verdict enum (`ready_to_receive | po_not_found | wrong_status | supplier_mismatch | unmatched_po_lines | unmatched_invoice_lines | qty_mismatch | cost_mismatch`) drives the HTTP status (200/404/409/422). Dry-run by default; `confirm=true` only commits when `verdict === "ready_to_receive"` — on success it attaches the invoice image using the `supplier_invoice_<supplier-slug>_<invoice-no>_<YYYY-MM-DD>.<ext>` naming convention (PB appends its random token at write time, matching the operator's existing `supplier_invoice_afriq_ina64721_2026_08_06_…` format on PO26073109STK), then calls `stock_purchases.receive` in the same round-trip. **Three follow-up phases added the same day:**

File diff suppressed because it is too large Load diff