WFDASH-WEBAPP/AGENT_BRIEF_AUTOFILL_QUOTES.v1.long.md

290 lines
10 KiB
Markdown
Raw Permalink Normal View History

# WFDASH Agent Brief — Auto-Fill Missing Quote Prices
**Mission:** Find quotes that have line items without pricing, match each line
item against our product catalogue, and patch in the missing `cost_price`,
`selling_price`, `description`, and `supplier` where a confident match exists.
**Stop-and-ask rules** (stick to these or don't act):
1. **Never** write to `price_list` — it's read-only for you. If a SKU is missing,
stop and tell the human.
2. **Never** patch a `quote_item` whose `cost_price` is already > 0 unless the
human says "force update" — operators often hand-edit prices, respect that.
3. **Never** delete anything. Patch-only.
4. **Tier 3 fuzzy matches (`search_text`)** are flagged "PICKER" — write the
`pricing_notes` field with `"needs human review: <explanation>"` and DO NOT
auto-fill. Only Tier 1 + Tier 2 may auto-fill.
5. If the supplier name from a catalogue row can't be resolved to a `suppliers`
id, set `pricing_notes = "supplier '<name>' not in PB — add it first"` and
stop on that item.
---
## 1. Environment (everything is local on the same PC)
| Service | URL | Auth |
|---|---|---|
| **WFDash web app** | `http://localhost:4000` | browser session (PB token in localStorage) |
| **PocketBase API** | `https://pocketbase-gnqc88suy8fqk6wyryjjdpvl.169.239.180.130.sslip.io` | admin (see below) |
| **WFDash MCP (JSON-RPC)** | `http://localhost:4000/api/mcp` | `Authorization: Bearer 123456` |
**PocketBase admin** (has full DB access — only share with yourself / on this PC):
```
email: david.els1@outlook.com
password: 1234567890
```
You'll use this to mint an auth token, then use the token on every subsequent
request. Sample login:
```bash
PB="https://pocketbase-gnqc88suy8fqk6wyryjjdpvl.169.239.180.130.sslip.io"
TOKEN=$(curl -s -X POST "$PB/api/collections/users/auth-with-password" \
-H "Content-Type: application/json" \
-d '{"identity":"david.els1@outlook.com","password":"1234567890"}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
echo "$TOKEN" > /tmp/pb_token
```
Then everything is `curl -H "Authorization: $TOKEN" $PB/api/...`.
**WFDash agent key** (for `/api/mcp` JSON-RPC tooling):
```
Authorization: Bearer 123456
```
The MCP exposes tools for `orders`, `quotes` (create/append/find from email),
`attachments.upload_for_quote`, `companies.*`, `customers.*`, `suppliers.*`. It
does NOT expose price_list reads or quote_item writes — those go via PocketBase
directly.
---
## 2. Health check (run first)
```bash
curl -s http://localhost:4000/api/v1/health
# {"ok":true,"service":"wfdash-api","timestamp":"..."}
```
If the WFDash app isn't running, the human will need to start it. The PocketBase
URL above is the production-style external URL — works from anywhere with
network access.
---
## 3. The collections you need
### `price_list` (catalogue — READ ONLY)
| Field | Type | Notes |
|---|---|---|
| `id` | text (15-char PB id) | primary key |
| `sku` | text | `"MAK-AG-125-9558"` |
| `sku_normalised` | text | canonical form: uppercase, non-alnum stripped |
| `description` | text | the operator-facing description |
| `cost_price` | number | what we pay |
| `selling_price` | number | what we charge (0 means "use cost × 1.25") |
| `unit` | text | `"EA"`, `"BOX"`, etc. |
| `supplier` | text | display name string, e.g. `"JEISA - WESTSCOTT GROUP"`. NOT a relation id. |
| `brand` | text | e.g. `"MAKITA"` |
| `category` | text | e.g. `"POWER TOOLS"` |
| `subcategory` | text | e.g. `"GRINDERS"` |
| `search_text` | text | pre-flattened: SKU + description + brand + category + supplier + every spec key/val/synonym. Use this for Tier 3 fuzzy match. |
| `specifications` | json | `{ "voltage": "230V", "synonyms": ["125mm grinder", ...] }` |
| `updated` | autodate | for "newest match wins" tie-breaking |
### `quote_items` (PATCH THIS)
| Field | Type | Notes |
|---|---|---|
| `id` | text (15-char) | |
| `quote` | relation → `quotes` | required |
| `sku` | text | may be empty — see §4 for matching logic |
| `description` | text | operator's raw line description |
| `quantity` | number | don't touch |
| `supplier` | relation → `suppliers` | the relation id, NOT a name |
| `cost_price` | number | |
| `selling_price` | number | |
| `markup` | number | recompute every time (see below) |
| `pricing_source` | select | set to `"catalogue"` if you filled prices |
| `pricing_notes` | text | explain what you did |
| `matched_price_list_id` | text (relation-ish) | the price_list id you matched against |
| `user_edited` | bool | set `true` |
| `unit` | text | copy from catalogue |
### `suppliers`
| Field | Type | Notes |
|---|---|---|
| `id` | text | |
| `suppliername` | text | the display name that matches `price_list.supplier` |
Lookup pattern (case-insensitive):
```bash
curl -s -H "Authorization: $TOKEN" \
"$PB/api/collections/suppliers/records?filter=suppliername~%22JEISA%22&fields=id,suppliername&perPage=5"
```
If no hits → follow stop-and-ask rule #5.
### `quotes`
| Field | Notes |
|---|---|
| `id` | |
| `quote_number` | `Q1782862717620`-style display id |
| `customer` | relation |
| `status` | `draft`, `sent`, `accepted`, etc. |
You don't need to write to `quotes`. You only need to find ones where their
`quote_items` are unpriced (§4).
---
## 4. The matcher (use exactly this tiered logic)
For a given `quote_item`:
1. **Skip if** `cost_price > 0` AND `selling_price > 0` (already priced —
respect operator hand-edits).
2. **Tier 1 — exact SKU match.**
```bash
curl -s -H "Authorization: $TOKEN" \
"$PB/api/collections/price_list/records?filter=sku%3D%22${SKU}%22%20%7C%7C%20sku_normalised%3D%22${NORM}%22&sort=-updated&perPage=25&fields=id,sku,description,cost_price,selling_price,unit,supplier,brand,category,subcategory,sku_normalised,updated"
```
Where `NORM` is the SKU uppercased with all non-alphanumeric stripped
(`"MAK-AG-125"` → `"MAKAG125"`). If 1+ hits → pick the newest, auto-fill.
3. **Tier 2 — substring on SKU.** Same call, filter is:
```
(sku_normalised~"${NORM}" || sku~"${SKU}")
```
Pick newest, auto-fill.
4. **Tier 3 — fuzzy on `search_text`.** Same call, filter is:
```
search_text~"${SKU_OR_DESCRIPTION_RAW}"
```
With cap of 10 results, sorted newest first. **Do NOT auto-fill.** Set:
```json
{
"pricing_notes": "needs human review — Tier 3 fuzzy match: <brand/category hits>"
}
```
and stop on this item.
### Markup formula
After patch, `markup = round(((selling / cost) - 1) * 100)`.
If catalogue `selling_price == 0`, fall back to `selling = round(cost * 1.25 * 100) / 100`
then compute markup.
### The full patch payload (Tier 1 or Tier 2 success)
```json
{
"cost_price": <catalogue cost>,
"selling_price": <effective selling — see formula above>,
"description": "<catalogue description, falling back to the existing one if empty>",
"supplier": "<resolved suppliers.id>",
"unit": "<catalogue unit>",
"pricing_source": "catalogue",
"matched_price_list_id": "<catalogue id>",
"user_edited": true,
"markup": <computed>,
"pricing_notes": "Auto-filled by agent on <YYYY-MM-DD> from <supplier display name> (Tier <N>)"
}
```
PATCH example:
```bash
curl -s -X PATCH -H "Authorization: $TOKEN" -H "Content-Type: application/json" \
-d '{"cost_price":1450,"selling_price":1812.5,"description":"MAKITA 9558NB ANGLE GRINDER 125MM 840W","supplier":"<id>","unit":"EA","pricing_source":"catalogue","matched_price_list_id":"<id>","user_edited":true,"markup":25,"pricing_notes":"Auto-filled by agent 2026-07-01 from JEISA (Tier 1)"}' \
"$PB/api/collections/quote_items/records/<quote_item_id>"
```
---
## 5. End-to-end flow
```bash
# (a) Get token (see §1)
# (b) Find unpriced quote_items
# A quote_item is "unpriced" if cost_price == 0 OR selling_price == 0
# PB filter: (cost_price = 0 || selling_price = 0)
curl -s -H "Authorization: $TOKEN" \
"$PB/api/collections/quote_items/records?filter=(cost_price%3D0%20%7C%7C%20selling_price%3D0)&perPage=50&fields=id,quote,sku,description,cost_price,selling_price,quantity,supplier"
# Returns a page; page through with ?page=2 etc. until empty.
# (c) For each unpriced quote_item — run the tiered matcher from §4
# (d) PATCH the item with the payload from §4 (Tier 1+2 auto-fill,
# or Tier 3 → pricing_notes-only)
# (e) Report a summary back to the human:
# - how many quote_items you touched
# - how many you filled at Tier 1 / Tier 2 / flagged at Tier 3
# - any suppliers you couldn't resolve
```
---
## 6. Test data (already in PB, you can use it)
- 5 dummy `TEST_GRINDER` catalogue rows (brand = `TEST_GRINDER`)
- 1 test quote id `7lvyrbdmftr3arx` with 3 items (`Q1782862717620`)
- 3 items: one exact-match, one fuzzy-match, one no-match
You can run your full flow against these without touching real data. When
you're done, ask the human whether to clean them up.
---
## 7. Useful one-liners
```bash
# Read a full quote with items in one go
curl -s -H "Authorization: $TOKEN" \
"$PB/api/collections/quotes/records/<quote_id>?expand=customer,company,quote_items_via_quote"
# Read a price_list row by id
curl -s -H "Authorization: $TOKEN" \
"$PB/api/collections/price_list/records/<id>"
# Paginate unpriced items
curl -s -H "Authorization: $TOKEN" \
"$PB/api/collections/quote_items/records?filter=cost_price%3D0&page=2&perPage=50"
# Check that a price_list supplier name resolves to a supplier
curl -s -H "Authorization: $TOKEN" \
"$PB/api/collections/suppliers/records?filter=suppliername%3D%22${NAME}%22&fields=id,suppliername&perPage=1"
```
---
## 8. Things you cannot do via this API
- The WFDash `/api/v1/quotes/{id}/re-price` route requires a **browser
session** cookie, not the agent key. Skip it — you're doing the same job
directly against PocketBase here.
- You have no file-system or shell access. PB admin doesn't grant that — it
only gives PB CRUD.
- Production-style constraints carry over: don't touch `price_list`, don't
delete anything, leave a `pricing_notes` audit trail on every row you write.
That's everything. Confirm you can hit `/api/v1/health` and authenticate to
PocketBase before you start listing unpriced rows.