WFDASH-WEBAPP/docs/api-mcp-layer.md
SJCoetz c4125ee40a feat: add split-invoice stock PO workflow and supporting tooling
Ship full split-invoice stock purchase order workflow:
- Add 5 new MCP tools for per-line stock purchase item management and catalogue updates
- Add idempotent database migration adding sku, unit, and supplier_sku fields to stock_purchase_items
- Implement service layers for all new operations, including auto-closing the parent PO when all lines are fully collected
- Add complete test coverage for new services and API routes
- Update all relevant documentation including MCP API specs, agent instructions, and feature request logs
2026-08-07 00:53:29 +02:00

17 KiB

WFDASH API + MCP Layer

This document describes the public HTTP surface intended for external automation (n8n workflows, custom AI agents, the local "Hermes" agent, etc.). Everything below is implemented in this repo and exercised by the Vitest suite under src/app/api/.

The README still lists an older subset of this surface. This file is the source of truth. If you add or rename an endpoint or MCP tool, update both files in the same commit.


1. Environment

Add to .env.local:

NEXT_PUBLIC_POCKETBASE_URL=https://pocketbase-gnqc88suy8fqk6wyryjjdpvl.169.239.180.130.sslip.io
PB_ADMIN_EMAIL=david.els1@outlook.com
PB_ADMIN_PASSWORD=1234567890
AGENT_API_KEY=123456
ORDER_ITEMS_COLLECTION=order_items   # or `order_items_v2` after migration

