diff --git a/docs/superpowers/plans/2026-11-05-giellie-stock-quantity-update.md b/docs/superpowers/plans/2026-11-05-giellie-stock-quantity-update.md new file mode 100644 index 0000000..b3cd733 --- /dev/null +++ b/docs/superpowers/plans/2026-11-05-giellie-stock-quantity-update.md @@ -0,0 +1,2088 @@ +# Giellie stock quantity update — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let the operator paste SKUs, descriptions and quantities into the Giellie chat and have the tracked stock quantities updated, audited, and visible at the till. + +**Architecture:** One new MCP tool (`stock_items.set_quantities`) resolves each pasted line against `stock_items`, computes the delta, and writes through the existing audited quantities service so the ledger row is stamped with the signed-in user. A pure resolver module does the matching, a bulk service does the PocketBase work, and the MCP route wires them together. Alongside it: a readable approval-card table, a "Stock update" shortcut in the chat composer, and a till tile that finally draws the quantity it is already being sent. + +**Tech Stack:** TypeScript, Next.js App Router route handlers, PocketBase (admin client), Anthropic SDK tool use, Vitest 3.2.4, plain ES modules for the POS PWA. + +**Spec:** `docs/superpowers/specs/2026-11-05-giellie-stock-quantity-update-design.md` + +## Global Constraints + +- **Nothing blocks on low stock.** A quantity may be negative — in the tool and at the till. The POS already oversells on purpose (`src/lib/server/pos-service.ts:603`). +- **Quantities are whole units.** Every write truncates to an integer with `Math.trunc`. +- **The ledger row is written first**, then `current_qty`. Same order as `stock-quantities-service.ts` — a crash between the two leaves the audit trail ahead of the cached total. +- **The acting user is stamped** on every ledger row via `created_by: actor.id`. +- **Per-row independence.** One unmatched or ambiguous line never stops the rest of the batch. +- **The POS catalogue is `stock_items`.** Never the legacy `pos` collection — nothing writes to it. +- **Release notes carry no dates** — release number + commit SHA only. `src/lib/release-notes.test.ts` fails the build if a date appears. +- **Bump the POS service-worker cache constant** whenever `public/pos/pos-app.js` changes, or till devices keep the old file. `pos-v37` → `pos-v38`. +- **Never `git add -A`.** This tree carries unrelated untracked artifacts. Stage by explicit path. + +**Commands** + +```bash +npx vitest run # one test file +npx vitest run src/lib/server # a directory +npx tsc --noEmit # type check — the baseline is clean, so any error is yours +``` + +## File Structure + +| File | Action | Responsibility | +|---|---|---| +| `src/lib/server/stock-quantities-service.ts` | modify | Lift the non-negative floor for the agent; make the ledger note say where the edit came from | +| `src/lib/server/stock-quantity-match.ts` | create | Pure line → stock item resolution. No PocketBase | +| `src/lib/server/stock-quantity-match.test.ts` | create | The matching rules | +| `src/lib/server/stock-quantities-bulk-service.ts` | create | The PB work: fetch candidates, resolve, apply, mirror `price_list.stock_qty` | +| `src/lib/server/stock-quantities-bulk-service.test.ts` | create | Apply and mirror behaviour | +| `src/app/api/mcp/route.ts` | modify | The `stock_items.set_quantities` tool: schema + handler | +| `src/app/api/mcp/route.test.ts` | modify | Tool-level behaviour | +| `src/lib/server/giellie-tool-policy.ts` | modify | `dry_run: true` classifies as a read | +| `src/lib/server/giellie-tool-policy.catalogue.test.ts` | modify | The new tool is offered and classified | +| `src/lib/server/giellie-system-prompt.ts` | modify | The "Stock quantity updates" section | +| `src/components/giellie/approval-card.tsx` | modify | Table renderer for the new tool | +| `src/components/giellie/approval-card.test.tsx` | modify | The table renders; other tools keep JSON | +| `src/components/giellie/composer.tsx` | modify | The "Stock update" paste panel | +| `src/components/giellie/composer.test.tsx` | modify | The panel seeds the chat | +| `src/lib/server/pos-service.ts` | modify | `listActiveStockCatalog` also returns `min_qty` | +| `public/pos/pos-app.js` | modify | The tile shows the quantity | +| `public/pos/pos.html` | modify | CSS for the out-of-stock tile | +| `public/pos/sw.js` | modify | Cache bump | +| `.agents/skills/wfdash-giellie/SKILL.md` | modify | Document the new tool | +| `src/lib/release-notes.ts` | modify | Release 7 | + +--- + +### Task 1: Let the quantities service write a negative quantity + +The service today rejects a negative `current_qty`: + +```ts +function coerceNonNegativeInt(key: string, value: unknown): number { + 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. It is wrong for the agent, which must write an absolute count that can legitimately sit below zero. The flag is opt-in so the page is untouched. + +**Files:** +- Modify: `src/lib/server/stock-quantities-service.ts` +- Test: `src/lib/server/stock-quantities-service.test.ts` + +**Interfaces:** +- Consumes: nothing. +- Produces: `UpdateStockItemsQuantitiesInput` gains `allowNegativeQty?: boolean` and `noteContext?: string`. `updateStockItemsQuantities` honours both. + +- [ ] **Step 1: Write the failing tests** + +Append to `src/lib/server/stock-quantities-service.test.ts`: + +```ts +describe("updateStockItemsQuantities — negative quantities", () => { + it("rejects a negative currentQty by default", async () => { + const { pb } = stubPb({ before: { id: "abcdefghijklmno", current_qty: 5 } }); + const res = await updateStockItemsQuantities({ + pb, + updates: [{ id: "abcdefghijklmno", currentQty: -1 }], + actor: { id: "u1", name: "Dave" }, + }); + const row = res.results[0]; + expect(row.ok).toBe(false); + expect(row.ok === false && row.reason).toBe("validation_error"); + }); + + it("accepts a negative currentQty when allowNegativeQty is set", async () => { + const { pb, ledgerCreates } = stubPb({ + before: { id: "abcdefghijklmno", current_qty: 5 }, + }); + const res = await updateStockItemsQuantities({ + pb, + updates: [{ id: "abcdefghijklmno", currentQty: -3 }], + actor: { id: "u1", name: "Dave" }, + allowNegativeQty: true, + }); + expect(res.summary.succeeded).toBe(1); + expect(ledgerCreates[0].change_qty).toBe(-8); + expect(ledgerCreates[0].notes).toBe("Dave edited qty 5 → -3 via Giellie"); + }); + + it("keeps minQty floored at zero even with allowNegativeQty", async () => { + const { pb } = stubPb({ before: { id: "abcdefghijklmno", current_qty: 5 } }); + const res = await updateStockItemsQuantities({ + pb, + updates: [{ id: "abcdefghijklmno", minQty: -1 }], + actor: { id: "u1", name: "Dave" }, + allowNegativeQty: true, + }); + const row = res.results[0]; + expect(row.ok).toBe(false); + expect(row.ok === false && row.reason).toBe("validation_error"); + }); +}); + +describe("updateStockItemsQuantities — ledger note context", () => { + it("defaults the note to the /stock page", async () => { + const { pb, ledgerCreates } = stubPb({ + before: { id: "abcdefghijklmno", current_qty: 5 }, + }); + await updateStockItemsQuantities({ + pb, + updates: [{ id: "abcdefghijklmno", currentQty: 9 }], + actor: { id: "u1", name: "Dave" }, + }); + expect(ledgerCreates[0].notes).toBe("Dave edited qty 5 → 9 from /stock"); + }); + + it("uses the caller's context when given one", async () => { + const { pb, ledgerCreates } = stubPb({ + before: { id: "abcdefghijklmno", current_qty: 5 }, + }); + await updateStockItemsQuantities({ + pb, + updates: [{ id: "abcdefghijklmno", currentQty: 9 }], + actor: { id: "u1", name: "Dave" }, + noteContext: "via Giellie", + }); + expect(ledgerCreates[0].notes).toBe("Dave edited qty 5 → 9 via Giellie"); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx vitest run src/lib/server/stock-quantities-service.test.ts` + +Expected: FAIL — `allowNegativeQty` and `noteContext` are not on the input type, so TypeScript rejects the object literal, or the negative quantity is refused at runtime. + +- [ ] **Step 3: Implement the flag and the note context** + +In `src/lib/server/stock-quantities-service.ts`, replace the input type: + +```ts +export type UpdateStockItemsQuantitiesInput = { + /** PB client; passed in so tests can swap a stub. */ + pb: StockItemsPb; + updates: StockQuantityUpdate[]; + actor: { id: string; name: string }; + /** + * When true, `currentQty` may be negative. The /stock UI keeps its + * non-negative floor — the operator types a physical count there. The + * agent needs to write an absolute count that can legitimately sit + * below zero, because the till oversells on purpose. + */ + allowNegativeQty?: boolean; + /** + * Where the edit came from, appended to the ledger note. Defaults to + * "from /stock"; the agent passes "via Giellie". + */ + noteContext?: string; +}; +``` + +Replace `coerceNonNegativeInt` with: + +```ts +function coerceQty( + key: string, + value: unknown, + allowNegative: boolean, +): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new Error(`${key} must be a finite number`); + } + if (!allowNegative && value < 0) { + throw new Error(`${key} must be a finite number >= 0`); + } + return Math.trunc(value); +} +``` + +Find the validation block that calls `coerceNonNegativeInt(key, value)` and replace it with: + +```ts + try { + coerceQty( + key, + value, + key === "currentQty" && input.allowNegativeQty === true, + ); + } catch (err) { +``` + +Find the ledger write and replace the `notes` line: + +```ts + notes: `${input.actor.name} edited qty ${beforeCurrent} → ${requestedCurrent} ${input.noteContext ?? "from /stock"}`, +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `npx vitest run src/lib/server/stock-quantities-service.test.ts` + +Expected: PASS — all tests in the file, including the pre-existing ones. The `/stock` page behaviour is unchanged because `allowNegativeQty` defaults to undefined. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/stock-quantities-service.ts src/lib/server/stock-quantities-service.test.ts +git commit -m "feat(stock): allow the agent to write a negative current_qty + +The /stock page keeps its non-negative floor — the operator types a +physical count there. The agent has to be able to write an absolute +count that sits below zero, because the till oversells on purpose and +the stocktake is what corrects it. + +Also make the ledger note say where the edit came from, so an agent +write is not mislabelled as a /stock edit." +``` + +--- + +### Task 2: The line resolver + +Pure matching, no PocketBase. Takes the candidate rows as an argument so it is testable on its own. + +**Files:** +- Create: `src/lib/server/stock-quantity-match.ts` +- Test: `src/lib/server/stock-quantity-match.test.ts` + +**Interfaces:** +- Consumes: `normaliseSku` from `@/lib/stock-match`; `tokenize` and `jaccard` from `@/lib/server/order-quote-match`. +- Produces: + - `resolveStockQuantityLines(lines: StockQuantityLine[], candidates: StockQuantityCandidate[]): ResolvedStockQuantityLine[]` + - `DESCRIPTION_MATCH_THRESHOLD: number` + - Types `StockQuantityLine`, `StockQuantityCandidate`, `ResolvedStockQuantityLine`, `StockQuantityMatchMethod`. + +**Note on the description rule.** The operator chose "use it, flag it as fuzzy" for the case where the description *clearly identifies one item*. So the description step ranks the candidates and takes the top one — unless two candidates are tied at the top, which is the genuine ambiguity. A single threshold alone would report "ambiguous" for almost every description, which defeats the feature. + +- [ ] **Step 1: Write the failing test** + +Create `src/lib/server/stock-quantity-match.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { resolveStockQuantityLines } from "./stock-quantity-match"; + +const CANDIDATES = [ + { + id: "id-abc", + sku: "M16-FLAT-WASHER", + name: "M16 Flat Washer", + description: "Flat washer M16 zinc plated", + current_qty: 12, + is_active: true, + }, + { + id: "id-def", + sku: "BOLT-M6-40", + name: "Bolt M6 40mm", + description: "Hex bolt M6 x 40mm", + current_qty: 5, + is_active: true, + }, + { + id: "id-ghi", + sku: "BOLT-M6-60", + name: "Bolt M6 60mm", + description: "Hex bolt M6 x 60mm", + current_qty: 0, + is_active: true, + }, + { + id: "id-jkl", + sku: "OLD-ITEM", + name: "Retired Widget", + description: "No longer sold", + current_qty: 3, + is_active: false, + }, +]; + +describe("resolveStockQuantityLines", () => { + it("matches an exact SKU", () => { + const out = resolveStockQuantityLines( + [{ sku: "M16-FLAT-WASHER", qty: 50 }], + CANDIDATES, + ); + expect(out[0].status).toBe("matched"); + expect(out[0].match_method).toBe("sku_exact"); + expect(out[0].item?.id).toBe("id-abc"); + }); + + it("matches a SKU that differs only in case and punctuation", () => { + const out = resolveStockQuantityLines( + [{ sku: "m16 flat washer", qty: 50 }], + CANDIDATES, + ); + expect(out[0].status).toBe("matched"); + expect(out[0].match_method).toBe("sku_exact"); + expect(out[0].item?.id).toBe("id-abc"); + }); + + it("matches on description and flags it as a fuzzy match", () => { + const out = resolveStockQuantityLines( + [{ description: "Hex bolt M6 x 40mm", qty: 120 }], + CANDIDATES, + ); + expect(out[0].status).toBe("matched"); + expect(out[0].match_method).toBe("description_fuzzy"); + expect(out[0].item?.id).toBe("id-def"); + }); + + it("reports a line with no identifier as unmatched", () => { + const out = resolveStockQuantityLines([{ qty: 5 }], CANDIDATES); + expect(out[0].status).toBe("unmatched"); + expect(out[0].message).toMatch(/neither a sku nor a description/i); + }); + + it("reports a SKU that matches nothing as unmatched", () => { + const out = resolveStockQuantityLines([{ sku: "ZZZ-9", qty: 30 }], CANDIDATES); + expect(out[0].status).toBe("unmatched"); + expect(out[0].message).toMatch(/no active stock item/i); + }); + + it("does not write to an inactive item and says why", () => { + const out = resolveStockQuantityLines([{ sku: "OLD-ITEM", qty: 3 }], CANDIDATES); + expect(out[0].status).toBe("unmatched"); + expect(out[0].message).toMatch(/inactive/i); + }); + + it("reports two candidates with the same SKU as ambiguous", () => { + const out = resolveStockQuantityLines( + [{ sku: "BOLT-M6", qty: 10 }], + [ + ...CANDIDATES, + { id: "id-dup", sku: "BOLT-M6", name: "Bolt M6", description: "", current_qty: 1 }, + ], + ); + expect(out[0].status).toBe("ambiguous"); + expect(out[0].candidates?.length).toBe(2); + }); + + it("keeps the line index so callers can map rows back", () => { + const out = resolveStockQuantityLines( + [{ sku: "ZZZ-9", qty: 1 }, { sku: "M16-FLAT-WASHER", qty: 2 }], + CANDIDATES, + ); + expect(out[0].index).toBe(0); + expect(out[1].index).toBe(1); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/lib/server/stock-quantity-match.test.ts` + +Expected: FAIL — `./stock-quantity-match` does not exist. + +- [ ] **Step 3: Implement the resolver** + +Create `src/lib/server/stock-quantity-match.ts`: + +```ts +/** + * Stock quantity line resolution — pure. No PocketBase, no I/O. + * + * The operator pastes lines of "SKU description qty" into the Giellie + * chat. This module decides which stock item each line means. It is + * kept pure so the matching rules can be tested without a database and + * so the same rules can be reused by a future batch-import path. + * + * Resolution order per line: + * 1. Exact SKU, normalised (uppercase, punctuation stripped). One + * hit matches; two or more are ambiguous; a single inactive hit is + * reported as unmatched with the reason. + * 2. Description match — tokenised Jaccard against the item's name + * and description. The top score wins unless two candidates are + * tied at the top, which is the genuine ambiguity. The operator + * asked for this to be used rather than refused, so it is flagged + * as a fuzzy match instead of being silent. + */ + +import { normaliseSku } from "@/lib/stock-match"; +import { jaccard, tokenize } from "@/lib/server/order-quote-match"; + +/** Tokenised Jaccard score at or above which a description counts as a match. */ +export const DESCRIPTION_MATCH_THRESHOLD = 0.6; + +export type StockQuantityLine = { + sku?: string; + description?: string; + qty: number; +}; + +export type StockQuantityCandidate = { + id: string; + sku?: string; + name?: string; + description?: string; + current_qty?: number; + is_active?: boolean; +}; + +export type StockQuantityMatchMethod = "sku_exact" | "description_fuzzy"; + +export type ResolvedStockQuantityLine = { + index: number; + sku: string; + description: string; + qty: number; + status: "matched" | "unmatched" | "ambiguous"; + match_method?: StockQuantityMatchMethod; + item?: StockQuantityCandidate; + candidates?: StockQuantityCandidate[]; + message?: string; +}; + +export function resolveStockQuantityLines( + lines: StockQuantityLine[], + candidates: StockQuantityCandidate[], +): ResolvedStockQuantityLine[] { + return lines.map((line, index) => { + const sku = (line.sku ?? "").trim(); + const description = (line.description ?? "").trim(); + const base = { index, sku, description, qty: line.qty }; + + if (!sku && !description) { + return { + ...base, + status: "unmatched", + message: "Line has neither a sku nor a description.", + }; + } + + if (sku) { + const norm = normaliseSku(sku); + const hits = norm + ? candidates.filter((c) => normaliseSku(c.sku) === norm) + : []; + if (hits.length > 1) { + return { + ...base, + status: "ambiguous", + candidates: hits, + message: `${hits.length} stock items share the SKU "${sku}".`, + }; + } + if (hits.length === 1) { + const hit = hits[0]; + if (hit.is_active === false) { + return { + ...base, + status: "unmatched", + message: `SKU "${sku}" matches "${hit.name ?? hit.id}", but that stock item is inactive.`, + }; + } + return { + ...base, + status: "matched", + match_method: "sku_exact", + item: hit, + }; + } + } + + if (description) { + const wanted = tokenize(description); + if (wanted.size > 0) { + const scored = candidates + .filter((c) => c.is_active !== false) + .map((c) => ({ + item: c, + score: jaccard(wanted, tokenize(`${c.name ?? ""} ${c.description ?? ""}`)), + })) + .filter((s) => s.score >= DESCRIPTION_MATCH_THRESHOLD) + .sort((a, b) => b.score - a.score); + + if (scored.length > 0) { + const top = scored[0]; + const runnerUp = scored[1]; + const tied = runnerUp !== undefined && runnerUp.score === top.score; + if (!tied) { + return { + ...base, + status: "matched", + match_method: "description_fuzzy", + item: top.item, + }; + } + const tiedRows = scored.filter((s) => s.score === top.score); + return { + ...base, + status: "ambiguous", + candidates: tiedRows.map((s) => s.item), + message: `"${description}" matches ${tiedRows.length} stock items equally well.`, + }; + } + } + } + + return { + ...base, + status: "unmatched", + message: "No active stock item matches this SKU or description.", + }; + }); +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx vitest run src/lib/server/stock-quantity-match.test.ts` + +Expected: PASS — 8 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/server/stock-quantity-match.ts src/lib/server/stock-quantity-match.test.ts +git commit -m "feat(stock): resolve pasted stock lines to stock items + +Pure matching, no PocketBase, so the rules can be tested on their own. +Exact SKU first (normalised), then a tokenised Jaccard description +match against the item's name and description. + +The description match takes the top candidate unless two are tied at +the top — a single threshold alone would report 'ambiguous' for almost +every description and defeat the feature." +``` + +--- + +### Task 3: The bulk service + +The PocketBase work: fetch candidates, resolve, apply through the audited service, and mirror `price_list.stock_qty`. + +**Files:** +- Create: `src/lib/server/stock-quantities-bulk-service.ts` +- Test: `src/lib/server/stock-quantities-bulk-service.test.ts` + +**Interfaces:** +- Consumes: `resolveStockQuantityLines` from Task 2; `updateStockItemsQuantities` and `StockItemsPb` from `stock-quantities-service`; `normaliseSku` and `escapeForPbString` from `@/lib/server/price-list-match`. +- Produces: `setStockQuantitiesBulk(input): Promise` and its types. + +**Note on the two `normaliseSku` helpers.** `@/lib/stock-match` strips whitespace and punctuation and uppercases; `@/lib/server/price-list-match` strips everything that is not A-Z or 0-9 and uppercases. They produce the same string. The resolver uses the first, the catalogue lookup uses the second, because `price_list.sku_normalised` was written with the second. + +- [ ] **Step 1: Write the failing test** + +Create `src/lib/server/stock-quantities-bulk-service.test.ts`: + +```ts +import { describe, it, expect, vi } from "vitest"; +import { setStockQuantitiesBulk } from "./stock-quantities-bulk-service"; + +const STOCK_ITEMS = [ + { + id: "id-abc", + sku: "M16-FLAT-WASHER", + name: "M16 Flat Washer", + description: "Flat washer M16", + current_qty: 12, + is_active: true, + }, + { + id: "id-def", + sku: "BOLT-M6-40", + name: "Bolt M6 40mm", + description: "Hex bolt M6 x 40mm", + current_qty: 5, + is_active: true, + }, +]; + +function stubPb(priceListRows: Array> = []) { + const calls: string[] = []; + const ledgerCreates: Array> = []; + const stockUpdates: Array<{ id: string; patch: Record }> = []; + const priceListUpdates: Array<{ id: string; patch: Record }> = []; + + const pb = { + collection: (name: string) => { + if (name === "stock_items") { + return { + getFullList: vi.fn().mockResolvedValue(STOCK_ITEMS), + getOne: vi.fn().mockImplementation(async (id: string) => ({ + ...STOCK_ITEMS.find((i) => i.id === id), + })), + update: vi.fn().mockImplementation(async (id: string, patch: Record) => { + calls.push("stock_items.update"); + stockUpdates.push({ id, patch }); + return { id, ...patch }; + }), + create: vi.fn(), + }; + } + if (name === "stock_ledger") { + return { + create: vi.fn().mockImplementation(async (row: Record) => { + calls.push("stock_ledger.create"); + ledgerCreates.push(row); + return { id: "ledger1", ...row }; + }), + }; + } + if (name === "price_list") { + return { + getList: vi.fn().mockResolvedValue({ + items: priceListRows, + totalItems: priceListRows.length, + }), + update: vi.fn().mockImplementation(async (id: string, patch: Record) => { + priceListUpdates.push({ id, patch }); + return { id, ...patch }; + }), + }; + } + throw new Error(`unexpected collection ${name}`); + }, + }; + + return { pb, calls, ledgerCreates, stockUpdates, priceListUpdates }; +} + +describe("setStockQuantitiesBulk — dry run", () => { + it("plans without writing anything", async () => { + const { pb, calls, ledgerCreates, stockUpdates } = stubPb(); + const res = await setStockQuantitiesBulk({ + pb, + lines: [{ sku: "M16-FLAT-WASHER", qty: 50 }], + mode: "set", + dryRun: true, + actor: { id: "u1", name: "Dave" }, + }); + expect(res.summary.applied).toBe(1); + expect(res.results[0].previous_qty).toBe(12); + expect(res.results[0].new_qty).toBe(50); + expect(res.results[0].delta).toBe(38); + expect(ledgerCreates).toHaveLength(0); + expect(stockUpdates).toHaveLength(0); + expect(calls).toHaveLength(0); + }); +}); + +describe("setStockQuantitiesBulk — applying", () => { + it("writes the ledger row before the quantity, stamped with the actor", async () => { + const { pb, calls, ledgerCreates, stockUpdates } = stubPb(); + const res = await setStockQuantitiesBulk({ + pb, + lines: [{ sku: "M16-FLAT-WASHER", qty: 50 }], + mode: "set", + dryRun: false, + actor: { id: "u1", name: "Dave" }, + }); + expect(res.summary.applied).toBe(1); + expect(calls[0]).toBe("stock_ledger.create"); + expect(calls[1]).toBe("stock_items.update"); + expect(ledgerCreates[0].change_qty).toBe(38); + expect(ledgerCreates[0].created_by).toBe("u1"); + expect(ledgerCreates[0].notes).toContain("via Giellie"); + expect(stockUpdates[0].patch.current_qty).toBe(50); + }); + + it("adds to the current quantity in add mode", async () => { + const { pb, ledgerCreates } = stubPb(); + const res = await setStockQuantitiesBulk({ + pb, + lines: [{ sku: "M16-FLAT-WASHER", qty: 8 }], + mode: "add", + dryRun: false, + actor: { id: "u1", name: "Dave" }, + }); + expect(res.results[0].new_qty).toBe(20); + expect(ledgerCreates[0].change_qty).toBe(8); + }); + + it("reports a line that already has that quantity as unchanged and writes nothing", async () => { + const { pb, ledgerCreates, calls } = stubPb(); + const res = await setStockQuantitiesBulk({ + pb, + lines: [{ sku: "M16-FLAT-WASHER", qty: 12 }], + mode: "set", + dryRun: false, + actor: { id: "u1", name: "Dave" }, + }); + expect(res.summary.unchanged).toBe(1); + expect(ledgerCreates).toHaveLength(0); + expect(calls).toHaveLength(0); + }); + + it("writes a negative quantity without complaint", async () => { + const { pb, ledgerCreates, stockUpdates } = stubPb(); + const res = await setStockQuantitiesBulk({ + pb, + lines: [{ sku: "M16-FLAT-WASHER", qty: -20 }], + mode: "set", + dryRun: false, + actor: { id: "u1", name: "Dave" }, + }); + expect(res.summary.applied).toBe(1); + expect(res.results[0].new_qty).toBe(-20); + expect(ledgerCreates[0].change_qty).toBe(-32); + expect(stockUpdates[0].patch.current_qty).toBe(-20); + }); + + it("applies the good lines and reports the unmatched one", async () => { + const { pb, ledgerCreates } = stubPb(); + const res = await setStockQuantitiesBulk({ + pb, + lines: [ + { sku: "M16-FLAT-WASHER", qty: 50 }, + { sku: "ZZZ-9", qty: 30 }, + ], + mode: "set", + dryRun: false, + actor: { id: "u1", name: "Dave" }, + }); + expect(res.summary.applied).toBe(1); + expect(res.summary.unmatched).toBe(1); + expect(res.results[1].status).toBe("unmatched"); + expect(ledgerCreates).toHaveLength(1); + }); +}); + +describe("setStockQuantitiesBulk — price_list mirror", () => { + it("mirrors the new quantity onto every catalogue row for that SKU", async () => { + const { pb, priceListUpdates } = stubPb([ + { id: "pl1", sku: "M16-FLAT-WASHER", sku_normalised: "M16FLATWASHER" }, + { id: "pl2", sku: "M16 Flat Washer", sku_normalised: "M16FLATWASHER" }, + ]); + const res = await setStockQuantitiesBulk({ + pb, + lines: [{ sku: "M16-FLAT-WASHER", qty: 50 }], + mode: "set", + dryRun: false, + actor: { id: "u1", name: "Dave" }, + }); + expect(res.results[0].price_list_rows_touched).toBe(2); + expect(priceListUpdates).toHaveLength(2); + expect(priceListUpdates[0].patch.stock_qty).toBe(50); + }); + + it("reports zero catalogue rows without failing the write", async () => { + const { pb, ledgerCreates } = stubPb([]); + const res = await setStockQuantitiesBulk({ + pb, + lines: [{ sku: "M16-FLAT-WASHER", qty: 50 }], + mode: "set", + dryRun: false, + actor: { id: "u1", name: "Dave" }, + }); + expect(res.summary.applied).toBe(1); + expect(res.results[0].price_list_rows_touched).toBe(0); + expect(ledgerCreates).toHaveLength(1); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/lib/server/stock-quantities-bulk-service.test.ts` + +Expected: FAIL — `./stock-quantities-bulk-service` does not exist. + +- [ ] **Step 3: Implement the bulk service** + +Create `src/lib/server/stock-quantities-bulk-service.ts`: + +```ts +/** + * Bulk stock quantity writes — the engine behind the MCP tool + * `stock_items.set_quantities`. + * + * The operator pastes lines of "SKU description qty" into the Giellie + * chat. This service resolves them against `stock_items`, works out the + * signed delta, and hands the write to `updateStockItemsQuantities` so + * the stock_ledger row is written first and stamped with the acting + * user. It then mirrors the new quantity onto `price_list.stock_qty`, + * which is a denormalised snapshot of our own quantity (see + * `price-list-service.ts`). + * + * Nothing here blocks on a low or negative quantity. The till oversells + * on purpose and the stocktake is what corrects it. + */ + +import { + updateStockItemsQuantities, + type StockItemsPb, +} from "./stock-quantities-service"; +import { + resolveStockQuantityLines, + type ResolvedStockQuantityLine, + type StockQuantityCandidate, + type StockQuantityLine, +} from "./stock-quantity-match"; +import { escapeForPbString, normaliseSku } from "@/lib/server/price-list-match"; + +export type StockQuantityMode = "set" | "add"; + +export type StockQuantitiesBulkPb = { + collection: (id: string) => { + getFullList: (opts?: Record) => Promise; + getList: ( + page: number, + perPage: number, + opts?: Record, + ) => Promise<{ items: T[]; totalItems: number }>; + getOne: (id: string, opts?: Record) => Promise; + update: (id: string, patch: Record) => Promise; + create: (data: Record) => Promise; + }; +}; + +export type StockQuantitiesBulkInput = { + pb: StockQuantitiesBulkPb; + lines: StockQuantityLine[]; + mode: StockQuantityMode; + dryRun: boolean; + actor: { id: string; name: string }; +}; + +export type StockQuantitiesBulkRowStatus = + | "applied" + | "unchanged" + | "unmatched" + | "ambiguous"; + +export type StockQuantitiesBulkResultRow = { + sku: string; + description: string; + qty: number; + status: StockQuantitiesBulkRowStatus; + match_method?: "sku_exact" | "description_fuzzy"; + id?: string; + name?: string; + previous_qty?: number; + new_qty?: number; + delta?: number; + price_list_rows_touched?: number; + candidates?: Array<{ id: string; name: string; current_qty: number }>; + message?: string; +}; + +export type StockQuantitiesBulkResult = { + ok: boolean; + mode: StockQuantityMode; + dry_run: boolean; + summary: { + total: number; + applied: number; + unchanged: number; + unmatched: number; + ambiguous: number; + }; + results: StockQuantitiesBulkResultRow[]; +}; + +type PriceListRow = { + id: string; + sku?: string; + sku_normalised?: string; +}; + +type StockCandidate = { + id: string; + sku?: string; + name?: string; + description?: string; + current_qty?: number; + is_active?: boolean; +}; + +function toCandidate(row: StockCandidate): StockQuantityCandidate { + return { + id: row.id, + sku: row.sku, + name: row.name, + description: row.description, + current_qty: Number(row.current_qty ?? 0), + is_active: row.is_active !== false, + }; +} + +/** + * Mirror our quantity onto the price-list catalogue. The catalogue holds + * one row per SKU per supplier, so one stock item can touch several + * rows. Matching is on the canonical SKU form, which is what the + * catalogue's own `sku_normalised` column stores. + */ +async function mirrorPriceListQty( + pb: StockQuantitiesBulkPb, + rawSku: string, + newQty: number, +): Promise { + const norm = normaliseSku(rawSku); + if (!norm) return 0; + + const page = await pb.collection("price_list").getList(1, 200, { + filter: `sku_normalised = "${norm}" || sku ~ "${escapeForPbString(rawSku)}"`, + }); + + const rows = page.items.filter((r) => normaliseSku(r.sku) === norm); + for (const row of rows) { + await pb.collection("price_list").update(row.id, { stock_qty: newQty }); + } + return rows.length; +} + +function candidateSummary(candidates: StockQuantityCandidate[]) { + return candidates.map((c) => ({ + id: c.id, + name: c.name ?? c.id, + current_qty: Number(c.current_qty ?? 0), + })); +} + +export async function setStockQuantitiesBulk( + input: StockQuantitiesBulkInput, +): Promise { + const { pb, lines, mode, dryRun, actor } = input; + + const raw = + (await pb.collection("stock_items").getFullList({ + fields: "id,sku,name,description,current_qty,is_active", + })) ?? []; + + const resolved: ResolvedStockQuantityLine[] = resolveStockQuantityLines( + lines, + raw.map(toCandidate), + ); + + // Work out the target for every matched line before touching anything. + const planned = resolved.map((line) => { + if (line.status !== "matched" || !line.item) { + return { line, target: undefined, delta: undefined }; + } + const current = Number(line.item.current_qty ?? 0); + const target = mode === "set" ? line.qty : current + line.qty; + return { line, target, delta: target - current }; + }); + + const toWrite = planned + .filter( + (p): p is { + line: ResolvedStockQuantityLine & { + item: NonNullable; + }; + target: number; + delta: number; + } => + p.line.status === "matched" && + p.target !== undefined && + p.delta !== undefined && + p.delta !== 0, + ) + .map((p) => ({ id: p.line.item.id, currentQty: p.target })); + + let writeResults: Awaited>["results"] = []; + if (!dryRun && toWrite.length > 0) { + const write = await updateStockItemsQuantities({ + pb: pb as unknown as StockItemsPb, + updates: toWrite, + actor, + allowNegativeQty: true, + noteContext: "via Giellie", + }); + writeResults = write.results; + } + + const results: StockQuantitiesBulkResultRow[] = []; + const summary = { + total: lines.length, + applied: 0, + unchanged: 0, + unmatched: 0, + ambiguous: 0, + }; + + for (const p of planned) { + const line = p.line; + const base: StockQuantitiesBulkResultRow = { + sku: line.sku, + description: line.description, + qty: line.qty, + }; + + if (line.status === "ambiguous") { + summary.ambiguous += 1; + results.push({ + ...base, + status: "ambiguous", + candidates: candidateSummary(line.candidates ?? []), + message: line.message, + }); + continue; + } + + if (line.status === "unmatched" || !line.item || p.target === undefined) { + summary.unmatched += 1; + results.push({ ...base, status: "unmatched", message: line.message }); + continue; + } + + const current = Number(line.item.current_qty ?? 0); + const delta = p.target - current; + + if (delta === 0) { + summary.unchanged += 1; + results.push({ + ...base, + status: "unchanged", + match_method: line.match_method, + id: line.item.id, + name: line.item.name, + previous_qty: current, + new_qty: p.target, + delta: 0, + }); + continue; + } + + const writeRow = writeResults.find((r) => r.id === line.item?.id); + + if (!writeRow || writeRow.ok === false) { + summary.unmatched += 1; + results.push({ + ...base, + status: "unmatched", + match_method: line.match_method, + id: line.item.id, + name: line.item.name, + previous_qty: current, + message: + writeRow && writeRow.ok === false + ? writeRow.message + : "The quantity write did not complete.", + }); + continue; + } + + summary.applied += 1; + let priceListRowsTouched = 0; + if (!dryRun && line.item.sku) { + priceListRowsTouched = await mirrorPriceListQty(pb, line.item.sku, p.target); + } + + results.push({ + ...base, + status: "applied", + match_method: line.match_method, + id: line.item.id, + name: line.item.name, + previous_qty: current, + new_qty: p.target, + delta, + price_list_rows_touched: priceListRowsTouched, + }); + } + + return { + ok: true, + mode, + dry_run: dryRun, + summary, + results, + }; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx vitest run src/lib/server/stock-quantities-bulk-service.test.ts` + +Expected: PASS — 9 tests. + +- [ ] **Step 5: Run the whole server test folder to check nothing else broke** + +Run: `npx vitest run src/lib/server` + +Expected: PASS — the new files plus every pre-existing test. + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/server/stock-quantities-bulk-service.ts src/lib/server/stock-quantities-bulk-service.test.ts +git commit -m "feat(stock): bulk quantity writes with a price-list mirror + +Resolves pasted lines, computes the signed delta, and hands the write +to updateStockItemsQuantities so the ledger row is written first and +stamped with the acting user. A negative result is a normal outcome, +not an error — the till oversells on purpose. + +Mirrors the new quantity onto price_list.stock_qty, which is a +denormalised snapshot of our own quantity. The catalogue holds one row +per SKU per supplier, so one item can touch several rows; the count is +reported per line." +``` + +--- + +### Task 4: The MCP tool + +**Files:** +- Modify: `src/app/api/mcp/route.ts` +- Test: `src/app/api/mcp/route.test.ts` + +**Interfaces:** +- Consumes: `setStockQuantitiesBulk` from Task 3. +- Produces: the MCP tool `stock_items.set_quantities`, offered to the model as `stock_items__set_quantities`. + +- [ ] **Step 1: Write the failing test** + +Append to `src/app/api/mcp/route.test.ts`: + +```ts +describe("MCP tool stock_items.set_quantities", () => { + it("is declared in the tool catalogue", async () => { + const res = await GET( + new NextRequest("http://localhost/api/mcp", { + headers: { authorization: `Bearer ${process.env.AGENT_API_KEY}` }, + }), + ); + const body = (await res.json()) as { tools: Array<{ name: string }> }; + expect(body.tools.some((t) => t.name === "stock_items.set_quantities")).toBe(true); + }); + + it("rejects a batch with a duplicate SKU", async () => { + const res = await POST( + new NextRequest("http://localhost/api/mcp", { + method: "POST", + headers: { + authorization: `Bearer ${process.env.AGENT_API_KEY}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "stock_items.set_quantities", + arguments: { + mode: "set", + lines: [ + { sku: "ABC-1", qty: 1 }, + { sku: "abc 1", qty: 2 }, + ], + }, + }, + }), + }), + ); + const body = await res.json(); + expect(JSON.stringify(body)).toMatch(/duplicate SKU/i); + }); + + it("rejects an unknown mode", async () => { + const res = await POST( + new NextRequest("http://localhost/api/mcp", { + method: "POST", + headers: { + authorization: `Bearer ${process.env.AGENT_API_KEY}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "stock_items.set_quantities", + arguments: { mode: "sideways", lines: [{ sku: "ABC-1", qty: 1 }] }, + }, + }), + }), + ); + const body = await res.json(); + expect(JSON.stringify(body)).toMatch(/mode must be/i); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/app/api/mcp/route.test.ts` + +Expected: FAIL — the tool is not in the catalogue. + +- [ ] **Step 3: Declare the tool** + +In `src/app/api/mcp/route.ts`, find the `stock_items.set_pricing` entry in the tool list and add this entry directly after it: + +```ts + { + name: "stock_items.set_quantities", + description: + "Set or change the stock quantity on one or more POS/stock items, matched by SKU and description. " + + 'Use mode "set" when the number is the new quantity on hand (the stocktake case), and mode "add" when it is a change to add to the current quantity. ' + + "Each line is resolved by exact SKU first, then by a description match (flagged as such). A line that cannot be resolved is reported, never guessed. " + + "Pass dry_run true first to preview the before/after plan. Quantities may be negative — the till oversells on purpose and the stocktake corrects it. " + + "Writes a stock_ledger row against the acting user and mirrors the new quantity onto the price-list catalogue.", + inputSchema: { + type: "object", + properties: { + mode: { + type: "string", + enum: ["set", "add"], + description: + '"set" = the number is the new quantity on hand. "add" = the number is added to the current quantity.', + }, + lines: { + type: "array", + minItems: 1, + maxItems: 100, + description: + "One line per stock item. Give the sku when you have it; the description is the fallback when the SKU does not match.", + items: { + type: "object", + properties: { + sku: { type: "string", description: "The stock item's SKU." }, + description: { + type: "string", + description: "Free-text description, used when the SKU does not match.", + }, + qty: { + type: "number", + description: + "The quantity. In set mode this is the new quantity on hand; in add mode it is the change.", + }, + }, + required: ["qty"], + }, + }, + dry_run: { + type: "boolean", + description: + "Plan the change without writing anything. Returns the same result shape with the same before/after numbers.", + }, + }, + required: ["mode", "lines"], + }, + }, +``` + +- [ ] **Step 4: Handle the call** + +Find the `stock_items.set_pricing` handler in the same file and add this branch immediately after it: + +```ts + if (toolName === "stock_items.set_quantities") { + const mode = String(args.mode ?? ""); + if (mode !== "set" && mode !== "add") { + throw new Error('mode must be "set" or "add"'); + } + + const rawLines = Array.isArray(args.lines) ? (args.lines as unknown[]) : []; + if (rawLines.length < 1 || rawLines.length > 100) { + throw new Error("lines must contain between 1 and 100 entries"); + } + + const lines = rawLines.map((raw) => { + const l = (raw ?? {}) as { sku?: unknown; description?: unknown; qty?: unknown }; + const qty = + typeof l.qty === "number" && Number.isFinite(l.qty) ? Math.trunc(l.qty) : NaN; + return { + sku: typeof l.sku === "string" ? l.sku : undefined, + description: typeof l.description === "string" ? l.description : undefined, + qty, + }; + }); + + if (lines.some((l) => !Number.isFinite(l.qty))) { + throw new Error("every line needs a finite numeric qty"); + } + + // One SKU is one stock row, so two lines for the same SKU cannot + // both be true. That is a malformed paste, not a per-row outcome. + const seen = new Set(); + const duplicates = new Set(); + for (const l of lines) { + const key = normaliseSku(l.sku ?? ""); + if (!key) continue; + if (seen.has(key)) duplicates.add(l.sku ?? key); + seen.add(key); + } + if (duplicates.size > 0) { + throw new Error(`duplicate SKU in one batch: ${[...duplicates].join(", ")}`); + } + + const actorRaw = (args.actor ?? {}) as { id?: string; name?: string }; + const result = await setStockQuantitiesBulk({ + pb, + lines, + mode, + dryRun: args.dry_run === true, + actor: { + id: typeof actorRaw.id === "string" ? actorRaw.id : "", + name: + typeof actorRaw.name === "string" && actorRaw.name ? actorRaw.name : "Giellie", + }, + }); + return jsonRpcOk(id, result); + } +``` + +Add the imports at the top of the file, next to the other service imports: + +```ts +import { setStockQuantitiesBulk } from "@/lib/server/stock-quantities-bulk-service"; +import { normaliseSku } from "@/lib/server/price-list-match"; +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `npx vitest run src/app/api/mcp/route.test.ts` + +Expected: PASS. + +- [ ] **Step 6: Type-check** + +Run: `npx tsc --noEmit` + +Expected: no errors. The baseline was clean, so anything here is yours. + +- [ ] **Step 7: Commit** + +```bash +git add src/app/api/mcp/route.ts src/app/api/mcp/route.test.ts +git commit -m "feat(mcp): stock_items.set_quantities + +One call sets the stock quantity on up to 100 pasted lines. Each line +is resolved by exact SKU, then by description, and reports what it +could not resolve instead of guessing. + +The batch is validated up front — a duplicate SKU is a malformed +paste, not a per-row failure — while a line that simply does not match +is reported and the rest of the batch still applies." +``` + +--- + +### Task 5: Tell the agent how to use it + +**Files:** +- Modify: `src/lib/server/giellie-tool-policy.ts` +- Modify: `src/lib/server/giellie-tool-policy.catalogue.test.ts` +- Modify: `src/lib/server/giellie-system-prompt.ts` +- Test: `src/lib/server/giellie-tool-policy.test.ts` +- Test: `src/lib/server/giellie-system-prompt.test.ts` + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: `classifyToolCall("stock_items.set_quantities", { dry_run: true })` returns `{ cls: "read" }`. + +- [ ] **Step 1: Write the failing tests** + +Append to `src/lib/server/giellie-tool-policy.test.ts`: + +```ts +describe("stock_items.set_quantities", () => { + it("is a write when it will actually write", () => { + expect( + classifyToolCall("stock_items.set_quantities", { mode: "set", lines: [] }).cls, + ).toBe("write"); + }); + + it("is a read when dry_run is true, so the preview never asks", () => { + expect( + classifyToolCall("stock_items.set_quantities", { + mode: "set", + lines: [], + dry_run: true, + }).cls, + ).toBe("read"); + }); +}); +``` + +In `src/lib/server/giellie-tool-policy.catalogue.test.ts`, add after the `stock_items.set_pricing` case: + +```ts + it("offers stock_items.set_quantities to the model as a write", () => { + expect(tools.some((t) => t.name === "stock_items.set_quantities")).toBe(true); + const offered = toAnthropicTools(tools).map((t) => fromModelToolName(t.name)); + expect(offered).toContain("stock_items.set_quantities"); + expect(classifyTool("stock_items.set_quantities")).toBe("write"); + expect(READ_TOOLS.has("stock_items.set_quantities")).toBe(false); + expect(DENIED_TOOLS.has("stock_items.set_quantities")).toBe(false); + expect(CONFIRM_TOOLS.has("stock_items.set_quantities")).toBe(false); + }); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `npx vitest run src/lib/server/giellie-tool-policy.test.ts src/lib/server/giellie-tool-policy.catalogue.test.ts` + +Expected: FAIL — the dry run is still classified as a write, and the catalogue does not have the tool. + +- [ ] **Step 3: Add the dry-run rule** + +In `src/lib/server/giellie-tool-policy.ts`, inside `classifyToolCall`, add this before the `base === "write"` branch: + +```ts + // A dry run writes nothing, so it must not raise an approval card. + if ( + name === "stock_items.set_quantities" && + (input as { dry_run?: unknown } | null)?.dry_run === true + ) { + return { cls: "read" }; + } +``` + +- [ ] **Step 4: Add the system prompt section** + +In `src/lib/server/giellie-system-prompt.ts`, add this section to the prompt string: + +``` +## Stock quantity updates + +When the operator pastes stock lines (a SKU or description with a quantity): + +1. **Always ask first** whether the numbers are the new quantity on hand or a + change to add to the current stock. Do not infer it from the wording, and do + not write anything until they answer. +2. Resolve the lines with `stock_items.set_quantities`. Never loop + `stock_items.list` and `stock_items.adjust` by hand — that path takes a + signed delta, has no batch call, and writes a ledger row with no actor. +3. Call it with `dry_run: true` first and show the operator the plan as a + table: SKU, item, before, after, status. Point out the lines that did not + match and the ones that were ambiguous. +4. Only when they say to apply, call it again with `dry_run: false`. +5. Report the outcome: applied, unchanged, unmatched, ambiguous. +6. Quantities may be negative and nothing blocks on a low quantity. The till + oversells on purpose and the stocktake is what corrects it. + +Use `price_list.add_items` only when the operator explicitly asks for the +price list or supplier catalogue. It is not the way to load prices or stock +onto the counter. +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `npx vitest run src/lib/server/giellie-tool-policy.test.ts src/lib/server/giellie-tool-policy.catalogue.test.ts src/lib/server/giellie-system-prompt.test.ts` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/server/giellie-tool-policy.ts src/lib/server/giellie-tool-policy.catalogue.test.ts src/lib/server/giellie-tool-policy.test.ts src/lib/server/giellie-system-prompt.ts src/lib/server/giellie-system-prompt.test.ts +git commit -m "feat(giellie): teach the agent the stock quantity workflow + +The dry run is a read, so the preview never raises an approval card. +The prompt says to ask whether the numbers are the new quantity on +hand or an addition, every time, before writing anything." +``` + +--- + +### Task 6: A readable approval card + +Today every tool renders raw JSON. A 40-line stocktake in JSON is unreviewable. + +**Files:** +- Modify: `src/components/giellie/approval-card.tsx` +- Test: `src/components/giellie/approval-card.test.tsx` + +**Interfaces:** +- Consumes: nothing. +- Produces: `renderToolInput(toolName: string, input: unknown): ReactNode` exported from `approval-card.tsx`. + +The card receives **dotted** MCP names — `PendingToolUse.name` is documented as `orders.update` — so the renderer matches `stock_items.set_quantities` directly. + +- [ ] **Step 1: Write the failing test** + +Append to `src/components/giellie/approval-card.test.tsx`: + +```tsx +describe("the stock quantity tool renders a table", () => { + it("lists the sku, description and qty", () => { + render( + , + ); + expect(screen.getByText("ABC-123")).toBeTruthy(); + expect(screen.getByText("Bolt M12")).toBeTruthy(); + expect(screen.getByText("50")).toBeTruthy(); + expect(screen.getByText(/new quantity on hand/i)).toBeTruthy(); + expect(document.body.textContent).not.toMatch(/"lines"/); + }); + + it("says when the quantities are being added instead of set", () => { + render( + , + ); + expect(screen.getByText(/adding these quantities/i)).toBeTruthy(); + }); + + it("leaves other tools as JSON", () => { + render( + , + ); + expect(document.body.textContent).toMatch(/"status"/); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/components/giellie/approval-card.test.tsx` + +Expected: FAIL — the card renders JSON for every tool. + +- [ ] **Step 3: Implement the renderer** + +In `src/components/giellie/approval-card.tsx`, add above the component: + +```tsx +type StockQuantityLine = { sku?: string; description?: string; qty?: number }; + +/** + * The stock quantity tool is the one tool whose input is a list the + * operator has to read line by line. JSON is not reviewable at that + * size, so it gets a table. Every other tool keeps raw JSON. + */ +export function renderToolInput(toolName: string, input: unknown): ReactNode { + if (toolName === "stock_items.set_quantities" && input && typeof input === "object") { + const payload = input as { mode?: unknown; lines?: unknown }; + const mode = typeof payload.mode === "string" ? payload.mode : ""; + const lines = Array.isArray(payload.lines) ? (payload.lines as StockQuantityLine[]) : []; + if (lines.length > 0) { + return ( +
+

