WFDASH-WEBAPP/src/app/api/mcp/FEATURE-REQUESTS.md
SJCoetz 60d320411d feat(mcp): add stock purchase invoice reconciliation tool
Add the full buyer-side supplier invoice reconciliation workflow:
- Include the new `stock_purchases.match_and_receive_from_invoice` MCP tool for deterministic PO-invoice matching
- Create core matching service with strict, rule-based line matching (sku → supplier_sku → description overlap)
- Support dry-run default mode to validate matches before committing
- Add automatic attachment handling with standard `supplier_invoice_*` naming convention
- Add full test suite for the matching logic
- Update documentation and agent skill profile to document the new tool and workflow

Pre-existing test failures (3 total) are unrelated to this change.
2026-08-09 16:59:44 +02:00

132 lines
15 KiB
Markdown
Raw 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 Feature Requests & Gaps
Captures requests the existing WFDASH MCP tools can't fulfil, surfaced from real
questions asked by agent operators / the agent itself. Each entry should be
small enough to ship in a single PR.
When one ships, move it to **Implemented** with a one-line commit/PR reference.
## Entry template
```
### [SHORT TITLE]
- **Surfaced:** YYYY-MM-DD — <quoted or paraphrased user question>
- **Workaround:** <what we did in the meantime to answer the question>
- **Severity:** now | nice-to-have
- **Suggested tool:** <name + signature, e.g. `orders.stats({from, to})`>
- **Schema needs:** <PB field additions / changes, or "none">
- **Open questions:** <anything to clarify with ops before implementing>
```
## Open requests
### Orders: filter by customer
- **Surfaced:** 2026-08-06 — "last 3 orders for Seth"
- **Workaround:** filter `order_items` by `order.customer = "<id>"`, dedupe to distinct orders
- **Severity:** now — every "orders for customer X" question hits this
- **Suggested tool:** add `customer` (id or name) + `from`/`to` filters to `orders.list`
- **Schema needs:** none
### Orders: date-range filter on `orders.list`
- **Surfaced:** 2026-08-06 — "how many orders were placed yesterday"
- **Workaround:** fall back to `order_items.list` with `created` filter, dedupe to distinct orders
- **Severity:** now — every "yesterday / last week / this month" question hits this
- **Suggested tool:** add `filter` (PB syntax) + `createdAfter` / `createdBefore` to `orders.list`
- **Schema needs:** none
### Orders: aggregate money value (count + selling total + cost total)
- **Surfaced:** 2026-08-06 — "...give me the money value aswell"
- **Workaround:** sum `selling_price × quantity` across items client-side
- **Severity:** now — every count question also wants a value
- **Suggested tool:** `orders.stats({from, to, groupBy?: 'day'|'customer'|'supplier'})` returning `{count, totalSell, totalCost}`
- **Schema needs:** none
### Order items: clarify "received" — no unambiguous field
- **Surfaced:** 2026-08-06 — "how many orders were received yesterday"
- **Workaround:** filtered by `qty_collected > 0 && updated` on target date — proxy, not exact
- **Severity:** now — user had to disambiguate ("received" can mean received-from-supplier, collected-from-warehouse, or delivered-to-customer)
- **Open questions:** ops must decide which definition to standardise on; current candidates are `drv_collected_at` (12 items/d yesterday) and `delivered_at` (0 items/d yesterday)
- **Suggested tool:** once defined, add a `received_at` filter param to `order_items.list` and a `orders.received_between` helper
- **Schema needs:** *probably* none if we pick an existing field; might need a new `received_at` if the chosen definition isn't already captured
### Order items: detect items missing `selling_price`
- **Surfaced:** 2026-08-06 — 4 of 42 items placed yesterday had `selling_price = 0` (still unpriced at end of day)
- **Workaround:** filter `selling_price = 0 && created > X` and report
- **Severity:** nice-to-have — these are unpriced orders that shouldn't ship without review
- **Suggested tool:** `orders.unpriced_items({from, to, minAgeHours?: number})` returning `{orderId, itemId, description, createdAt, hoursUnpriced}`
- **Schema needs:** none
### Dashboard: single-call daily summary
- **Surfaced:** 2026-08-06 — combining counts + values across multiple tools to answer one question
- **Workaround:** 3–4 separate tool calls + client-side aggregation
- **Severity:** nice-to-have
- **Suggested tool:** `dashboard.daily_summary({date, includeReceived: bool})` returning a single payload with placed + received counts and totals
- **Schema needs:** none
### Order items: whitelist missing `sku` field
- **Surfaced:** 2026-08-06 — "the items on PO-2026-08-006 add this sku for each item and save it" — every patch with `sku` rejected
- **Workaround:** none — `sku` is a real PB field on `order_items`, but the route.ts whitelist (`ORDER_ITEM_PATCH_FIELDS`) omitted it. Locally fixed by adding `"sku"` to the array at `route.ts:145`; still pending deploy on `wfdash.apphub.co.za`
- **Severity:** now — every supplier PO needs SKUs and the agent can't set them
- **Suggested tool:** no new tool — just keep the whitelist in sync with the real PB schema. Better long-term: derive whitelist from the live PB schema on each call (or at boot) instead of a hardcoded array, so schema drift never silently blocks writes
- **Schema needs:** none — PB field exists, the gap is MCP-only
- **Open questions:** should the patch tool also reject unknown keys silently (current behaviour is a hard 4xx — fine), or warn-and-accept? User seems happy with hard rejects for now.
### Orders: no create-from-scratch — only from a quote
- **Surfaced:** 2026-08-06 — "can you create a order or load items as a order using mcp?"
- **Workaround:** the only path is `quotes.*` → `orders.create_from_quote`. To create an order from scratch via MCP: (1) seed a quote via `quotes_create_from_email` or `quotes_create_from_template`, (2) add items with `quote_items_create` (or `bulk_update_quote_items`), (3) call `orders.create_from_quote`. There is NO `orders.create` and NO `order_items.create`.
- **Severity:** nice-to-have — the quote-pipe works, but it's 3+ calls and forces the user to materialise a quote just to load an order
- **Suggested tool:** `orders.create({customer, company, items: [{description, quantity, unit?, sku?, cost_price?, selling_price?}]})` returning the new order + items. (Pushes the same idea further: `order_items.create({order_id, ...})` if items need to be appended to an existing order.)
- **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.
- **Severity:** nice-to-have
- **Suggested tool:** `stock_purchases.create_from_pdf({supplier, base64, content_type})` that parses supplier PO PDFs (using something like `pdf-parse` or `unpdf`) and emits a draft `stock_purchase` for review. Alternatively, `stock_purchases.create_from_email({from, subject, body, ...})` mirroring `quotes_create_from_email`.
- **Schema needs:** none — the `stock_purchase` + `stock_purchase_item` collections already exist
- **Open questions:** which PDF format(s) does the user want supported? Many SA suppliers send bespoke PO PDFs — a generic parser would need supplier-specific extraction rules. Probably needs an LLM-driven extraction step rather than layout heuristics. Worth scoping before implementation.
### Stock purchases: paperclip badge on list table
- **Surfaced:** 2026-08-06 — surfaced during the attachments-card PR (UI ships the detail-page affordance but the list table still shows no document indicator)
- **Severity:** nice-to-have
- **Suggested tool:** add a `count_additional_attachments` field to `stock_purchases.list` (or a separate `/api/v1/stock-purchases/attachment-counts` endpoint mirroring `/api/v1/sprint/attachment-counts`), render the sprint-style `<Paperclip>` chip in `StockOrdersTable` per row
- **Schema needs:** none — derived from `additional_attachments.length`
### Drivers: no MCP visibility or assignment
- **Surfaced:** 2026-08-06 — "can you see the driver stops and assign stops to drivers?"
- **Workaround:** "driver" data lives only as three fields on `order_items` (`drv_id`, `drv_assigned_at`, `drv_collected_at`). There is no separate `drivers` collection, no MCP tool to list drivers, no tool to see today's stops for one driver, no tool to bulk-assign stops to a driver. Operators can call `orders.list` and manually click through. Effectively the driver flow is invisible to the agent.
- **Severity:** now — driver dispatch is a daily workflow and currently bypasses the agent entirely
- **Suggested tool:** `drivers.list()` returning `{id, name, currentLoad, maxStops}`. `drivers.assign_stops({drv_id, order_item_ids:[...]})` to bulk-assign. `drivers.stops({drv_id, date})` returning today's route. If `drivers` isn't a real PB collection, the simplest first cut is virtual (read drivers off `users` or `suppliers` where they probably already exist) — but that needs ops to confirm the canonical source.
- **Schema needs:** possibly a new `drivers` collection if there isn't one already; otherwise a way to identify the driver relation (`drv_id` currently points to a freeform string, not a PB relation)
- **Open questions:** where is the canonical drivers list today? `users`? `suppliers`? a CSV? "Assign" semantics — operator workflow or agent-controlled?
### Suppliers: read/update works, geocoding does not
- **Surfaced:** 2026-08-06 — "can you access suppliers and geocode them update the lat and long if needed ? and the address ?"
- **Read:** ✅ `suppliers.list` / `suppliers.get` already return full records including `coordinates` (legacy "lat, lng" string) and `supplieraddress`
- **Update:** ✅ `suppliers.update({id, patch:{supplieraddress, coordinates, ...}})` patches those fields today
- **Geocode:** ❌ no MCP tool — there's no geocoding service exposed, so the agent can't *generate* the lat/lng from an address. Anyone wanting current coordinates has to call Google Maps / Nominatim externally and then patch the result via `suppliers.update`. `coordinates` is also still the legacy text "lat, lng" string on `suppliers` (it's pre-`geoPoint`) — even when the agent *has* lat/lng it has to know to format it as "lat,lng".
- **Severity:** now — the user clearly wants the round-trip (read missing coords → geocode → write back) automated, not hand-driven
- **Suggested tool:** `suppliers.geocode({id?, address?, limit?: 10})` calling a hosted geocoder (OpenStreetMap Nominatim is free and sufficient; Google would need a key). Returns `{lat, lng, formatted_address, confidence}` and optionally persists via `suppliers.update` if `id` is passed. Migrate `coordinates` to `geoPoint` while we're here so the round trip becomes a single field write.
- **Schema needs:** migrate `suppliers.coordinates` from text to PB `geoPoint` (delete the field, recreate it as lat/lng pair). Backfill existing rows from the parsed "lat,lng" strings first.
## Schema questions for ops
These came up but need a human answer before code changes:
- **"Received" semantics** — see `Order items: clarify "received"` above. Also, the `order_items_update` docstring references fields (`collection_date`, `collected_date`, `verified_date`, `delivered_date`) that don't exist in the current PB schema (only `drv_collected_at`, `delivered_at`, `assigned_at`, `drv_assigned_at` exist). Either the docstring is stale or those fields were planned and never created.
- **Currency notes** — prices are stored as numbers but the `notes` field on items embeds "R" (ZAR) strings. Confirm the system is single-currency ZAR before adding any multi-currency stats.
## Implemented
- 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`, `invoice.{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. Closes the "AI checks my invoices for the buyer" workflow surfaced 2026-08-09. Implementation: new `src/lib/server/stock-purchase-invoice-matching-service.ts` (+ matching `.test.ts`), wired into `src/app/api/mcp/route.ts` (one new tool entry + one dispatch block + 4 dispatch tests). 1,654/1,657 vitest tests pass; the 3 failing tests are pre-existing (PocketBase SDK mock mismatch in `pocketbase-admin.test.ts`, 5s timeout in `quotes/items/[id]/attachments/upload/route.test.ts` — both reproducible on `main` without this change).