docs(spec): giellie stock quantity update

Giellie can already reach stock_items, but only through ad-hoc
list+adjust loops: a signed delta instead of an absolute count, no
batch call, fuzzy SKU picks the model cannot see are wrong, and
stock_items.adjust throws the actor away so the ledger row has no
attribution.

Adds a stock_items.set_quantities MCP tool that resolves each pasted
line (exact SKU, then description fuzzy), computes the delta, and
writes through updateStockItemsQuantities so the ledger is audited
against the signed-in user. Also mirrors price_list.stock_qty, which
price-list-service.ts documents as a denormalised snapshot of
current_qty.

The live data makes this matter: 522 of the 4,083 sellable items carry
a negative tracked quantity and 2,901 sit at zero. That drift is by
design - the till oversells on purpose (pos-service.ts:603) and is
corrected at the next stocktake, which is what this feature is.

Nothing blocks on low stock. One service flag (allowNegativeQty)
lifts the non-negative floor for the agent's writes only, leaving the
/stock page untouched.
This commit is contained in:
Dave 2026-11-05 06:46:19 +02:00
parent a05330e0cd
commit 4babb893f4

View file

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