The MCP endpoint and every /api/v1/* route (except /api/v1/health) require the agent bearer token:

Authorization: Bearer <AGENT_API_KEY>

Rotate AGENT_API_KEY in this file plus every consumer when a key leaks.


2. REST endpoints (/api/v1/*)

All routes return JSON. All PATCH / POST bodies are JSON unless noted. Pagination defaults to page=1, perPage=50 where relevant.

2.1 Health / discovery

Method Path Auth Notes
GET /api/v1/health none { ok, service, time, version, env }

2.2 Orders

Method Path Body / params Notes
GET /api/v1/orders ?page&perPage&status&search Status filter is informational; items drive workflow. Expands company,customer,quote.
POST /api/v1/orders/from-quote { "quote_id": "..." } Creates order + items; idempotent on existing order_for_quote.
GET /api/v1/orders/{id} — Order detail with item rows.
GET /api/v1/orders/{id}/matched-attachments — Returns attachment matches against quote items.

2.3 Order items

Method Path Body Notes
PATCH /api/v1/order-items/{id}/status { item_status?, drv_status?, notes?, problem_notes? } Accepts legacy driver_status alias.

2.4 Quotes

Method Path Body / params Notes
GET /api/v1/quotes/lookup ?q=... Fuzzy lookup by quote number, ref, customer.
GET /api/v1/quotes/next-number — Atomic Q-YYYY-MM-NNN from quote_counters.
POST /api/v1/quotes/{id}/clone — Clone a quote + items.
POST /api/v1/quotes/{id}/save-as-template { name } Save a quote as a template.
POST /api/v1/quotes/{id}/create-from-template { template_id } New quote from a template.
POST /api/v1/quotes/{id}/retry-email — Resend the outbound quote email.
POST /api/v1/quotes/{id}/send-email { template? } Send the outbound quote email.
GET /api/v1/quotes/{id}/pdf — Render the quote PDF.
POST /api/v1/quotes/{id}/re-price { items: [...] } Bulk re-price quote items.
GET /api/v1/quotes/{id}/attachments ?direction=inbound|outbound|generated|all List quote-level attachments.
POST /api/v1/quotes/{id}/attachments/upload multipart file Upload a quote-level attachment (admin).
GET /api/v1/quotes/{id}/items/{itemId}/match — Match a quote item to a price-list row.
GET /api/v1/quotes/{id}/items/{itemId}/lookup — Suggest matches for an item.
POST /api/v1/quotes/items/rewrite { items: [...] } AI rewrite item descriptions.

2.5 Quote item attachments

Method Path Body Notes
POST /api/v1/quotes/items/{id}/attachments/upload multipart file New manual-upload endpoint (source = "manual").

2.6 Sprint

Method Path Body Notes
POST /api/v1/sprint/items/{id}/claim { ttl_minutes? } Claim a sprint row (30-min default lock).
GET /api/v1/sprint/items/{id}/attachments — List attachments for a quote item.
POST /api/v1/sprint/items/{id}/attachments/upload multipart file Upload a pricing proof (operator or AI).
GET /api/v1/sprint/attachment-counts ?quote_item_ids=... Bulk counts for sprint badges.

2.7 Companies

Method Path Body Notes
GET /api/v1/companies ?page&perPage&search&sort Filter on company / vendor substring.
POST /api/v1/companies { company, address?, vendor?, coordinates?: {lon,lat} } company required.
GET /api/v1/companies/{id} — Single row.
PATCH /api/v1/companies/{id} partial of create body
DELETE /api/v1/companies/{id} — PB cascade rules apply.

2.8 Customers

Method Path Body Notes
GET /api/v1/customers ?page&perPage&search&sort&expand expand=company,rep to inline.
POST /api/v1/customers { customer, email?, alternative_email?, buyer_email?, number?, company?, rep?, coordinates?, description? } customer required. company / rep accept id or name.
GET /api/v1/customers/{id} ?expand
PATCH /api/v1/customers/{id} partial Empty string unsets company / rep.
DELETE /api/v1/customers/{id} — Cascade rules apply.

2.9 Suppliers

Method Path Body Notes
GET /api/v1/suppliers ?page&perPage&search&sort coordinates is the legacy "lat, lng" text.
POST /api/v1/suppliers { suppliername, email?, suppliernumber?, supplieraddress?, supply_tags?, coordinates?, closingtime? }
GET /api/v1/suppliers/{id} —
PATCH /api/v1/suppliers/{id} partial
DELETE /api/v1/suppliers/{id} — Cascade rules apply.

2.10 Collection pool / map

Method Path Body Notes
GET /api/v1/collection-pool ?page&perPage&status Pool status filter.
POST /api/v1/collection-map/items/{id}/assign { driver_id } Assign a pool row to a driver.

2.11 Purchase orders

Method Path Body Notes
POST /api/v1/purchase-orders/{id}/dispatch { channel: "email"|"print" } Dispatch PO.
POST /api/v1/purchase-orders/{id}/send-email { to?, cc?, body? } Send PO email.
GET /api/v1/purchase-orders/{id}/send-email/outcome — Outcome of last send.
GET /api/v1/purchase-orders/{id}/pdf — Render PO PDF.
GET /api/v1/purchase-orders/stale ?days=... List POs untouched for N days.
POST /api/v1/purchase-orders/{id}/attachments/upload multipart/form-data field file Append a free-form attachment to additional_attachments. Max 25 MB.
DELETE /api/v1/purchase-orders/{id}/attachments/{filename} — Remove a single additional-attachment token.

2.12 Stock purchases

Method Path Body Notes
POST /api/v1/stock-purchases/{id}/attachments/upload multipart/form-data field file Append a free-form attachment (supplier invoice, packing slip) to stock_purchases.additional_attachments. Max 25 MB.
DELETE /api/v1/stock-purchases/{id}/attachments/{filename} — Remove a single additional-attachment token.

2.13 Users / file proxy

Method Path Body Notes
GET /api/v1/users/lookup ?q=... User search.
GET /api/v1/files/{collection}/{recordId}/{filename} — Auth-aware file proxy for collections whose rules reject the client.

3. MCP endpoint (/api/mcp)

JSON-RPC over HTTP. Same Authorization bearer as /api/v1/*.

3.1 Methods

  • tools/list — returns the full tool list (23 tools as of writing).
  • tools/call — { params: { name, arguments } }.

3.2 Tools

Orders / pool / sprint

Tool Purpose
orders.list List orders by order_number substring.
orders.create_from_quote Create order + items from a quote.
collection_pool.list List pool rows by status.
order_items.update_status Update item_status / drv_status / notes.

Quotes (email pipeline)

Tool Purpose
quotes.create_from_email Create quote + customer + items from parsed email. Idempotent on message_id. Use attachments.upload_for_quote per file after this returns.
quotes.append_reply Append a structured reply. Matches In-Reply-To then Q-YYYY-MM-NNN in subject.
quotes.find_by_message_id Look up by RFC 5322 Message-ID.
attachments.upload_for_quote Upload one attachment to a quote (base64, ≤25 MB).
bulk_update_quote_items Patch up to 100 quote items in one call (whitelisted fields only).

Companies / customers / suppliers (full CRUD)

Tool Purpose
companies.list / .get / .create / .update / .delete Full CRUD.
customers.list / .get / .create / .update / .delete Full CRUD. company / rep accept id or name.
suppliers.list / .get / .create / .update / .delete Full CRUD.

Stock purchases / POs (attachments)

Tool Purpose
purchase_orders.add_attachment Upload one free-form attachment to a PO (base64, ≤25 MB).
stock_purchases.add_attachment Upload one free-form attachment (supplier invoice, packing slip, photo of damaged stock) to a stock purchase (base64, ≤25 MB). Appends to additional_attachments; does NOT touch the versioned-PDF documents ledger.

Stock purchases (line-level writes — added 2026-08-07)

Tool Purpose
stock_purchase_items.update Patch whitelisted pricing + catalogue fields on a single stock_purchase_item (cost_price, sku, description, notes, unit, supplier_sku, quantity). Hand-edit guard on cost_price > 0.
stock_purchase_items.bulk_update Batch patch (≤100 rows). Same field whitelist + hand-edit guard as .update. Per-row independence; dry_run: true returns identical shape with status: "dry_run" per row.
stock_purchase_items.assign_driver Bind a line to a driver (drv_id). Transitions line status null → assigned_to_driver or collection_pool → assigned_to_driver.
stock_purchase_items.mark_collected Mark a line as physically delivered. Status → collected, bumps quantity_received. Does NOT book into stock — that's stock_purchases.receive. Header auto-flips to received when every line on the PO has quantity_received >= quantity.

Stock items (catalogue)

Tool Purpose
stock_items.update Patch catalogue fields on a master stock_item (sku, description, notes, unit, selling_price, markup). For inventory qty use stock_items.adjust.

3.3 Tool input shapes (condensed)

quotes.create_from_email (required: subject):

{
  "from_email": "buyer@acme.co.za",
  "from_name": "Acme Procurement",
  "subject": "RFQ — magnetic drill",
  "body_plain": "...",
  "body_html": "...",
  "message_id": "<unique-id@mail>",
  "in_reply_to": "<reply@mail>",
  "received_at": "2026-07-09T10:00:00Z",
  "company_hint": "Acme",
  "extracted_items": [
    { "description": "Magnetic drill", "quantity": 1, "unit": "ea", "notes": "" }
  ],
  "attachments": []
}

attachments.upload_for_quote (required: quote_id, filename, base64):

{
  "quote_id": "abc123",
  "filename": "spec.pdf",
  "content_type": "application/pdf",
  "size": 482113,
  "base64": "JVBERi0xLjQK..."
}

stock_purchase_items.update (required: id, patch):

{
  "id": "15-char-line-id",
  "patch": {
    "cost_price": 145.50,
    "sku": "HYD68-20",
    "supplier_sku": "SUP-HYD-068"
  },
  "force_overwrite": false
}

Whitelist: cost_price, sku, description, notes, unit, supplier_sku, quantity. Anything else returns forbidden_field. Lines with cost_price > 0 already return already_priced unless force_overwrite: true.

stock_purchase_items.bulk_update (required: items):

{
  "items": [
    { "id": "line-a", "patch": { "sku": "HYD68-20" } },
    { "id": "line-b", "patch": { "cost_price": 230.00 }, "force_overwrite": true }
  ],
  "dry_run": false
}

Returns { updated, skipped, errors, results: [{id, status, reason?, message?, previous?, item?}] }. Cap is 100 rows per call.

stock_purchase_items.assign_driver (required: id, drv_id):

{ "id": "15-char-line-id", "drv_id": "15-char-driver-id", "assigned_at": "2026-08-07T08:00:00Z" }

stock_purchase_items.mark_collected (required: id):

{ "id": "15-char-line-id", "quantity_collected": 4 }

Returns { item, previous_quantity_received, new_quantity_received, header_status_after, header_flipped }. header_flipped: true when this call completed the parent PO (auto-flip from sent → received).

stock_items.update (required: id, patch):

{
  "id": "15-char-stock-item-id",
  "patch": {
    "sku": "HYD68-20",
    "selling_price": 285.00,
    "markup": 35
  }
}

Whitelist: sku, description, notes, unit, selling_price, markup. Anything else returns reason: "forbidden_field". For inventory qty use stock_items.adjust. Returns {ok: true, item, previous} on success or {ok: false, reason, message, field?} on rejection.

bulk_update_quote_items (required: items):

{
  "items": [
    {
      "id": "15-char-quote-item-id",
      "patch": {
        "cost_price": 1200.00,
        "selling_price": 1800.00,
        "supplier": "15-char-supplier-id",
        "pricing_source": "ai",
        "pricing_notes": "Auto-priced from Acme catalogue 2026-07-09"
      },
      "force_overwrite": false
    }
  ],
  "dry_run": false
}

Whitelist: cost_price, selling_price, description, sku, unit, supplier, markup, pricing_source, pricing_notes, matched_price_list_id, user_edited. Anything else returns reason: "forbidden_field" per row. Pre-priced rows (cost_price > 0 AND selling_price > 0) are skipped with reason: "already_priced" unless force_overwrite: true. Cap is 100 rows per call.

companies.create:

{ "company": "Acme", "address": "1 Road", "vendor": "V-1", "coordinates": { "lon": 28.04, "lat": -26.20 } }

customers.create:

{
  "customer": "Acme Procurement",
  "email": "buyer@acme.co.za",
  "alternative_email": "ops@acme.co.za",
  "buyer_email": "ap@acme.co.za",
  "number": "+27 11 555 0100",
  "company": "Acme",
  "rep": "David",
  "coordinates": { "lon": 28.04, "lat": -26.20 },
  "description": "Industrial buyer"
}

suppliers.create:

{
  "suppliername": "Acme Supplies",
  "email": "sales@acme.co.za",
  "suppliernumber": "+27 11 555 0200",
  "supplieraddress": "2 Road",
  "supply_tags": "tools,fasteners",
  "coordinates": "-26.20, 28.04",
  "closingtime": "17:00"
}

3.4 Response envelopes

Success:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "content": [{ "type": "text", "text": "{\"...\":\"...\"}" }]
  }
}

Error:

{
  "jsonrpc": "2.0",
  "id": 1,
  "error": { "code": -32601, "message": "Method not found" }
}

Auth failures return HTTP 401 for /api/v1/* but land in the JSON-RPC error envelope for /api/mcp (per the in-tree test suite).


4. Known quirks

  • orders.list has no status filter. The orders.status column was removed; status lives on items now. The MCP orders.list tool only supports search / order_number substring.
  • Quote-level attachments via the REST route (/api/v1/quotes/{id}/attachments/upload) and via the MCP tool (attachments.upload_for_quote) are independent code paths. New agents should prefer the MCP tool.
  • Quote-item attachments have two upload routes — the new one at /api/v1/quotes/items/{id}/attachments/upload (source "manual"), and the sprint one at /api/v1/sprint/items/{id}/attachments/upload (source "operator_proof" / "agent_proof"). Use the sprint route for pricing proofs; use the quotes route for raw attachments coming in with the original RFQ.
  • customers.create / customers.update accept company and rep as either ids or case-insensitive names. An unresolved name leaves the relation empty (no error) so the operator can wire it up later.
  • suppliers.coordinates is the legacy "lat, lng" text field. Don't migrate it to {lon,lat} — supplier-map.ts still parses the text form.

5. Testing the surface

Unit/API tests:

npm run test:run -- src/app/api

End-to-end (boots Next dev on :3100):

export E2E_BASE_URL=http://localhost:3100
export E2E_AGENT_API_KEY=$AGENT_API_KEY
npm run e2e

The Playwright suite intentionally skips env-driven specs when the keys aren't set, so a clean checkout reports passed, N skipped.