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.
329 lines
No EOL
11 KiB
TypeScript
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,
|
|
});
|
|
});
|
|
}); |