From 22305645e75cc8c1d2b27b40bf4f0850edcadf06 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 5 Nov 2026 21:54:20 +0200 Subject: [PATCH] feat(stock): bulk quantity writes with a price-list mirror MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../stock-quantities-bulk-service.test.ts | 271 ++++++++++++ .../server/stock-quantities-bulk-service.ts | 406 ++++++++++++++++++ 2 files changed, 677 insertions(+) create mode 100644 src/lib/server/stock-quantities-bulk-service.test.ts create mode 100644 src/lib/server/stock-quantities-bulk-service.ts diff --git a/src/lib/server/stock-quantities-bulk-service.test.ts b/src/lib/server/stock-quantities-bulk-service.test.ts new file mode 100644 index 0000000..9cac01f --- /dev/null +++ b/src/lib/server/stock-quantities-bulk-service.test.ts @@ -0,0 +1,271 @@ +import { describe, it, expect, vi } from "vitest"; +import { + setStockQuantitiesBulk, + type StockQuantitiesBulkPb, +} 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> = [], + opts: { failUpdateIds?: string[] } = {}, +) { + const calls: string[] = []; + const ledgerCreates: Array> = []; + const stockUpdates: Array<{ id: string; patch: Record }> = []; + const priceListUpdates: Array<{ id: string; patch: Record }> = []; + // The quantity each row now holds, so a row that is written twice + // reads back the value it now has — the way PB behaves. + const written: Record = {}; + + const pb = { + collection: (name: string) => { + if (name === "stock_items") { + return { + getFullList: vi.fn().mockResolvedValue(STOCK_ITEMS), + getOne: vi.fn().mockImplementation(async (id: string) => { + const item = STOCK_ITEMS.find((i) => i.id === id); + return { + ...item, + current_qty: written[id] ?? item?.current_qty, + }; + }), + update: vi.fn().mockImplementation(async (id: string, patch: Record) => { + calls.push("stock_items.update"); + stockUpdates.push({ id, patch }); + if (opts.failUpdateIds?.includes(id)) { + throw new Error("PB write failed"); + } + if (typeof patch.current_qty === "number") { + written[id] = patch.current_qty; + } + 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}`); + }, + } as unknown as StockQuantitiesBulkPb; + + return { pb, calls, ledgerCreates, stockUpdates, priceListUpdates }; +} + +describe("setStockQuantitiesBulk — dry run", () => { + it("plans without writing anything", async () => { + const { pb, calls, ledgerCreates, stockUpdates, priceListUpdates } = 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(priceListUpdates).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("keeps the plan and the audit trail honest when a SKU is pasted twice", async () => { + const { pb, ledgerCreates, stockUpdates } = stubPb(); + const res = await setStockQuantitiesBulk({ + pb, + lines: [ + { sku: "M16-FLAT-WASHER", qty: 8 }, + { sku: "M16-FLAT-WASHER", qty: 3 }, + ], + mode: "add", + dryRun: false, + actor: { id: "u1", name: "Dave" }, + }); + // The second line is planned from the first line's target, not + // from the quantity we read before the batch started. + expect(res.results[0].previous_qty).toBe(12); + expect(res.results[0].new_qty).toBe(20); + expect(res.results[1].previous_qty).toBe(20); + expect(res.results[1].new_qty).toBe(23); + // One ledger row per pasted line, each with its own signed delta. + expect(ledgerCreates).toHaveLength(2); + expect(ledgerCreates[0].change_qty).toBe(8); + expect(ledgerCreates[1].change_qty).toBe(3); + expect(stockUpdates.map((u) => u.patch.current_qty)).toEqual([20, 23]); + }); + + 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("keeps the rest of the batch going when one write fails", async () => { + const { pb } = stubPb([], { failUpdateIds: ["id-def"] }); + const res = await setStockQuantitiesBulk({ + pb, + lines: [ + { sku: "M16-FLAT-WASHER", qty: 50 }, + { sku: "BOLT-M6-40", qty: 9 }, + ], + mode: "set", + dryRun: false, + actor: { id: "u1", name: "Dave" }, + }); + // The good row still landed. + expect(res.summary.applied).toBe(1); + expect(res.results[0].status).toBe("applied"); + expect(res.results[0].new_qty).toBe(50); + // The failed row is reported on its own, with the reason. + expect(res.summary.unmatched).toBe(1); + expect(res.results[1].status).toBe("unmatched"); + expect(res.results[1].message).toContain("Failed to update"); + }); + + 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); + }); +}); diff --git a/src/lib/server/stock-quantities-bulk-service.ts b/src/lib/server/stock-quantities-bulk-service.ts new file mode 100644 index 0000000..9554741 --- /dev/null +++ b/src/lib/server/stock-quantities-bulk-service.ts @@ -0,0 +1,406 @@ +/** + * 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. + * + * Two rules the whole batch follows: + * + * - Per-row independence. One unmatched or ambiguous line never + * stops the rest of the batch, and one failed write never stops + * the others. + * - A dry run writes nothing at all — no ledger row, no + * `stock_items` update, no `price_list` update. It still + * reports the full plan, so the operator can see what a real + * run would do. + * + * The matching itself lives in `stock-quantity-match.ts` (pure, no + * PocketBase) and the audited write lives in + * `stock-quantities-service.ts`. This module is the plumbing that + * joins the two. + */ + +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 client; passed in so tests can swap a stub. */ + pb: StockQuantitiesBulkPb; + /** The pasted lines, already split into sku / description / qty. */ + lines: StockQuantityLine[]; + /** "set" replaces the quantity, "add" moves it by the given amount. */ + mode: StockQuantityMode; + /** When true nothing is written — the plan is returned instead. */ + dryRun: boolean; + /** The acting user; stamped on every ledger row we write. */ + actor: { id: string; name: string }; +}; + +export type StockQuantitiesBulkRowStatus = + | "applied" + | "unchanged" + | "unmatched" + | "ambiguous"; + +export type StockQuantitiesBulkResultRow = { + /** The SKU as the operator typed it. */ + sku: string; + /** The description as the operator typed it. */ + description: string; + /** The quantity as the operator typed it, truncated to whole units. */ + qty: number; + status: StockQuantitiesBulkRowStatus; + match_method?: "sku_exact" | "description_fuzzy"; + /** The stock item the line resolved to. */ + id?: string; + name?: string; + previous_qty?: number; + new_qty?: number; + /** Signed change — negative when the quantity went down. */ + delta?: number; + /** Catalogue rows mirrored onto. Zero when the SKU has no catalogue row. */ + price_list_rows_touched?: number; + /** The equally-good candidates, when the line was ambiguous. */ + candidates?: Array<{ id: string; name: string; current_qty: number }>; + /** Why the line did not apply, in the operator's words. */ + 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; +}; + +/** One resolved line and the target we worked out for it. */ +type PlannedRow = { + line: ResolvedStockQuantityLine; + /** The quantity the item held when this line was planned. */ + current?: number; + /** The quantity the item should end up with. */ + target?: number; + /** Signed change from the quantity the item held when planned. */ + delta?: number; +}; + +/** One row of the audited write, as the write service reports it. */ +type QuantityWriteRow = + Awaited>["results"][number]; + +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. + * + * A SKU with no catalogue row is not an error — it just means the + * count of touched rows is zero. + */ +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)}"`, + }); + + // The PB filter is a superset (it also substring-matches the raw + // SKU), so narrow it here to an exact canonical match. A row counts + // when its own SKU canonicalises to the same string, or when its + // stored `sku_normalised` does — the catalogue writes both. + const rows = page.items.filter( + (r) => + normaliseSku(r.sku) === norm || + normaliseSku(r.sku_normalised) === 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), + })); +} + +/** + * Set (or add to) the stock quantity of every pasted line. + * + * Returns one result row per pasted line, in the order the lines came + * in, plus a summary for the toast. Per-row failures are reported in + * `results[]` — the function itself does not throw on a bad line. + */ +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. A paste can name the same SKU twice, so the second line + // is planned from the first line's target rather than from the + // snapshot we read — that keeps the plan and the sequential writes + // in agreement. Quantities are whole units: the resolver already + // truncated the line's qty, and the target is truncated again so a + // fractional stored quantity can never leak into the plan. + const plannedQty = new Map(); + const planned: PlannedRow[] = resolved.map((line) => { + if (line.status !== "matched" || !line.item) { + return { + line, + current: undefined, + target: undefined, + delta: undefined, + }; + } + const id = line.item.id; + const current = + plannedQty.get(id) ?? Number(line.item.current_qty ?? 0); + const target = + mode === "set" ? line.qty : Math.trunc(current + line.qty); + plannedQty.set(id, target); + return { line, current, target, delta: target - current }; + }); + + const toWrite: Array<{ id: string; currentQty: number }> = []; + // Which planned row each write belongs to. The write service answers + // in the order it was handed the rows, so the two lists line up — and + // a pasted SKU that appears twice keeps its own result per line. + const writePlanIndexes: number[] = []; + for (let i = 0; i < planned.length; i++) { + const p = planned[i]; + if (p.line.status !== "matched" || !p.line.item) continue; + if (p.target === undefined || p.delta === undefined) continue; + // A line that already holds the target needs no ledger row and no + // update — the ledger would only collect no-ops. + if (p.delta === 0) continue; + toWrite.push({ id: p.line.item.id, currentQty: p.target }); + writePlanIndexes.push(i); + } + + const writeByPlanIndex = new Map(); + if (!dryRun && toWrite.length > 0) { + const write = await updateStockItemsQuantities({ + pb: pb as unknown as StockItemsPb, + updates: toWrite, + actor, + allowNegativeQty: true, + noteContext: "via Giellie", + }); + write.results.forEach((row, i) => { + const planIndex = writePlanIndexes[i]; + if (planIndex !== undefined) writeByPlanIndex.set(planIndex, row); + }); + } + + const results: StockQuantitiesBulkResultRow[] = []; + const summary = { + total: lines.length, + applied: 0, + unchanged: 0, + unmatched: 0, + ambiguous: 0, + }; + + for (let i = 0; i < planned.length; i++) { + const p = planned[i]; + const line = p.line; + const base = { + 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 = p.current ?? Number(line.item.current_qty ?? 0); + const delta = p.delta ?? 0; + + 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; + } + + // A dry run has nothing to write, so the plan is the answer: + // "applied" here means "would be applied". + if (dryRun) { + summary.applied += 1; + 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: 0, + }); + continue; + } + + const writeRow = writeByPlanIndex.get(i); + + 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 (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, + }; +}