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

10 KiB
Raw Permalink Blame 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:

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:

  1. Skip if cost_price > 0 AND selling_price > 0 (already priced — respect operator hand-edits).

  2. 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 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:

    {
      "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_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

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