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
250 lines
15 KiB
Markdown
250 lines
15 KiB
Markdown
# Agent Brief — Fill Prices on New Quotes (v3, 2026-07-02)
|
||
|
||
**Your job:** Find quotes with `status="new"`, look at every line item that has no pricing yet, match it against the price list, fill it if you can, leave a note with the 3 closest catalogue candidates if you can't. Move to the next one.
|
||
|
||
**This is v3.** v1 had the security/auth smell of using a raw admin JWT. v2 inherited that — and the agent ended up running 26 separate PATCH round-trips per batch. v3 switches the writes to the new `bulk_update_quote_items` MCP tool — one JSON-RPC call covers the whole batch. v2's matcher logic, schema corrections and traps are unchanged and carried forward below as §3, §7, §8. **Read §7 and §8 BEFORE writing anything.**
|
||
|
||
---
|
||
|
||
## 1. Access
|
||
|
||
**Use the MCP, not PB REST.** The MCP server is on the same Next.js app — hit it with the agent bearer token (the one from your environment as `WFDASH_AGENT_API_KEY` or equivalent). Never mint a `users/auth-with-password` token; you don't need it and you should never see the admin JWT.
|
||
|
||
```bash
|
||
MCP="https://wfdash.example.com/api/mcp" # adjust to your env
|
||
TOKEN="${WFDASH_AGENT_API_KEY}" # bearer token in env
|
||
AUTH="Authorization: Bearer $TOKEN"
|
||
```
|
||
|
||
> **Token gotcha (unchanged from v2):** if you assign the token in Python (`t = json.loads(out)['token']`), Hermes's secret-redaction truncates it to ~13 chars and every subsequent request returns 0 results. Read it from an env var, not from a curl/Python pipe.
|
||
|
||
To verify auth + discovery, do a `tools/list` first:
|
||
|
||
```bash
|
||
curl -s -H "$AUTH" -H "Content-Type: application/json" \
|
||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' \
|
||
"$MCP" | python3 -m json.tool
|
||
```
|
||
|
||
You should see `bulk_update_quote_items` in the list. If you don't, the operator hasn't deployed the latest MCP yet — stop and ask, don't fall back to PB REST.
|
||
|
||
---
|
||
|
||
## 2. The matcher (unchanged from v2)
|
||
|
||
### Tier 1 — exact SKU match
|
||
Filter: `sku="<sku>" || sku_normalised="<NORM>"` where NORM = sku uppercased with non-alnum stripped. Sort `-updated`, perPage 5. If ≥ 1 hit → auto-fill.
|
||
|
||
### Tier 2 — substring on SKU
|
||
Filter: `sku_normalised~"<norm>" || sku~"<sku>"`. Sort `-updated`, perPage 5. If ≥ 1 hit → auto-fill.
|
||
|
||
> **Skip Tier 1 + 2 when SKU is empty** (`sku==""`). With `~""` the filter matches every catalogue row. Implementing requires an explicit `if sku: ... else Tier 3` branch in code, not just relying on the filter to be useless.
|
||
|
||
### Tier 3 — fuzzy on description
|
||
Query = cleaned description (noise chars stripped: `" ( ) * ~ \``). Use this 4-step cascade:
|
||
|
||
**A. Full cleaned query.** Filter: `search_text~"<cleaned>"`. perPage 10. From the candidates, pick the row whose `description` normalises equal to the quote line's description (modulo noise chars + whitespace + case). If exact match → auto-fill.
|
||
|
||
**B. Dimension-AND filter.** Extract every `Nmm` value AND every fractional inch (`1/2`, `2-1/4`, `3/8`) from the ORIGINAL (uncleaned) description. AND them together. perPage 30. Pick the row whose `description` normalises equal to the quote line's. If exact match → auto-fill.
|
||
|
||
**C. Dimension-pair fallback.** If B returns no exact match, try each pair of extracted dimensions AND-ed together. Pick exact match if found → auto-fill.
|
||
|
||
**D. If no exact match anywhere → flag.** Don't auto-fill a partial match. Set `pricing_notes = "needs human review — closest catalogue candidates: …"` with the top 3 by description similarity.
|
||
|
||
### Tier 3 with 0 catalogue hits at all → flag with "no match"
|
||
`pricing_notes = "needs human review — no catalogue match for '<query>'"`. Set `user_edited = true`. Do NOT leave the row silently unpriced.
|
||
|
||
### Selling-price fallback
|
||
```
|
||
selling = catalogue.selling_price
|
||
|| round(catalogue.cost_price * 1.25 * 100) / 100
|
||
markup = round(((selling / cost) - 1) * 100) # %
|
||
```
|
||
|
||
---
|
||
|
||
## 3. The writes — one MCP call, no PB REST
|
||
|
||
Cataloguing the per-row decision in §2 produces a list of patches. Don't issue 26 PATCHes. Batch them into a single `tools/call` for `bulk_update_quote_items`.
|
||
|
||
### The call
|
||
|
||
```bash
|
||
curl -s -H "$AUTH" -H "Content-Type: application/json" \
|
||
-d '{
|
||
"jsonrpc":"2.0",
|
||
"id":1,
|
||
"method":"tools/call",
|
||
"params":{
|
||
"name":"bulk_update_quote_items",
|
||
"arguments":{
|
||
"dry_run": false,
|
||
"items": [
|
||
{ "id": "m9z3ubxog1jz7ye", "patch": {
|
||
"cost_price": 249.51, "selling_price": 311.89, "markup": 25,
|
||
"description": "HSS Annular Cutter 1/2\" (12.7mm) 25mm DOC",
|
||
"supplier": "q6hpwbxsicyoiuu", "unit": "each",
|
||
"pricing_source": "catalogue",
|
||
"matched_price_list_id": "wklkwmbkmstf53m",
|
||
"user_edited": true,
|
||
"pricing_notes": "Auto-filled by <your-agent-name> on <YYYY-MM-DD> from JEISA - WESTSCOTT GROUP (Tier 3-exact)"
|
||
}},
|
||
{ "id": "2zcarxk7p6a8lvg", "patch": {
|
||
"user_edited": true,
|
||
"pricing_notes": "needs human review — no catalogue match for \"circular saw blade 184mm\"\nClosest candidates:\n - <sku1> R<cost1> <desc1>\n - <sku2> R<cost2> <desc2>\n - <sku3> R<cost3> <desc3>"
|
||
}}
|
||
]
|
||
}
|
||
}
|
||
}' \
|
||
"$MCP"
|
||
```
|
||
|
||
For a probe-then-confirm workflow (recommended before a 20+ batch — see §6), pass `"dry_run": true`. The response shape is identical except `results[].status` is `"dry_run"` and `update` was not called. Zero side effects, real per-row feedback.
|
||
|
||
### What the tool does for you
|
||
|
||
- **Field whitelist** — only `cost_price, selling_price, description, sku, unit, supplier, markup, pricing_source, pricing_notes, matched_price_list_id, user_edited` are patchable. Any other key (`id`, `quote`, `created`, `updated`, `quantity`, …) is rejected with `reason: forbidden_field: <list>`.
|
||
- **Hand-edit guard** — if a row already has `cost_price > 0 && selling_price > 0`, the tool skips it with `reason: already_priced`. Pass `force_overwrite: true` on that specific row if you really mean to overwrite the operator's number.
|
||
- **Per-row independence** — one row's failure does not abort the batch. Returned per-row reasons: `forbidden_field`, `validation_error`, `not_found`, `already_priced`, `pb_error`. For non-array `items` or batches > 100 the whole call fast-fails with a JSON-RPC error.
|
||
- **Caller stamps `pricing_notes`** — the agent knows who it is and the date. Include it in the patch when you change pricing fields (the existing `/match` style assumes you will). The tool does NOT auto-inject an audit string.
|
||
|
||
### Response shape (one row)
|
||
|
||
```jsonc
|
||
{
|
||
"ok": true,
|
||
"updated": 14,
|
||
"skipped": 0,
|
||
"errors": [],
|
||
"results": [
|
||
{
|
||
"id": "m9z3ubxog1jz7ye",
|
||
"status": "ok",
|
||
"previous_cost_price": 0,
|
||
"previous_selling_price": 0,
|
||
"new_cost_price": 249.51,
|
||
"new_selling_price": 311.89,
|
||
"updated_fields": ["cost_price","selling_price","markup", ...]
|
||
},
|
||
{ "id": "2zcarxk7p6a8lvg", "status": "ok", "...": "..." },
|
||
{ "id": "abc...", "status": "skipped", "reason": "already_priced",
|
||
"previous_cost_price": 100, "previous_selling_price": 125 }
|
||
],
|
||
"dry_run": false
|
||
}
|
||
```
|
||
|
||
`ok: false` means errors.length > 0 (some rows failed but the rest were applied). `ok: true, updated: 0` is a no-op batch (everything skipped). Inspect `errors[].reason` for per-row diagnostics.
|
||
|
||
---
|
||
|
||
## 4. Decisions (unchanged from v2)
|
||
|
||
- **"Confident match"** = the candidate row's `description` normalises equal to the quote line's `description` (modulo noise chars, whitespace, case). Partial matches don't count — leave candidates.
|
||
- **`pricing_source`** = `"catalogue"` only on a successful fill. Leave unset (or set to `"manual"`) when you only left candidates.
|
||
- **Supplier resolution**: just copy `price_list.supplier` (the relation id) straight into `quote_items.supplier`. If you need the human-readable name for the audit line, look it up via `GET /api/collections/suppliers/records/<id>` and cache it. Don't try to resolve name → id — the catalogue already stores the id.
|
||
- **Quantity** stays as-is — never modify it. (The MCP tool rejects `quantity` in the whitelist anyway, so even if you pass it by accident it won't write.)
|
||
|
||
---
|
||
|
||
## 5. Don'ts
|
||
|
||
- **Read from `price_list` freely — that's the whole point.** Just don't write to it or delete from it. It's the source of truth for pricing.
|
||
- **Don't issue raw PB REST writes.** All quote_item patches go through the MCP. Don't fall back to a `curl -X PATCH` loop because the auth header looks shorter than you expected.
|
||
- Don't delete anything from any collection.
|
||
- Don't overwrite a quote_item row that already has both `cost_price > 0` AND `selling_price > 0` (the operator may have hand-set it). Pass `force_overwrite: true` if you really mean it.
|
||
- Don't auto-write across > 20 rows without surfacing the histogram first and getting explicit confirmation (see §6).
|
||
- Don't trust `sort=-updated` as a tiebreaker — see §7.2.
|
||
- Don't use bare `search_text~"<full description>"` if the description contains `"`, `(`, `)`, or `*` — see §7.1.
|
||
- Don't leave Tier 3 rows silently unpriced — always write a `pricing_notes` flag.
|
||
- Don't pass `id`, `quote`, `created`, `updated`, `quantity`, or any other unwhitelisted field in the patch — the MCP will reject the row but you'll spend a round-trip learning it.
|
||
|
||
---
|
||
|
||
## 6. Probe-then-confirm workflow
|
||
|
||
1. Walk all new quotes, paginate fully. Build the full list of unpriced items.
|
||
2. Run the matcher on each. Build the patch list.
|
||
3. Send the patches as `bulk_update_quote_items` with `dry_run: true`. Get back per-row diagnostics.
|
||
4. Print histogram: `Tier 1 / Tier 2 / Tier 3-exact-fill / Tier 3-flag-with-candidates / Tier 3-flag-no-match / skipped (already priced)`.
|
||
5. Show sample fills and the top 3 fuzzy-match outliers.
|
||
6. **Stop and ask the operator** which mode to run:
|
||
- **Full auto-fill + flag** — Tier 1/2/3-exact fill, Tier 3-partial/empty flag with `pricing_notes`. **Default mode.**
|
||
- **Probe-only** — keep `dry_run: true`, write nothing. (Useful for a first look on a brand-new supplier.)
|
||
- **Report only** — don't even call the MCP. Send the histogram and candidate list as a message.
|
||
7. After confirmation, send the same batch with `dry_run: false`. The MCP reports per-row outcomes — surface `errors[]` in your final report so the operator sees what didn't land.
|
||
|
||
**Exception:** if the operator explicitly says "go" or "fill and flag them" in the prompt, skip step 5 and run the full MCP call immediately. The `dry_run` probe is operator-time, not a safety guarantee — the tool still rejects forbidden fields and already-priced rows for you.
|
||
|
||
---
|
||
|
||
## 7. Schema corrections vs the v1 brief (carried from v2)
|
||
|
||
| v1 said | v3 truth |
|
||
|---|---|
|
||
| `price_list.supplier` is a display name string | It's a relation id (15 chars). Copy verbatim. |
|
||
| Tier 3: `search_text~"<full desc>"` works for any description | Breaks on `"`, `(`, `)`, `*` — always strip noise chars or use dimension-AND |
|
||
| Trust first `-updated` sort hit | Bulk imports share `updated` dates; sort is effectively random. Always verify exact description match. |
|
||
| Flag candidates only when Tier 3 returns ≥ 1 hit | Also flag when Tier 3 returns 0 hits — otherwise rows disappear into the noise |
|
||
| (new in v3) Use `curl -X PATCH` with an admin JWT for each row | Use `bulk_update_quote_items` MCP tool — one call covers the whole batch, you never see the admin token |
|
||
|
||
---
|
||
|
||
## 8. Known traps
|
||
|
||
### 8.1 `search_text~` chokes on `"`, `(`, `)`, `*`
|
||
|
||
PocketBase's substring operator parses the first `"` in your query as the end of the string literal and bails. Affected: every description with fractional-inch dimensions like `HSS Annular Cutter 1/2" (12.7mm) 25mm DOC`. The literal substring **is** in `search_text` — you just can't reach it through `~` with an unescaped quote.
|
||
|
||
**Symptoms:** Tier 3 returns 0 hits for items that visibly match the catalogue.
|
||
|
||
**Fix:** strip noise chars (`" ( ) * ~ \``) from the query, OR skip to step B of the Tier 3 cascade and use dimension-AND instead.
|
||
|
||
### 8.2 `sort=-updated` is unreliable after bulk imports
|
||
|
||
When a batch import lands, every imported row shares the same `updated` timestamp. `sort=-updated` then returns an effectively arbitrary order. The 18 carbide burrs in this catalogue all updated 2026-06-30; trusting the first hit gave us `A0616` (head 6×16mm) when we needed `D0605` (head 6×5mm).
|
||
|
||
**Fix:** from the candidate set, find the row whose `description` normalises equal to the quote line's description. Only fall back to "first by `-updated`" if no exact-normalised match exists — and even then, return top 3 candidates rather than auto-fill.
|
||
|
||
### 8.3 Empty-SKU guard
|
||
|
||
`sku=""` makes Tier 2's `~""` substring match every catalogue row (872 in our case). The script must short-circuit:
|
||
```python
|
||
if not sku: # skip Tier 1 + 2
|
||
# go straight to Tier 3
|
||
```
|
||
|
||
### 8.4 Outrider — `"test"` description
|
||
|
||
Quote `7h1w44755zkvknw` has a row with `description = "test"`. It will match every `TEST_GRINDER` row in Tier 3. Do NOT auto-fill these — leave flagged.
|
||
|
||
### 8.5 Hermes redaction mangles the token
|
||
|
||
When a JWT is echoed back into a Python variable (e.g. from a Python-issued bearer), Hermes replaces the middle with asterisks. The variable ends up as `eyJhbG...XXXXX` (~13 chars). Subsequent requests succeed but return empty results because the auth header has garbage.
|
||
|
||
**Fix:** always read the MCP bearer from `$WFDASH_AGENT_API_KEY` (or whatever env var the operator set). Never assign the token in Python directly.
|
||
|
||
---
|
||
|
||
## 9. Final report
|
||
|
||
After running, report:
|
||
- How many quotes walked
|
||
- How many items filled (with totals: cost R…, sell R…)
|
||
- How many items flagged (and whether flag has candidates or "no match")
|
||
- How many items skipped as `already_priced` (operator had hand-set them)
|
||
- Any `errors[]` from the MCP — usually empty, but if `pb_error: ...` shows up the row landed a 4xx and you should retry that specific id
|
||
- Any anomalies (sort weirdness, empty hits that should have matched, etc.)
|
||
|
||
---
|
||
|
||
## 10. Test fixtures (use to validate before touching real data)
|
||
|
||
Test catalogue rows: `brand="TEST_GRINDER"` — 5 rows (`MAK-AG-125-9558`, `BOS-AG-125-GWS850`, `DEW-AG-125-DWE4257`, `MAK-AG-125-9558HD`, `GEN-DISC-125-CUT`). All supplier `q6hpwbxsicyoiuu`.
|
||
|
||
Test quote `7lvyrbdmftr3arx` (Q1782862717620): 3 items, mix of Tier 1 / Tier 2-3 / no-match. The `circular saw blade 184mm` row should produce Tier 3 with 0 hits and a "no catalogue match" flag.
|
||
|
||
Test quote `7h1w44755zkvknw`: has a junk row with `description="test"` that produces 5 false-positive Tier 3 hits — must NOT auto-fill.
|
||
|
||
Run the matcher against the test fixtures first. Confirm the cascade produces the expected outcomes before running on real data. Use `dry_run: true` for the validation pass — zero writes, real diagnostics.
|