diff --git a/src/lib/server/stock-quantities-service.test.ts b/src/lib/server/stock-quantities-service.test.ts index b9b26ac..26f0faa 100644 --- a/src/lib/server/stock-quantities-service.test.ts +++ b/src/lib/server/stock-quantities-service.test.ts @@ -326,4 +326,73 @@ describe("updateStockItemsQuantities — multi-row batch", () => { change_qty: 5, }); }); -}); \ No newline at end of file +}); + +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: pb as never, + updates: [{ id: "abcdefghijklmno", currentQty: -1 }], + actor: { id: "u1", name: "Dave" }, + } as UpdateStockItemsQuantitiesInput); + 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: pb as never, + updates: [{ id: "abcdefghijklmno", currentQty: -3 }], + actor: { id: "u1", name: "Dave" }, + allowNegativeQty: true, + } as UpdateStockItemsQuantitiesInput); + 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: pb as never, + updates: [{ id: "abcdefghijklmno", minQty: -1 }], + actor: { id: "u1", name: "Dave" }, + allowNegativeQty: true, + } as UpdateStockItemsQuantitiesInput); + 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: pb as never, + updates: [{ id: "abcdefghijklmno", currentQty: 9 }], + actor: { id: "u1", name: "Dave" }, + } as UpdateStockItemsQuantitiesInput); + 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: pb as never, + updates: [{ id: "abcdefghijklmno", currentQty: 9 }], + actor: { id: "u1", name: "Dave" }, + noteContext: "via Giellie", + } as UpdateStockItemsQuantitiesInput); + expect(ledgerCreates[0].notes).toBe("Dave edited qty 5 → 9 via Giellie"); + }); +}); diff --git a/src/lib/server/stock-quantities-service.ts b/src/lib/server/stock-quantities-service.ts index b80044e..a8cf136 100644 --- a/src/lib/server/stock-quantities-service.ts +++ b/src/lib/server/stock-quantities-service.ts @@ -11,6 +11,12 @@ import type { StockItem } from "@/lib/types"; * trail stays honest. `min_qty` and `target_qty` are simple * patch-as-you-go fields — no ledger row. * + * Two callers share it: the /stock page (which keeps a + * non-negative floor — the operator types a physical count there) and + * the Giellie agent (which may write an absolute count below zero, + * because the till oversells on purpose). The agent opts in with + * `allowNegativeQty`; the page never does. + * * Why a separate service instead of folding into * `updateStockItemsPricing`? * @@ -46,6 +52,19 @@ 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 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" for the /stock page, and to "via Giellie" for an + * agent write (one that sets `allowNegativeQty`). + */ + noteContext?: string; }; export type StockItemsPb = { @@ -97,12 +116,21 @@ const PATCHABLE_KEYS = ["currentQty", "minQty", "targetQty"] as const; * `Math.trunc` decision in `stock/items/[id]/adjust/route.ts`. If * a future caller legitimately needs fractional (e.g. metres of * cable) they can drop the trunc and the test that pins it. + * + * `allowNegative` lifts the floor for that one call only — the agent + * writes an absolute count that can legitimately sit below zero, + * because the till oversells on purpose. `min_qty` and `target_qty` + * stay floored at zero regardless. */ -function coerceNonNegativeInt( +function coerceQty( key: string, value: unknown, + allowNegative: boolean, ): number { - if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + 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); @@ -124,6 +152,12 @@ export async function updateStockItemsQuantities( ): Promise { const results: UpdateStockItemsQuantitiesResultRow[] = []; + // Where the edit came from, for the ledger note. The /stock page is + // the default; `allowNegativeQty` is the agent-only opt-in, so an + // agent write that does not name its own context still reads as one. + const noteContext = + input.noteContext ?? (input.allowNegativeQty ? "via Giellie" : "from /stock"); + for (const update of input.updates) { // 1. Whitelist + type validation. Reject the row on any // forbidden / non-finite / negative field — partial patches @@ -148,7 +182,11 @@ export async function updateStockItemsQuantities( const value = (update as Record)[key]; if (key !== "id" && value !== undefined) { try { - coerceNonNegativeInt(key, value); + coerceQty( + key, + value, + key === "currentQty" && input.allowNegativeQty === true, + ); } catch (err) { results.push({ id: update.id, @@ -221,7 +259,7 @@ export async function updateStockItemsQuantities( change_qty: delta, reason: "adjustment", reference_type: "manual", - notes: `${input.actor.name} edited qty ${beforeCurrent} → ${requestedCurrent} from /stock`, + notes: `${input.actor.name} edited qty ${beforeCurrent} → ${requestedCurrent} ${noteContext}`, created_by: input.actor.id, }); ledgerId = (created as { id: string }).id;