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