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
10 KiB
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):
- Never write to
price_list— it's read-only for you. If a SKU is missing, stop and tell the human. - Never patch a
quote_itemwhosecost_priceis already > 0 unless the human says "force update" — operators often hand-edit prices, respect that. - Never delete anything. Patch-only.
- Tier 3 fuzzy matches (
search_text) are flagged "PICKER" — write thepricing_notesfield with"needs human review: <explanation>"and DO NOT auto-fill. Only Tier 1 + Tier 2 may auto-fill. - If the supplier name from a catalogue row can't be resolved to a
suppliersid, setpricing_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:
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)
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):
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:
-
Skip if
cost_price > 0ANDselling_price > 0(already priced — respect operator hand-edits). -
Tier 1 — exact SKU match.
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
NORMis the SKU uppercased with all non-alphanumeric stripped ("MAK-AG-125"→"MAKAG125"). If 1+ hits → pick the newest, auto-fill. -
Tier 2 — substring on SKU. Same call, filter is:
(sku_normalised~"${NORM}" || sku~"${SKU}")Pick newest, auto-fill.
-
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:
{ "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)
{
"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:
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
# (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_GRINDERcatalogue rows (brand =TEST_GRINDER) - 1 test quote id
7lvyrbdmftr3arxwith 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
# 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-priceroute 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 apricing_notesaudit 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.