WFDASH-WEBAPP/AGENT_BRIEF_AUTOFILL_QUOTES.v1.long.md
SJCoetz 05abe499b0 feat(sprint): add new sprint pricing workflow and supporting code
Add full sprint pricing workflow and supporting infrastructure:
- New sidebar navigation entry for the sprint dashboard
- PocketBase category collection schema and migration script
- Server API routes for sprint item claims, attachments, counts, and file proxying
- Dev identity utilities and UI switcher for multi-user testing
- Reusable FilterTabs component replacing ItemStatusTabs
- Updated TypeScript types for sprint-related entities

Also include:
- Fix PocketBase file URL method casing (getUrl → getURL) across codebase
- Add development utility, smoke test, and helper scripts
- Update package.json with new sprint migration command
2026-07-03 07:29:43 +02:00

289 lines
10 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.

# 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.