+ {mode === "add" + ? "Adding these quantities to the current stock." + : "Setting these as the new quantity on hand."} +

+
+ + + + + + + + + + {lines.map((line, i) => ( + + + + + + ))} + +
SKUDescriptionQty
{line.sku ?? "—"}{line.description ?? "—"}{line.qty ?? "—"}
+
+
+ ); + } + } + return
{JSON.stringify(input, null, 2)}
; +} +``` + +Find the line in the component that renders the JSON and replace it: + +```tsx +
{JSON.stringify(tu.input, null, 2)}
+``` + +with: + +```tsx + {renderToolInput(tu.name, tu.input)} +``` + +Make sure `ReactNode` is imported. If the file does not already import it from react, add: + +```tsx +import type { ReactNode } from "react"; +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx vitest run src/components/giellie/approval-card.test.tsx` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/components/giellie/approval-card.tsx src/components/giellie/approval-card.test.tsx +git commit -m "feat(giellie): show stock quantity approvals as a table + +A 40-line stocktake in raw JSON is unreviewable. The stock quantity +tool gets a table with the mode, SKU, description and quantity. +Every other tool keeps its JSON exactly as it was." +``` + +--- + +### Task 7: The "Stock update" shortcut + +**Files:** +- Modify: `src/components/giellie/composer.tsx` +- Test: `src/components/giellie/composer.test.tsx` + +**Interfaces:** +- Consumes: the existing `onSend(text, files, onAccepted)` prop. +- Produces: a "Stock update" button that opens a paste panel and sends the pasted lines to the chat. + +The button is a shortcut only. It does **not** carry the set/add intent — the operator chose to be asked that every time, so Giellie asks it in the conversation. + +- [ ] **Step 1: Write the failing test** + +Append to `src/components/giellie/composer.test.tsx`: + +```tsx +describe("the stock update shortcut", () => { + it("sends the pasted lines to Giellie", () => { + const onSend = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("button", { name: /stock update/i })); + fireEvent.change(screen.getByPlaceholderText(/ABC-123 50/i), { + target: { value: "ABC-123 50\nBOLT-6MM 120" }, + }); + fireEvent.click(screen.getByRole("button", { name: /send to giellie/i })); + + expect(onSend).toHaveBeenCalledTimes(1); + const sent = String(onSend.mock.calls[0][0]); + expect(sent).toContain("ABC-123 50"); + expect(sent).toContain("BOLT-6MM 120"); + }); + + it("does not send an empty list", () => { + const onSend = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: /stock update/i })); + fireEvent.click(screen.getByRole("button", { name: /send to giellie/i })); + expect(onSend).not.toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/components/giellie/composer.test.tsx` + +Expected: FAIL — there is no stock update button. + +- [ ] **Step 3: Implement the panel** + +In `src/components/giellie/composer.tsx`, add state next to the existing `text` / `files` state: + +```tsx + const [stockOpen, setStockOpen] = useState(false); + const [stockText, setStockText] = useState(""); +``` + +Add the handler: + +```tsx + function sendStockList() { + const list = stockText.trim(); + if (!list || disabled) return; + onSend( + `Update the stock quantities on these lines.\n\n${list}`, + [], + () => { + setStockText(""); + setStockOpen(false); + }, + ); + } +``` + +Add the button next to the existing icon buttons, and the panel next to the textarea: + +```tsx + +``` + +```tsx + {stockOpen && ( +
+