diff --git a/docs/superpowers/specs/2026-11-05-giellie-stock-quantity-update-design.md b/docs/superpowers/specs/2026-11-05-giellie-stock-quantity-update-design.md new file mode 100644 index 0000000..085c17c --- /dev/null +++ b/docs/superpowers/specs/2026-11-05-giellie-stock-quantity-update-design.md @@ -0,0 +1,259 @@ +# Giellie stock quantity update + +Status: proposed by the operator on 2026-11-05. Awaiting operator review before the implementation plan is written. + +## Intent + +Let the operator paste a list of SKUs, descriptions and quantities into the Giellie chat and have the tracked stock quantities updated — without opening thousands of rows by hand. + +The operator's framing: *"if we give him descriptions and skus he need to be able to update the stock qtys for us"*. The data is pasted into the chat directly; there is no document-parsing step in this scope. + +The live state of the data makes this a real problem rather than a convenience. Of the 4,083 items the till can currently sell, **522 carry a negative tracked quantity** and **2,901 sit at exactly zero** — 84% of the catalogue is at or below zero. The worst are `M16-FLAT-WASHER` at **−2,581**, `M12-FLAT-WASHER` at −1,518 and `M20-FLAT-WASHER` at −1,385. + +This drift is by design. `src/lib/server/pos-service.ts:603-606`: + +> *"Overselling is allowed on purpose — the till gets used ahead of a physical stock take, so current_qty is allowed to go negative rather than blocking the sale; it gets corrected at the next stock take."* + +The till sells first, the stocktake corrects later. **This feature is the correction step.** Nothing in this spec may block a sale or a write on a low or negative quantity. + +## Where the data lives + +One collection, three consumers. This is the finding that shapes the whole design. + +| Collection | Qty field | Read by | Live? | +|---|---|---|---| +| `stock_items` | `current_qty` | the till (`/api/v1/pos/catalog`), the `/stock` page, POS checkout | ✅ **the source of truth** | +| `price_list` | `stock_qty` | the quote / re-price flow, the `/pricelist` page | ✅ live, but a denormalised snapshot | +| `pos` | `stock_qty` | two read-only fallbacks (`item-history-service.ts:236`, `quote-items-insights-service.ts:519`) | ❌ nothing writes to it | + +The PocketBase collection literally named `pos` is the legacy name for the price-list catalogue — it has the identical schema to `price_list`. Nothing writes to it; it is not touched by the POS. **It gets nothing from this feature.** + +### How the till gets its items + +`public/pos/pos.html` → `pos-app.js:119` `window.posFetch('/catalog')` → `GET /api/v1/pos/catalog` → `src/app/api/v1/pos/catalog/route.ts:19` → `listActiveStockCatalog()` → `pb.collection("stock_items").getFullList({ filter: "is_active = true && selling_price > 0" })`. + +The POS app never talks to PocketBase directly (`pos-app.js:1-3`, `pos-config.js:1-3`); `pos-search.js` makes no network calls at all. + +`price_list.stock_qty` is meant to mirror our quantity, not the supplier's — `src/lib/server/price-list-service.ts:80-82`: + +> *"Current on-hand qty for the catalogue row… The catalogue's stock_qty is a denormalised snapshot — the live `current_qty` lives on `stock_items`."* + +## Confirmed operator decisions + +1. **The intent is always asked.** Before writing anything, Giellie asks whether the pasted numbers are the new quantity on hand or an addition to the current stock. Every time — not inferred from the wording. +2. **Unknown SKUs are skipped, not created.** The rest of the batch applies; the unmatched lines are reported back. +3. **A description match is used, but flagged.** If the SKU matches nothing and the description clearly identifies one item, that item is used and marked as a description match so the operator can see it. +4. **No blocking, ever.** Low or negative stock never blocks a write or a sale. Quantities are allowed to go into minus. +5. **The bulk write is trusted like an ordinary write.** It auto-approves in a trusted chat and asks otherwise. +6. **The approval screen shows a readable table**, not raw JSON. +7. **The "Stock update" action button stays**, as a shortcut into the chat. +8. **The till tile shows the quantity as it is**, styled as out-of-stock when the quantity is not positive. +9. **The dashboard low-stock work is a separate, later spec.** + +## Design + +### 1. New MCP tool `stock_items.set_quantities` + +Named for symmetry with the existing `stock_items.set_pricing`. + +**Input** + +~~~jsonc +{ + "mode": "set" | "add", // set = absolute on-hand, add = delta + "lines": [ // 1–100 + { "sku": "ABC-123", "description": "Bolt M12 x 60", "qty": 50 } + ], + "dry_run": true | false // default false +} +~~~ + +**Matching, per line.** A new pure module `src/lib/server/stock-quantity-match.ts` holds the resolution logic and takes the candidate rows as an argument, so it is testable without PocketBase. It reuses the normalisation helpers that already exist rather than inventing a third canonical form: + +| Step | Rule | Reuses | +|---|---|---| +| 1 | Exact SKU, normalised (uppercase, strip `-_/()[]` and other noise) | `normaliseSku` — `src/lib/stock-match.ts:47` | +| 2 | Description fuzzy — Jaccard ≥ 0.6 over tokenised description vs name + description | `tokenize` + `jaccard` — `src/lib/server/order-quote-match.ts` | + +Resolution outcomes: + +- **1 candidate** → matched. `match_method` is `"sku_exact"` or `"description_fuzzy"`. +- **2+ candidates** → **ambiguous**. Nothing is written. The candidates come back with their ids, names and current quantities so the operator can pick. +- **0 candidates** → **unmatched**. Nothing is written. The line is reported. + +Matching runs against active items. An inactive item that matches is reported as unmatched with the reason stated, rather than silently written to. + +**Write semantics.** The tool never does arithmetic that the model could get wrong; it resolves, computes the delta and writes through the existing audited service. + +| mode | target | ledger delta | result may be negative | +|---|---|---|---| +| `set` | `qty` | `qty − current_qty` | yes | +| `add` | `current_qty + qty` | `qty` | yes | + +`qty` is a signed integer in both modes. In `add` it is a delta and may be negative — a correction downward, not a block. In `set` it is the absolute quantity on hand and may be negative when that is the truth of the books; nothing in the tool rejects it. + +### Input validation + +Malformed input is rejected up front, before anything is resolved or written: + +- **More than 100 lines** → the call is rejected with a clear message. The model splits the batch. +- **The same SKU twice in one batch** → the call is rejected, listing the duplicated SKUs. One SKU is one stock row, so two lines for it cannot both be true. This is a malformed paste, not a per-row outcome. +- **A line with neither `sku` nor `description`** → reported as unmatched with the reason stated. It never reaches the resolver. + +Per-row *matching* failures (unmatched, ambiguous) are not validation failures: they never stop the rest of the batch. + +The write goes through `updateStockItemsQuantities` (`src/lib/server/stock-quantities-service.ts`), which gives us: + +- the ledger row written **first**, with the signed delta, `reason: "adjustment"`, `reference_type: "manual"` +- `created_by` stamped with the signed-in user — the MCP bridge injects `actor` on every call, and this service uses it. (`stock_items.adjust` ignores the actor and writes `created_by: ""`; this path does not.) +- `current_qty` updated second +- a delta of zero → status `unchanged`, no ledger row, no write + +**The `price_list.stock_qty` mirror.** After a successful write, the matching `price_list` rows are updated to the same quantity, matched on the stored `sku_normalised`. The catalogue holds one row per SKU **per supplier**, so one stock item can map to several rows; all of them are updated and the count is reported. A SKU with no catalogue row is reported, not an error. + +**Result shape** + +~~~jsonc +{ + "ok": true, + "mode": "set", + "dry_run": false, + "summary": { "total": 12, "applied": 9, "unchanged": 1, "unmatched": 1, "ambiguous": 1 }, + "results": [ + { "sku": "ABC-123", "id": "abc…", "name": "Bolt M12 x 60", + "match_method": "sku_exact", + "previous_qty": 12, "new_qty": 50, "delta": 38, + "status": "applied", "price_list_rows_touched": 2 }, + { "sku": "ZZZ-9", "status": "unmatched", + "message": "No active stock item matches this SKU or description" }, + { "sku": "BOLT-6", "status": "ambiguous", + "candidates": [{ "id": "…", "name": "Bolt M6 40mm", "current_qty": 5 }, + { "id": "…", "name": "Bolt M6 60mm", "current_qty": 0 }] } + ] +} +~~~ + +Per-row independence: one bad line never stops the others. + +### 2. The one service change — negatives + +`updateStockItemsQuantities` currently rejects a negative `current_qty`: + +~~~ts +function coerceNonNegativeInt(key, value) { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + throw new Error(${key} must be a finite number >= 0); + } + return Math.trunc(value); +} +~~~ + +That floor is right for the `/stock` page — the operator types a physical count there. It is wrong for the agent, which must be able to write an absolute quantity that legitimately sits below zero. + +`UpdateStockItemsQuantitiesInput` gains an opt-in flag: + +~~~ts +export type UpdateStockItemsQuantitiesInput = { + pb: StockItemsPb; + updates: StockQuantityUpdate[]; + actor: { id: string; name: string }; + /** When true, currentQty may be negative. The /stock UI keeps its + * non-negative floor; the agent needs to write an absolute count that + * can legitimately sit below zero, because the till oversells on purpose. */ + allowNegativeQty?: boolean; // default false +}; +~~~ + +`min_qty` and `target_qty` stay floored at zero regardless. The `/stock` page keeps its current behaviour exactly; only the agent's call passes the flag. + +The ledger note also stops claiming the edit came from `/stock`. The service currently writes `"${actor.name} edited qty 12 → 50 from /stock"`; a context parameter makes the agent's writes read *"Dave set qty 12 → 50 via Giellie"*. + +### 3. Approval card renders a table + +`src/components/giellie/approval-card.tsx` currently renders `JSON.stringify(tu.input, null, 2)` for every tool. It gains a per-tool renderer: for `stock_items.set_quantities` it draws a table — Mode · SKU · Description · Qty — and every other tool keeps raw JSON exactly as today. + +The tool name arrives in the model-facing form (`stock_items__set_quantities`); the renderer matches on the unmangled name. + +The card renders the tool's **input**, because that is what the operator is approving — the mode, the SKUs and the quantities. The resolved before → after detail lives in the chat dry-run table shown a moment earlier, where unmatched and ambiguous lines are already visible. Between the two, the operator sees the plan and then the thing being committed. + +### 4. Tool policy + +`src/lib/server/giellie-tool-policy.ts`: + +- `stock_items.set_quantities` is a **write** — auto-approved in a trusted chat, asks otherwise. Not `confirm`. +- `dry_run: true` is treated as a **read**, so the preview never raises an approval card. `classifyToolCall` is already args-aware, so this is a small addition alongside the existing contact-key escalation. + +### 5. System prompt + +`src/lib/server/giellie-system-prompt.ts` gains a **Stock quantity updates** section: + +- always ask whether the numbers are the new quantity on hand or an addition, before doing anything +- resolve through `stock_items.set_quantities`; never loop `stock_items.list` + `stock_items.adjust` by hand +- dry-run first, show the plan as a table, then apply +- report unmatched and ambiguous rows, with candidates +- never write to the `price_list` catalogue via `price_list.add_items` — that is the supplier price list, and this feature updates quantities on `stock_items` + +### 6. The "Stock update" action button + +A **Stock update** button in the Giellie composer opens a small paste panel with a textarea showing the expected format and a **Send to Giellie** button. It seeds the chat with the pasted lines and a short instruction, then sends. + +It is a shortcut only. It does **not** carry the set/add intent — the operator chose to be asked that question every time, so Giellie asks it in the conversation. + +### 7. The till shows the quantity + +`listActiveStockCatalog` (`src/lib/server/pos-service.ts:139-189`) already returns `current_qty` per item, and the till receives it — `pos-app.js` and `pos.html` contain **no** reference to `current_qty` or `stock_qty` at all. The data is there and unused. + +Changes: + +- **`listActiveStockCatalog`** additionally returns `min_qty`, so the tile can decide its own cue. +- **`pos-app.js:283`** — the tile's `.sub` line becomes `sku · brand · unit · N in stock · M in cart`, where `N` is `current_qty` and `M` is the existing cart count. +- **The out-of-stock cue** is `current_qty <= 0`, not `current_qty <= min_qty`. Only 48 of the 4,083 sellable items have `min_qty > 0`, so a min-based rule would badge 4,035 tiles — every one of them — and mean nothing. The cue that carries information is "you have none of this". +- **The number is shown as it is.** A tile can read `-2,581 in stock`. The tile is styled as out-of-stock so it reads as a state rather than a glitch, but the number is not clamped and not hidden. The numeric value is the signal; colour only reinforces it. +- **Nothing is blocked.** The cue is informational. The cashier can still sell the item, and the quantity still goes into minus. + +Because `public/pos/` is served cache-first by the service worker, the cache constant is bumped **`pos-v37` → `pos-v38`** so till devices pick up the new script. The floor asserted in `src/lib/pos-cart-quantity.test.ts:225` stays satisfied. + +## The conversation, end to end + +> **Operator:** `ABC-123 50` / `BOLT-6MM 120` / `ZZZ-9 30` +> **Giellie:** *Is this the new qty on hand, or should I add these to the current stock?* +> **Operator:** *on hand* +> **Giellie:** *(dry run → plan table)* + +| SKU | Item | Before | After | Status | +|---|---|---|---|---| +| ABC-123 | Bolt M12 x 60 | 12 | 50 | applied | +| BOLT-6MM | Bolt M6 40mm | 5 | 120 | applied · matched by description | +| ZZZ-9 | — | — | — | **not found** | + +> **Operator:** *apply* +> **Giellie:** *(approval card → table → approve)* +> **Giellie:** *Done — 2 applied · 1 unchanged · 1 not found. The till now shows 50 and 120.* + +## Testing + +- `src/app/api/mcp/route.test.ts` — the tool is declared; a dry run writes nothing; `set` and `add` compute the right delta; a negative target is accepted; unmatched and ambiguous lines write nothing; the ledger row carries the acting user. +- `src/lib/server/stock-quantities-service.test.ts` — `allowNegativeQty` lets a negative `current_qty` through while the default still rejects it; `min_qty` and `target_qty` stay floored. +- New `src/lib/server/stock-quantity-match.test.ts` — exact SKU, normalised SKU, description fuzzy, ambiguous, unmatched, inactive candidate. +- `src/components/giellie/approval-card.test.tsx` — the table renders for the new tool; other tools still render JSON. +- `src/lib/server/giellie-tool-policy.test.ts` — the tool classifies as a write; `dry_run: true` classifies as a read. +- `src/lib/server/giellie-system-prompt.test.ts` — the new section is present. +- `src/app/api/v1/pos/catalog` — `min_qty` is present in the response. + +## Not in this spec + +- **The dashboard low-stock view** — the operator's "nice way in the actual dashboard where we update low stock qty and see what is low". A separate spec. Note that `/stock` already has inline quantity editing, a low-stock filter and the xlsx bulk import, so that work is mostly a UI pass over existing capability. +- **Setting `min_qty` on more items.** Only 48 of 4,083 sellable items have a reorder point, so a "what is low" view has almost nothing to compare against until the operator sets thresholds on the items they care about. +- **Keeping `price_list.stock_qty` current on POS sales.** The mirror in this spec covers Giellie writes only. A sale decrements `stock_items.current_qty` and leaves the catalogue snapshot alone, so the snapshot still drifts out of step on every sale. Fixing that belongs in the stock-movement path, not in the agent. +- **Writing to the legacy `pos` collection.** It is an orphan with no writers. + +## Deployment + +The MCP tool lives in `src/app/api/mcp/route.ts` and only reaches the live server after a rebuild and redeploy. Until then `tools/list` will not show it and the in-app Giellie cannot call it. + +`.agents/skills/wfdash-giellie/SKILL.md` is updated in the same change: the new tool is added to the stock tool family, the tool index, and the tool count, and the "73-tool catalogue" figure in the skill is corrected to the new count. + +## Notes + +- The till's catalogue is cached in IndexedDB. An offline till shows the last-known quantities until it reconnects. The service worker deliberately never caches `/api/*` (`sw.js:102`), so the network path is always fresh. +- Stock quantities are whole units by convention; both write paths truncate to integers.