Introduces the `person_hint` field to the `EmailIntakePayload`, allowing the system to resolve the actual customer when a rep forwards an email. The `findOrCreateCustomer` function now prioritizes `person_hint` over the sender's email, enabling a more accurate attribution of quotes. This change includes a case-insensitive substring match with a token-drop fallback for multi-token names, ensuring better matching against existing customer records. Additionally, new unit tests have been added to validate this functionality, covering various scenarios including token-drop behavior and ensuring the correct customer is linked when a rep forwards an RFQ. Co-Authored-By: Claude <noreply@anthropic.com>
163 lines
4.9 KiB
Markdown
163 lines
4.9 KiB
Markdown
# WFDASH API — Create Order (POST /api/v1/orders/create)
|
|
|
|
> **One payload, one call.** The full order header + items array goes
|
|
> in a single request body. There is no `/api/v1/orders` POST —
|
|
> `/orders` is GET-only.
|
|
|
|
---
|
|
|
|
## Endpoint
|
|
|
|
```
|
|
POST /api/v1/orders/create
|
|
```
|
|
|
|
Bearer-token auth via `AGENT_API_KEY` header. **Hermes drops
|
|
`AGENT_API_KEY` on direct REST calls** — use the MCP wrapper
|
|
`mcp__wfdash-mcp__orders__create` with the same body shape.
|
|
|
|
`runtime = "nodejs"` (PocketBase admin auth requires Node).
|
|
|
|
---
|
|
|
|
## Request body
|
|
|
|
```json
|
|
{
|
|
"customer_id": "<15-char PB id>", // required
|
|
"company_id": "<15-char PB id>", // required
|
|
"notes": "string?", // order-level notes
|
|
"contact_name":"string?", // operator-readable contact
|
|
"description": "string?", // short description of the order
|
|
"email_message_id": "string?", // dedup key
|
|
"rep": "string?", // sales rep free-text
|
|
"items": [
|
|
{
|
|
"description": "string", // required, trimmed
|
|
"quantity": 1, // default 1; must be > 0 when supplied
|
|
"cost_price": 123.45,
|
|
"markup": 25,
|
|
"selling_price": 154.31, // optional
|
|
"sku": "string?",
|
|
"unit": "EA",
|
|
"supplier_id": "<15-char PB id?>",
|
|
"notes": "string?"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
### Field rules
|
|
|
|
- **`customer_id`, `company_id`** — must resolve in PB. Free-text
|
|
names are **not** accepted on this endpoint (use the MCP `orders.create`
|
|
wrapper if you need name-resolution; the REST handler requires ids).
|
|
- **`items[]`** — non-empty, every item needs `description`.
|
|
- **`selling_price` auto-derive** — when `cost_price` and `markup` are
|
|
both supplied and `selling_price` is omitted, it auto-derives as
|
|
`cost_price * (1 + markup / 100)` rounded to 2 dp. Pass
|
|
`selling_price` explicitly to override.
|
|
- **`email_message_id`** — optional idempotency key. If an order with
|
|
the same `email_message_id` already exists, the response returns the
|
|
existing record with `duplicate: true` and HTTP **200** instead of
|
|
201. Safe to retry.
|
|
- **Per-row independence** — each item creates one `order_item` row
|
|
with `item_status = "pending"` and `drv_status = "unassigned"`. One
|
|
item's secondary-field patch failure does **not** abort the rest of
|
|
the call; failures land in `items_failed[]` with the partial-success
|
|
shape.
|
|
|
|
---
|
|
|
|
## Response
|
|
|
|
### 201 — created
|
|
|
|
```json
|
|
{
|
|
"order": { /* full PB order record */ },
|
|
"order_items_collection": "<relation-id>",
|
|
"items_created": [ { "id": "...", "description": "..." } ],
|
|
"items_failed": []
|
|
}
|
|
```
|
|
|
|
### 200 — duplicate (`email_message_id` matched)
|
|
|
|
Same shape as 201, plus `"duplicate": true`. Nothing new was written.
|
|
|
|
### 400 — validation failure
|
|
|
|
```json
|
|
{ "error": "customer_id is required" }
|
|
{ "error": "items must be a non-empty array" }
|
|
```
|
|
|
|
### 404 — not found
|
|
|
|
```json
|
|
{ "error": "Requested resource not found..." }
|
|
```
|
|
|
|
`customer_id` or `company_id` doesn't resolve in PB.
|
|
|
|
### 500 — server / PB write failure
|
|
|
|
```json
|
|
{ "error": "<PocketBase error message>" }
|
|
```
|
|
|
|
---
|
|
|
|
## Common workflows
|
|
|
|
### Walk-in / counter sale
|
|
|
|
No upstream quote. Customer walks up, picks items, you write the
|
|
order straight from the till:
|
|
|
|
```bash
|
|
curl -X POST https://wfdash.example/api/v1/orders/create \
|
|
-H "Authorization: Bearer $AGENT_API_KEY" \
|
|
-H "Content-Type: application/json" \
|
|
-d '{
|
|
"customer_id": "<customer-id>",
|
|
"company_id": "<company-id>",
|
|
"notes": "Counter sale — 2026-08-27",
|
|
"items": [
|
|
{ "description": "SKF 6205 bearing", "quantity": 4,
|
|
"cost_price": 85, "markup": 30 }
|
|
]
|
|
}'
|
|
```
|
|
|
|
### Re-create from a quote
|
|
|
|
**Don't** use this endpoint — use
|
|
`POST /api/v1/orders/from-quote` (MCP: `orders.create_from_quote`).
|
|
It clones the matched quote into a fresh `ORD-YYYY-MM-NNN` order,
|
|
inheriting customer / company / rep / letterhead / line items.
|
|
|
|
### Append to an existing order
|
|
|
|
There **is** a separate endpoint:
|
|
`POST /api/v1/orders/{id}/items` (MCP: `order_items.create`). But it
|
|
**refuses with 409 `order_locked`** the moment any line on the order
|
|
is past `pending`. Use it only on fresh orders — for any order that
|
|
has a PO in flight, you must `orders.split` first.
|
|
|
|
---
|
|
|
|
## Pitfalls
|
|
|
|
- **Passing the order header without `items[]`** → 400. The handler
|
|
does not create empty orders.
|
|
- **Passing `quantity: 0` or negative** → rejected per item by the
|
|
service layer; the row lands in `items_failed[]` with
|
|
`validation_error`.
|
|
- **Forgetting to send `AGENT_API_KEY`** → 401 from
|
|
`assertAgentAuth`. From Hermes, use `mcp__wfdash-mcp__orders__create`
|
|
instead — the MCP layer injects the key.
|
|
- **Reusing `email_message_id` for genuinely different orders** →
|
|
the second call returns the first order with `duplicate: true`.
|
|
Don't reuse the key; generate a new one per logical order.
|