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.
This commit is contained in:
parent
a8958abc70
commit
00fbada312
2 changed files with 112 additions and 5 deletions
|
|
@ -326,4 +326,73 @@ describe("updateStockItemsQuantities — multi-row batch", () => {
|
|||
change_qty: 5,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<UpdateStockItemsQuantitiesResult> {
|
||||
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<string, unknown>)[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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue