WFDASH-WEBAPP/src/lib/server/stock-quantities-service.test.ts
Dave 698abe9fbc feat(stock): inline qty editing + column sort + pricing-gap operator scripts
Doubles down on the /stock list to make catalogue and inventory
edits one-click for the operator.

What's new:

- /stock: clickable column headers on Cost / Markup / Selling
  cycle none → asc → desc → none and feed through to the PB sort
  param. The apply bar now commits pricing AND qty in parallel
  (Promise.all against /set-pricing and /set-quantities) with
  per-row failure tracking and focused error toasts. In edit
  mode the row is no longer a button — inputs render in-place
  for current_qty, min_qty, and target_qty alongside the existing
  pricing inputs, and the row click is gated off so clicks on an
  input never navigate to the detail page.
- stock-quantities-service + /api/v1/stock/items/set-quantities
  (POST): bulk-update current_qty / min_qty / target_qty on a
  set of stock_items rows. Whitelist-validated patches, per-row
  results with stable failure reasons, and a stock_ledger row
  written FIRST whenever current_qty actually moves so the audit
  trail stays ahead of the cached total (same pattern as
  stock-surplus and stock-booking-service).
- scripts/apply-default-markup.ts: one-shot operator tool that
  seeds selling_price = round(cost_price * 1.80, 2) and markup = 80
  on stock_items rows whose cost_price is set but selling_price
  is zero. Bypasses STOCK_ITEM_PATCHABLE_FIELDS (operator-initiated
  bulk action); idempotent (never overwrites a non-zero shelf
  price or markup).
- scripts/list-irda-stock.ts: read-only inspector for the
  IRIDA spray-caint line with summary counts (without
  selling_price, without cost_price, zero stock) so the
  operator can see exactly which rows the backfill tools above
  will touch.

Tests: 81 new test cases across /stock page, stock-quantities
service + route, and apply-default-markup.
2026-09-14 13:04:26 +02:00

329 lines
No EOL
11 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import {
updateStockItemsQuantities,
type UpdateStockItemsQuantitiesInput,
} from "./stock-quantities-service";
/**
* Helper: build a stub PB that exposes `stock_items` (getOne +
* update) and `stock_ledger` (create). Returns the PB instance and
* an array of every ledger row written, so tests can assert on the
* audit trail.
*/
function stubPb(args: {
before: Record<string, unknown>;
updated?: Record<string, unknown>;
}) {
const ledgerCreates: Array<Record<string, unknown>> = [];
const updated = args.updated ?? { ...args.before };
return {
pb: {
collection: (name: string) => {
if (name === "stock_items") {
return {
getOne: vi.fn().mockResolvedValue(args.before),
update: vi.fn().mockResolvedValue(updated),
create: vi.fn(),
};
}
if (name === "stock_ledger") {
return {
create: vi.fn().mockImplementation(async (row) => {
ledgerCreates.push(row);
return { id: "ledger1", ...row };
}),
};
}
throw new Error(`unexpected collection ${name}`);
},
},
ledgerCreates,
} as const;
}
describe("updateStockItemsQuantities — happy path", () => {
it("patches all three qty fields and reports per-row previous", async () => {
const before = {
id: "abcdefghijklmno",
current_qty: 5,
min_qty: 2,
target_qty: 10,
};
const updated = {
...before,
current_qty: 8,
min_qty: 3,
target_qty: 12,
};
const { pb } = stubPb({ before, updated });
const result = await updateStockItemsQuantities({
pb: pb as never,
updates: [
{ id: "abcdefghijklmno", currentQty: 8, minQty: 3, targetQty: 12 },
],
actor: { id: "useropid1234567", name: "Gary" },
} as UpdateStockItemsQuantitiesInput);
expect(result.summary).toEqual({ total: 1, succeeded: 1, failed: 0 });
expect(result.results[0]).toMatchObject({
id: "abcdefghijklmno",
ok: true,
previous: { current_qty: 5, min_qty: 2, target_qty: 10 },
item: updated,
});
});
});
describe("updateStockItemsQuantities — stock_ledger audit on current_qty change", () => {
it("writes a stock_ledger row with reason=adjustment when currentQty changes", async () => {
const before = { id: "abcdefghijklmno", current_qty: 5, min_qty: 2 };
const updated = { ...before, current_qty: 8 };
const { pb, ledgerCreates } = stubPb({ before, updated });
await updateStockItemsQuantities({
pb: pb as never,
updates: [{ id: "abcdefghijklmno", currentQty: 8 }],
actor: { id: "useropid1234567", name: "Gary" },
} as UpdateStockItemsQuantitiesInput);
expect(ledgerCreates).toEqual([
expect.objectContaining({
stock_item: "abcdefghijklmno",
// delta = 8 - 5 = +3
change_qty: 3,
reason: "adjustment",
reference_type: "manual",
notes: expect.stringMatching(/edited qty 5 → 8 from \/stock/),
created_by: "useropid1234567",
}),
]);
});
it("writes a NEGATIVE change_qty when current_qty is decremented", async () => {
const before = { id: "abcdefghijklmno", current_qty: 50, min_qty: 5 };
const updated = { ...before, current_qty: 47 };
const { pb, ledgerCreates } = stubPb({ before, updated });
await updateStockItemsQuantities({
pb: pb as never,
updates: [{ id: "abcdefghijklmno", currentQty: 47 }],
actor: { id: "u1", name: "Gary" },
} as UpdateStockItemsQuantitiesInput);
expect(ledgerCreates).toEqual([
expect.objectContaining({
change_qty: -3,
notes: expect.stringMatching(/edited qty 50 → 47/),
}),
]);
});
it("does NOT write a stock_ledger row when only min/target change (no current_qty movement)", async () => {
const before = { id: "abcdefghijklmno", current_qty: 5, min_qty: 2, target_qty: 10 };
const updated = { ...before, min_qty: 3, target_qty: 12 };
const { pb, ledgerCreates } = stubPb({ before, updated });
await updateStockItemsQuantities({
pb: pb as never,
updates: [{ id: "abcdefghijklmno", minQty: 3, targetQty: 12 }],
actor: { id: "u1", name: "Gary" },
} as UpdateStockItemsQuantitiesInput);
expect(ledgerCreates).toEqual([]);
});
it("does NOT write a stock_ledger row when currentQty is supplied but matches existing value", async () => {
const before = { id: "abcdefghijklmno", current_qty: 5, min_qty: 2 };
const { pb, ledgerCreates } = stubPb({ before });
await updateStockItemsQuantities({
pb: pb as never,
updates: [{ id: "abcdefghijklmno", currentQty: 5 }],
actor: { id: "u1", name: "Gary" },
} as UpdateStockItemsQuantitiesInput);
expect(ledgerCreates).toEqual([]);
});
});
describe("updateStockItemsQuantities — validation", () => {
it("rejects the row when a forbidden field is included", async () => {
const before = { id: "abcdefghijklmno", current_qty: 5 };
const { pb } = stubPb({ before });
const result = await updateStockItemsQuantities({
pb: pb as never,
updates: [
// `selling_price` is not on the whitelist for this service.
{ id: "abcdefghijklmno", currentQty: 8, sellingPrice: 99 } as never,
],
actor: { id: "u1", name: "Gary" },
} as UpdateStockItemsQuantitiesInput);
expect(result.summary.failed).toBe(1);
expect(result.results[0]).toMatchObject({
ok: false,
reason: "forbidden_field",
});
});
it("rejects negative numbers", async () => {
const before = { id: "abcdefghijklmno", current_qty: 5 };
const { pb } = stubPb({ before });
const result = await updateStockItemsQuantities({
pb: pb as never,
updates: [{ id: "abcdefghijklmno", currentQty: -1 }],
actor: { id: "u1", name: "Gary" },
} as UpdateStockItemsQuantitiesInput);
expect(result.results[0]).toMatchObject({
ok: false,
reason: "validation_error",
});
});
it("rejects non-integer current_qty (fractions are truncated, not rejected)", async () => {
const before = { id: "abcdefghijklmno", current_qty: 5 };
const updated = { ...before, current_qty: 8 };
const { pb, ledgerCreates } = stubPb({ before, updated });
const result = await updateStockItemsQuantities({
pb: pb as never,
updates: [{ id: "abcdefghijklmno", currentQty: 8.9 }],
actor: { id: "u1", name: "Gary" },
} as UpdateStockItemsQuantitiesInput);
expect(result.results[0]?.ok).toBe(true);
// 8.9 → 8, delta = 8 - 5 = +3.
expect(ledgerCreates[0]).toMatchObject({ change_qty: 3 });
});
it("returns not_found when the row doesn't exist", async () => {
const pb = {
collection: (name: string) => {
if (name === "stock_items") {
return {
getOne: vi.fn().mockRejectedValue(new Error("404 not found")),
update: vi.fn(),
create: vi.fn(),
};
}
if (name === "stock_ledger") {
return { create: vi.fn() };
}
throw new Error(`unexpected ${name}`);
},
};
const result = await updateStockItemsQuantities({
pb: pb as never,
updates: [{ id: "abcdefghijklmno", currentQty: 8 }],
actor: { id: "u1", name: "Gary" },
} as UpdateStockItemsQuantitiesInput);
expect(result.results[0]).toMatchObject({
ok: false,
reason: "not_found",
});
});
it("returns pb_error when the stock_items update fails", async () => {
const before = { id: "abcdefghijklmno", current_qty: 5 };
const pb = {
collection: (name: string) => {
if (name === "stock_items") {
return {
getOne: vi.fn().mockResolvedValue(before),
update: vi.fn().mockRejectedValue(new Error("PB down")),
create: vi.fn(),
};
}
if (name === "stock_ledger") {
return { create: vi.fn() };
}
throw new Error(`unexpected ${name}`);
},
};
const result = await updateStockItemsQuantities({
pb: pb as never,
updates: [{ id: "abcdefghijklmno", currentQty: 8 }],
actor: { id: "u1", name: "Gary" },
} as UpdateStockItemsQuantitiesInput);
expect(result.results[0]).toMatchObject({
ok: false,
reason: "pb_error",
});
});
});
describe("updateStockItemsQuantities — multi-row batch", () => {
it("processes each row independently — one failure doesn't block others", async () => {
const stockById: Record<string, Record<string, unknown>> = {
siok1aaaaaaaaaaa: { id: "siok1aaaaaaaaaaa", current_qty: 5, min_qty: 2 },
sibad2aaaaaaaaa: { id: "sibad2aaaaaaaaa", current_qty: 5, min_qty: 2 },
};
const ledgerCreates: Array<Record<string, unknown>> = [];
const pb = {
collection: (name: string) => {
if (name === "stock_items") {
return {
getOne: vi.fn().mockImplementation(async (id: string) => {
if (!stockById[id]) throw new Error("404 not found");
return stockById[id];
}),
update: vi.fn().mockImplementation(async (id: string, patch: Record<string, unknown>) => {
// The second row's update blows up.
if (id === "sibad2aaaaaaaaa") throw new Error("PB write failed");
stockById[id] = { ...stockById[id], ...patch };
return stockById[id];
}),
create: vi.fn(),
};
}
if (name === "stock_ledger") {
return {
create: vi.fn().mockImplementation(async (row) => {
ledgerCreates.push(row);
return { id: "ledger1", ...row };
}),
};
}
throw new Error(`unexpected ${name}`);
},
};
const result = await updateStockItemsQuantities({
pb: pb as never,
updates: [
{ id: "siok1aaaaaaaaaaa", currentQty: 10 },
{ id: "sibad2aaaaaaaaa", currentQty: 10 },
],
actor: { id: "u1", name: "Gary" },
} as UpdateStockItemsQuantitiesInput);
expect(result.summary).toEqual({ total: 2, succeeded: 1, failed: 1 });
expect(result.results[0]?.ok).toBe(true);
expect(result.results[1]?.ok).toBe(false);
expect((result.results[1] as { reason?: string }).reason).toBe("pb_error");
// The service writes the ledger row BEFORE the cache update
// (audit-before-cache, same pattern as stock-surplus /
// stock-booking-service), so the bad row's ledger entry lands
// before the stock_items update fails. Two ledger rows are
// the correct outcome — the second one is a "drift" that a
// future drift-repair job can rebuild current_qty from.
// Operators see the failure in the toast and can re-apply.
expect(ledgerCreates).toHaveLength(2);
expect(ledgerCreates[0]).toMatchObject({
stock_item: "siok1aaaaaaaaaaa",
change_qty: 5,
});
expect(ledgerCreates[1]).toMatchObject({
stock_item: "sibad2aaaaaaaaa",
change_qty: 5,
});
});
});