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:
parent
a05330e0cd
commit
4babb893f4
1 changed files with 259 additions and 0 deletions
|
|
@ -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.
|
||||
Loading…
Reference in a new issue