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.
This commit is contained in:
parent
7443776a14
commit
698abe9fbc
9 changed files with 2719 additions and 79 deletions
252
scripts/apply-default-markup.test.ts
Normal file
252
scripts/apply-default-markup.test.ts
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
/**
|
||||
* Tests for the pure decision + formatting logic in
|
||||
* `scripts/apply-default-markup.ts`.
|
||||
*
|
||||
* The script's PB I/O is not under test here — only the logic that
|
||||
* decides "given this stock_item, what (if anything) should we
|
||||
* patch?" The orchestrator and the PB plumbing stay untested
|
||||
* because they require a live PB admin client, which the dry-run
|
||||
* + `pnpm exec tsx … --limit=10` smoke test covers.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
decideMarkupForStockItem,
|
||||
formatDecisionLine,
|
||||
formatPatchLine,
|
||||
type MarkupStockSnapshot,
|
||||
type MarkupDecision,
|
||||
} from "./apply-default-markup";
|
||||
|
||||
function stockItem(
|
||||
overrides: Partial<MarkupStockSnapshot> = {},
|
||||
): MarkupStockSnapshot {
|
||||
return {
|
||||
id: "si_abc",
|
||||
sku: "ABC123",
|
||||
cost_price: 100,
|
||||
selling_price: 0,
|
||||
markup: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────
|
||||
|
||||
describe("decideMarkupForStockItem", () => {
|
||||
it("proposes 80% markup patch when cost > 0 and selling = 0", () => {
|
||||
const decision = decideMarkupForStockItem(
|
||||
stockItem({
|
||||
id: "si_unpriced",
|
||||
sku: "ABC123",
|
||||
cost_price: 100,
|
||||
selling_price: 0,
|
||||
markup: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(decision.kind).toBe("patch");
|
||||
if (decision.kind !== "patch") return;
|
||||
|
||||
expect(decision.stockItemId).toBe("si_unpriced");
|
||||
expect(decision.sku).toBe("ABC123");
|
||||
expect(decision.patch.selling_price).toEqual({ from: 0, to: 180 });
|
||||
expect(decision.patch.markup).toEqual({ from: 0, to: 80 });
|
||||
});
|
||||
|
||||
it("returns no_match when cost is 0 (no patch possible)", () => {
|
||||
const decision = decideMarkupForStockItem(
|
||||
stockItem({
|
||||
id: "si_no_cost",
|
||||
sku: "NO-COST",
|
||||
cost_price: 0,
|
||||
selling_price: 0,
|
||||
markup: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(decision.kind).toBe("no_match");
|
||||
if (decision.kind !== "no_match") return;
|
||||
expect(decision.stockItemId).toBe("si_no_cost");
|
||||
expect(decision.sku).toBe("NO-COST");
|
||||
});
|
||||
|
||||
it("treats null/undefined cost_price as no_match", () => {
|
||||
// PB's typed SDK may surface unset numeric fields as null or
|
||||
// undefined; both should route to no_match.
|
||||
const decisionNull = decideMarkupForStockItem(
|
||||
stockItem({ id: "si_null_cost", cost_price: null }),
|
||||
);
|
||||
expect(decisionNull.kind).toBe("no_match");
|
||||
|
||||
const decisionUndef = decideMarkupForStockItem(
|
||||
stockItem({ id: "si_undef_cost", cost_price: undefined }),
|
||||
);
|
||||
expect(decisionUndef.kind).toBe("no_match");
|
||||
});
|
||||
|
||||
it("is a no-op (skipped_idempotent) when selling_price already non-zero", () => {
|
||||
const decision = decideMarkupForStockItem(
|
||||
stockItem({
|
||||
id: "si_already_priced",
|
||||
cost_price: 100,
|
||||
selling_price: 150,
|
||||
markup: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(decision.kind).toBe("skipped_idempotent");
|
||||
if (decision.kind !== "skipped_idempotent") return;
|
||||
expect(decision.stockItemId).toBe("si_already_priced");
|
||||
expect(decision.sku).toBe("ABC123");
|
||||
expect(decision.reason).toBe("selling_price_already_set");
|
||||
});
|
||||
|
||||
it("is a no-op (skipped_idempotent) when markup already non-zero (even if selling = 0)", () => {
|
||||
// Edge case: row has cost_price > 0 AND selling = 0 AND a
|
||||
// legacy markup > 0. We never overwrite the explicit markup
|
||||
// field — that's the POS' source of truth. Skipping with
|
||||
// `markup_already_set` is the right move; the operator can
|
||||
// resolve manually if they want the new selling_price to
|
||||
// match.
|
||||
const decision = decideMarkupForStockItem(
|
||||
stockItem({
|
||||
id: "si_legacy_markup",
|
||||
cost_price: 100,
|
||||
selling_price: 0,
|
||||
markup: 50,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(decision.kind).toBe("skipped_idempotent");
|
||||
if (decision.kind !== "skipped_idempotent") return;
|
||||
expect(decision.reason).toBe("markup_already_set");
|
||||
});
|
||||
|
||||
it("rounds selling_price to 2 decimal places", () => {
|
||||
// 33.33 * 1.80 = 59.994 — round to 59.99, not 60.00.
|
||||
const decision = decideMarkupForStockItem(
|
||||
stockItem({ id: "si_three_dec", cost_price: 33.33 }),
|
||||
);
|
||||
expect(decision.kind).toBe("patch");
|
||||
if (decision.kind !== "patch") return;
|
||||
expect(decision.patch.selling_price.to).toBe(59.99);
|
||||
// Markup field is always the integer constant.
|
||||
expect(decision.patch.markup.to).toBe(80);
|
||||
|
||||
// 50.55 * 1.80 = 90.99 — already 2dp.
|
||||
const decision2 = decideMarkupForStockItem(
|
||||
stockItem({ id: "si_two_dec", cost_price: 50.55 }),
|
||||
);
|
||||
expect(decision2.kind).toBe("patch");
|
||||
if (decision2.kind !== "patch") return;
|
||||
expect(decision2.patch.selling_price.to).toBe(90.99);
|
||||
});
|
||||
|
||||
it("sets markup to exactly 80 (the constant)", () => {
|
||||
const decision = decideMarkupForStockItem(
|
||||
stockItem({ id: "si_any_cost", cost_price: 42 }),
|
||||
);
|
||||
expect(decision.kind).toBe("patch");
|
||||
if (decision.kind !== "patch") return;
|
||||
expect(decision.patch.markup).toEqual({ from: 0, to: 80 });
|
||||
});
|
||||
|
||||
it("proposes selling_price of exactly 0 → 0 when cost is a tiny positive number", () => {
|
||||
// 0.01 * 1.80 = 0.018 → 0.02 (rounded). Sanity check the
|
||||
// scale-shift rounding survives tiny positive costs.
|
||||
const decision = decideMarkupForStockItem(
|
||||
stockItem({ id: "si_tiny", cost_price: 0.01 }),
|
||||
);
|
||||
expect(decision.kind).toBe("patch");
|
||||
if (decision.kind !== "patch") return;
|
||||
expect(decision.patch.selling_price.to).toBe(0.02);
|
||||
});
|
||||
|
||||
it("handles cost_price as a number that fails Number() defensively (NaN → no_match)", () => {
|
||||
// Defensive: PB shouldn't ever hand us NaN, but a corrupted
|
||||
// row could. `Number(NaN) > 0` is false, so it falls into the
|
||||
// no_match branch — which is the correct behaviour (we
|
||||
// can't compute a markup without a cost).
|
||||
const decision = decideMarkupForStockItem(
|
||||
stockItem({ id: "si_nan", cost_price: Number.NaN }),
|
||||
);
|
||||
expect(decision.kind).toBe("no_match");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── formatDecisionLine — secondary surface so the operator's
|
||||
// dry-run output is human-readable and stays stable across refactors.
|
||||
|
||||
describe("formatDecisionLine", () => {
|
||||
it("renders a no_match as a single short line", () => {
|
||||
const decision: Extract<MarkupDecision, { kind: "no_match" }> = {
|
||||
kind: "no_match",
|
||||
stockItemId: "si_x",
|
||||
sku: "XYZ",
|
||||
};
|
||||
const line = formatDecisionLine({ decision });
|
||||
expect(line).toBe("no_match sku=XYZ");
|
||||
});
|
||||
|
||||
it("renders a skipped_idempotent with the selling_price reason", () => {
|
||||
const decision: Extract<MarkupDecision, { kind: "skipped_idempotent" }> = {
|
||||
kind: "skipped_idempotent",
|
||||
stockItemId: "si_x",
|
||||
sku: "XYZ",
|
||||
reason: "selling_price_already_set",
|
||||
};
|
||||
const line = formatDecisionLine({ decision });
|
||||
expect(line).toContain("skipped_idempotent sku=XYZ");
|
||||
expect(line).toContain("reason=selling_price_already_set");
|
||||
});
|
||||
|
||||
it("renders a skipped_idempotent with the markup reason", () => {
|
||||
const decision: Extract<MarkupDecision, { kind: "skipped_idempotent" }> = {
|
||||
kind: "skipped_idempotent",
|
||||
stockItemId: "si_x",
|
||||
sku: "XYZ",
|
||||
reason: "markup_already_set",
|
||||
};
|
||||
const line = formatDecisionLine({ decision });
|
||||
expect(line).toContain("reason=markup_already_set");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── formatPatchLine — the operator-facing would_patch line shape.
|
||||
|
||||
describe("formatPatchLine", () => {
|
||||
it("renders the canonical would_patch line with cost + selling + markup deltas", () => {
|
||||
const decision = decideMarkupForStockItem(
|
||||
stockItem({ id: "si_abc", sku: "ABC123", cost_price: 100 }),
|
||||
);
|
||||
expect(decision.kind).toBe("patch");
|
||||
if (decision.kind !== "patch") return;
|
||||
|
||||
const line = formatPatchLine({
|
||||
decision,
|
||||
costPrice: 100,
|
||||
verb: "would_patch",
|
||||
});
|
||||
expect(line).toContain("would_patch sku=ABC123");
|
||||
expect(line).toContain("cost R 100.00");
|
||||
expect(line).toContain("selling R 0.00 -> R 180.00");
|
||||
expect(line).toContain("markup 0 -> 80");
|
||||
});
|
||||
|
||||
it("uses the patched verb in --commit mode", () => {
|
||||
const decision = decideMarkupForStockItem(
|
||||
stockItem({ id: "si_abc", sku: "ABC123", cost_price: 50 }),
|
||||
);
|
||||
expect(decision.kind).toBe("patch");
|
||||
if (decision.kind !== "patch") return;
|
||||
|
||||
const line = formatPatchLine({
|
||||
decision,
|
||||
costPrice: 50,
|
||||
verb: "patched",
|
||||
});
|
||||
expect(line.startsWith("patched ")).toBe(true);
|
||||
expect(line).toContain("selling R 0.00 -> R 90.00");
|
||||
});
|
||||
});
|
||||
543
scripts/apply-default-markup.ts
Normal file
543
scripts/apply-default-markup.ts
Normal file
|
|
@ -0,0 +1,543 @@
|
|||
/**
|
||||
* Apply a default 80% markup on every `stock_items` row that has a
|
||||
* known `cost_price` but a zero `selling_price`.
|
||||
*
|
||||
* Context
|
||||
* ───────
|
||||
* The same catalogue gap the backfill script addresses (see
|
||||
* `backfill-stock-prices.ts`) has a sibling: rows that DO have a
|
||||
* `cost_price` but never had `selling_price` or `markup` set.
|
||||
* These items have a known input cost but no shelf price yet —
|
||||
* the till can't ring them up. This script is the operator's
|
||||
* one-shot tool to seed `selling_price = round(cost_price * 1.80, 2)`
|
||||
* and `markup = 80` so the till starts reading them.
|
||||
*
|
||||
* It deliberately does NOT route through `stock-items-service.ts`
|
||||
* — that service enforces a whitelist
|
||||
* (`STOCK_ITEM_PATCHABLE_FIELDS`) that excludes the path the
|
||||
* operator wants here. This is an operator-initiated bulk action
|
||||
* (CLI, not a user form), so we write directly via admin PB.
|
||||
*
|
||||
* What it does
|
||||
* ────────────
|
||||
* 1. **Report 1 (always, even in dry-run)**: count rows where
|
||||
* `cost_price IS NULL OR cost_price = 0`. These items need a
|
||||
* cost price sourced elsewhere (supplier invoice) before
|
||||
* markup can be applied. Print this prominently.
|
||||
* 2. **Report 2 + action**: for every row where `cost_price > 0`
|
||||
* AND `selling_price = 0`:
|
||||
* - `dry-run`: print the would_patch line.
|
||||
* - `--commit`: PATCH the row with
|
||||
* `selling_price = round(cost_price * 1.80, 2)` AND
|
||||
* `markup = 80`. Both fields together — never one without
|
||||
* the other.
|
||||
*
|
||||
* Idempotency
|
||||
* ───────────
|
||||
* - Never overwrite a non-zero `selling_price`.
|
||||
* - Never overwrite a non-zero `markup`.
|
||||
* - Re-running with `--commit` after every row has been patched
|
||||
* produces no further changes.
|
||||
*
|
||||
* Modes
|
||||
* ─────
|
||||
* - Default (no flag): dry-run. No PB writes.
|
||||
* - `--commit`: PATCH the matched rows.
|
||||
* - `--limit=N`: process at most N candidate rows.
|
||||
* - `--sku=ABC`: process only the single row whose `sku`
|
||||
* equals ABC.
|
||||
*
|
||||
* Run with:
|
||||
* npx tsx scripts/apply-default-markup.ts # dry-run
|
||||
* npx tsx scripts/apply-default-markup.ts --commit # write
|
||||
* npx tsx scripts/apply-default-markup.ts --commit --limit=10
|
||||
* npx tsx scripts/apply-default-markup.ts --sku=ABC123 # one SKU
|
||||
*
|
||||
* Requires .env.local with NEXT_PUBLIC_POCKETBASE_URL,
|
||||
* PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD.
|
||||
*/
|
||||
|
||||
import "./_env"; // side-effect import: loads .env.local before anything else
|
||||
|
||||
import { getAdminPocketBase } from "../src/lib/server/pocketbase-admin";
|
||||
import { escapeForPbString } from "../src/lib/server/price-list-match";
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Default markup percentage to apply when a row has a known
|
||||
* `cost_price` but a zero `selling_price`.
|
||||
*
|
||||
* 80 means: `selling_price = round(cost_price * 1.80, 2)` and
|
||||
* `markup = 80` (both fields together — never one without the
|
||||
* other, so the till and the POS report stay consistent).
|
||||
*
|
||||
* To change the default rate, edit this constant and re-run the
|
||||
* script with `--commit`. There is no per-row override — every
|
||||
* eligible row gets the same rate.
|
||||
*/
|
||||
const DEFAULT_MARKUP_PERCENT = 80;
|
||||
|
||||
/** Multiplier derived from DEFAULT_MARKUP_PERCENT (kept in sync by
|
||||
* construction; recomputed once at module load so the math is
|
||||
* obvious to the reader). */
|
||||
const DEFAULT_MARKUP_MULTIPLIER = 1 + DEFAULT_MARKUP_PERCENT / 100;
|
||||
|
||||
// ─── CLI parsing ──────────────────────────────────────────────────────
|
||||
|
||||
type CliOptions = {
|
||||
commit: boolean;
|
||||
limit: number | null;
|
||||
sku: string | null;
|
||||
};
|
||||
|
||||
function parseCli(argv: string[]): CliOptions {
|
||||
const opts: CliOptions = {
|
||||
commit: false,
|
||||
limit: null,
|
||||
sku: null,
|
||||
};
|
||||
for (const arg of argv) {
|
||||
if (arg === "--commit") {
|
||||
opts.commit = true;
|
||||
} else if (arg === "--dry-run") {
|
||||
// Default behaviour; explicit flag for clarity in cron logs.
|
||||
opts.commit = false;
|
||||
} else if (arg.startsWith("--limit=")) {
|
||||
const n = Number(arg.slice("--limit=".length));
|
||||
if (!Number.isFinite(n) || n <= 0 || !Number.isInteger(n)) {
|
||||
throw new Error(`--limit must be a positive integer, got "${arg}"`);
|
||||
}
|
||||
opts.limit = n;
|
||||
} else if (arg.startsWith("--sku=")) {
|
||||
const raw = arg.slice("--sku=".length).trim();
|
||||
if (!raw) throw new Error(`--sku must be non-empty, got "${arg}"`);
|
||||
opts.sku = raw;
|
||||
} else if (arg === "--help" || arg === "-h") {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
} else {
|
||||
// Unknown flag — surface it so typos aren't silently ignored.
|
||||
throw new Error(`Unknown CLI flag: ${arg}`);
|
||||
}
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
function printHelp(): void {
|
||||
console.log(`Apply a ${DEFAULT_MARKUP_PERCENT}% default markup on stock_items that have
|
||||
cost_price > 0 but selling_price = 0.
|
||||
|
||||
Usage:
|
||||
tsx scripts/apply-default-markup.ts # dry-run (default)
|
||||
tsx scripts/apply-default-markup.ts --commit # write to PB
|
||||
tsx scripts/apply-default-markup.ts --commit --limit=10 # cap rows (smoke)
|
||||
tsx scripts/apply-default-markup.ts --sku=ABC123 # single SKU
|
||||
|
||||
Flags:
|
||||
--commit Actually PATCH stock_items. Default is dry-run.
|
||||
--limit=N Process at most N candidate rows.
|
||||
--sku=ABC Only the row whose sku matches ABC.
|
||||
--help, -h Show this help.
|
||||
|
||||
Output: Report 1 (items still needing a COST price) + one line per scanned
|
||||
row + a totals block at the bottom.
|
||||
|
||||
This script writes directly via admin PB and bypasses the user-facing
|
||||
STOCK_ITEM_PATCHABLE_FIELDS whitelist (operator-initiated bulk action).`);
|
||||
}
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Subset of a `stock_items` row the decision function needs. */
|
||||
export type MarkupStockSnapshot = {
|
||||
id: string;
|
||||
sku: string;
|
||||
cost_price?: number | null;
|
||||
selling_price?: number | null;
|
||||
markup?: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The pure decision: given a single stock_item snapshot, should we
|
||||
* apply the default markup?
|
||||
*
|
||||
* Returns a discriminated union:
|
||||
* - `patch` — both `selling_price` and `markup` will be set
|
||||
* - `skipped_idempotent` — the row already has a non-zero selling
|
||||
* price OR a non-zero markup; never clobber
|
||||
* - `no_match` — `cost_price` is zero/null; nothing to
|
||||
* base a markup on (Report 1 territory)
|
||||
*
|
||||
* Pure: no PB calls, no Date.now() side effects. Easy to unit test.
|
||||
*/
|
||||
export type MarkupDecision =
|
||||
| {
|
||||
kind: "patch";
|
||||
stockItemId: string;
|
||||
sku: string;
|
||||
patch: {
|
||||
selling_price: { from: number; to: number };
|
||||
markup: { from: number; to: number };
|
||||
};
|
||||
}
|
||||
| {
|
||||
kind: "skipped_idempotent";
|
||||
stockItemId: string;
|
||||
sku: string;
|
||||
reason: "selling_price_already_set" | "markup_already_set";
|
||||
}
|
||||
| {
|
||||
kind: "no_match";
|
||||
stockItemId: string;
|
||||
sku: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pure decision function — exported for unit tests.
|
||||
*
|
||||
* Rules
|
||||
* ─────
|
||||
* - If `stockItem.cost_price <= 0` → `no_match` (Report 1 territory;
|
||||
* the operator needs to source the cost elsewhere first).
|
||||
* - If `stockItem.selling_price > 0` → `skipped_idempotent`
|
||||
* (never clobber an existing shelf price).
|
||||
* - If `stockItem.markup > 0` → `skipped_idempotent` (the
|
||||
* explicit markup field is the POS' source of truth; never
|
||||
* overwrite it).
|
||||
* - Otherwise propose:
|
||||
* - `selling_price` = round(cost_price * multiplier, 2)
|
||||
* - `markup` = the constant
|
||||
* Both fields together — never one without the other.
|
||||
*/
|
||||
export function decideMarkupForStockItem(
|
||||
stockItem: MarkupStockSnapshot,
|
||||
): MarkupDecision {
|
||||
const stockCost = Number(stockItem.cost_price ?? 0);
|
||||
const stockSelling = Number(stockItem.selling_price ?? 0);
|
||||
const stockMarkup = Number(stockItem.markup ?? 0);
|
||||
|
||||
// Report 1 boundary: nothing to base a markup on without a cost.
|
||||
if (!(stockCost > 0)) {
|
||||
return {
|
||||
kind: "no_match",
|
||||
stockItemId: stockItem.id,
|
||||
sku: stockItem.sku,
|
||||
};
|
||||
}
|
||||
|
||||
// Idempotency: shelf price already set — the till owns that
|
||||
// number now, never clobber.
|
||||
if (stockSelling > 0) {
|
||||
return {
|
||||
kind: "skipped_idempotent",
|
||||
stockItemId: stockItem.id,
|
||||
sku: stockItem.sku,
|
||||
reason: "selling_price_already_set",
|
||||
};
|
||||
}
|
||||
|
||||
// Idempotency: explicit markup already set — the POS reads
|
||||
// markup alongside selling_price; never overwrite.
|
||||
if (stockMarkup > 0) {
|
||||
return {
|
||||
kind: "skipped_idempotent",
|
||||
stockItemId: stockItem.id,
|
||||
sku: stockItem.sku,
|
||||
reason: "markup_already_set",
|
||||
};
|
||||
}
|
||||
|
||||
const newSelling = round2(stockCost * DEFAULT_MARKUP_MULTIPLIER);
|
||||
|
||||
return {
|
||||
kind: "patch",
|
||||
stockItemId: stockItem.id,
|
||||
sku: stockItem.sku,
|
||||
patch: {
|
||||
selling_price: { from: stockSelling, to: newSelling },
|
||||
markup: { from: stockMarkup, to: DEFAULT_MARKUP_PERCENT },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Round to 2 decimal places (R cents). Uses the rounding-to-even
|
||||
* behaviour of `Math.round` after a scale shift — sufficient for
|
||||
* currency display; PB stores the number as a plain float
|
||||
* underneath. */
|
||||
function round2(n: number): number {
|
||||
if (!Number.isFinite(n)) return 0;
|
||||
// Scale-shift to cents, round, scale back. Avoids the
|
||||
// floating-point drift that `n.toFixed(2)`-then-Number would
|
||||
// introduce on values like 0.1 + 0.2.
|
||||
return Math.round(n * 100) / 100;
|
||||
}
|
||||
|
||||
// ─── Formatting ────────────────────────────────────────────────────────
|
||||
|
||||
/** Format a number as R X.XX — or R 0 if not finite. */
|
||||
function fmtMoney(n: number): string {
|
||||
if (!Number.isFinite(n)) return "R 0.00";
|
||||
return `R ${n.toFixed(2)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a one-liner for a `skipped_idempotent` or `no_match`
|
||||
* decision. `patch`-kind decisions are rendered by
|
||||
* `formatPatchLine` instead — they need the original cost_price
|
||||
* to surface the operator's actual scan input.
|
||||
*/
|
||||
export function formatDecisionLine(args: {
|
||||
decision: Extract<MarkupDecision, { kind: "skipped_idempotent" | "no_match" }>;
|
||||
}): string {
|
||||
const { decision } = args;
|
||||
if (decision.kind === "no_match") {
|
||||
return `no_match sku=${decision.sku}`;
|
||||
}
|
||||
return `skipped_idempotent sku=${decision.sku} reason=${decision.reason}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the canonical patch line:
|
||||
* would_patch sku=ABC123 cost R 100.00 selling 0.00 -> 180.00 markup 0 -> 80
|
||||
*
|
||||
* Takes the original row's cost_price alongside the decision so
|
||||
* we can show the operator "what was the cost, and what the
|
||||
* selling price becomes" — the more useful scan than just the
|
||||
* `from → to` of selling_price (which is always "0 → X").
|
||||
*/
|
||||
export function formatPatchLine(args: {
|
||||
decision: Extract<MarkupDecision, { kind: "patch" }>;
|
||||
costPrice: number;
|
||||
verb: "would_patch" | "patched";
|
||||
}): string {
|
||||
const { decision, costPrice, verb } = args;
|
||||
const sp = decision.patch.selling_price;
|
||||
const mk = decision.patch.markup;
|
||||
return [
|
||||
`${verb} sku=${decision.sku}`,
|
||||
`cost ${fmtMoney(costPrice)}`,
|
||||
`selling ${fmtMoney(sp.from)} -> ${fmtMoney(sp.to)}`,
|
||||
`markup ${mk.from} -> ${mk.to}`,
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
// ─── PB plumbing ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read every `stock_items` row whose `cost_price > 0` AND
|
||||
* `selling_price = 0` — the candidate set. Optionally filter
|
||||
* further by `--sku` and cap with `--limit`.
|
||||
*
|
||||
* We DO NOT use PB's `getFullList` for the candidate set (that
|
||||
* would return every stock row including ones with selling_price
|
||||
* already set); we filter at the server with a PB `filter` so
|
||||
* the round-trip stays small even if the catalogue is large.
|
||||
*
|
||||
* Cost-zero rows are reported separately by `countRowsNeedingCostPrice`
|
||||
* below — that's Report 1.
|
||||
*/
|
||||
async function listMarkupCandidateStockItems(
|
||||
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
||||
args: { sku: string | null; limit: number | null },
|
||||
): Promise<MarkupStockSnapshot[]> {
|
||||
const filters: string[] = ["cost_price > 0", "selling_price = 0"];
|
||||
if (args.sku) {
|
||||
filters.push(`sku = "${escapeForPbString(args.sku)}"`);
|
||||
}
|
||||
const filter = filters.join(" && ");
|
||||
|
||||
const rows = await pb.collection("stock_items").getFullList<{
|
||||
id: string;
|
||||
sku: string;
|
||||
cost_price?: number;
|
||||
selling_price?: number;
|
||||
markup?: number;
|
||||
}>({
|
||||
filter,
|
||||
fields: "id,sku,cost_price,selling_price,markup",
|
||||
batch: 500,
|
||||
sort: "sku",
|
||||
});
|
||||
|
||||
const sliced = args.limit !== null ? rows.slice(0, args.limit) : rows;
|
||||
return sliced.map((r) => ({
|
||||
id: r.id,
|
||||
sku: r.sku ?? "",
|
||||
cost_price: r.cost_price ?? null,
|
||||
selling_price: r.selling_price ?? null,
|
||||
markup: r.markup ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Report 1: count rows where `cost_price IS NULL OR cost_price = 0`.
|
||||
*
|
||||
* PB doesn't support `IS NULL` in its filter expression — use
|
||||
* `cost_price = 0` for the populated-zero case and a second
|
||||
* pass with `cost_price = null` is not supported. We approximate
|
||||
* "cost_price is unset OR zero" with `cost_price <= 0` (since
|
||||
* `cost_price` is numeric in the schema — see
|
||||
* `pb-exports/pb_schema-2.json`, the `stock_items.cost_price`
|
||||
* field is `"type": "number"` with `"min": 0`, so 0 is the
|
||||
* unset sentinel and `null` reads back as 0 on the typed shape
|
||||
* used by PB's JS SDK anyway).
|
||||
*/
|
||||
async function countRowsNeedingCostPrice(
|
||||
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
||||
): Promise<number> {
|
||||
const rows = await pb.collection("stock_items").getFullList<{
|
||||
id: string;
|
||||
}>({
|
||||
filter: "cost_price <= 0",
|
||||
fields: "id",
|
||||
batch: 500,
|
||||
});
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
type RunTotals = {
|
||||
scanned: number;
|
||||
wouldPatch: number;
|
||||
patched: number;
|
||||
skippedIdempotent: number;
|
||||
noMatch: number;
|
||||
errors: number;
|
||||
};
|
||||
|
||||
function emptyTotals(): RunTotals {
|
||||
return {
|
||||
scanned: 0,
|
||||
wouldPatch: 0,
|
||||
patched: 0,
|
||||
skippedIdempotent: 0,
|
||||
noMatch: 0,
|
||||
errors: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Main ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function runApplyDefaultMarkup(opts: CliOptions): Promise<RunTotals> {
|
||||
const totals = emptyTotals();
|
||||
const startTs = Date.now();
|
||||
|
||||
console.log(
|
||||
`=== Apply ${DEFAULT_MARKUP_PERCENT}% default markup on cost prices (${opts.commit ? "COMMIT" : "DRY RUN"}) ===`,
|
||||
);
|
||||
if (opts.sku) console.log(` sku filter: ${opts.sku}`);
|
||||
if (opts.limit !== null) console.log(` limit: ${opts.limit}`);
|
||||
|
||||
const pb = await getAdminPocketBase();
|
||||
|
||||
// Report 1 — always printed, even in dry-run, so the operator
|
||||
// sees how many rows still need a cost source before they look
|
||||
// at the would_patch list.
|
||||
const needingCost = await countRowsNeedingCostPrice(pb);
|
||||
console.log(`\nItems still needing a COST price: ${needingCost}`);
|
||||
|
||||
// The candidate set for the patch is filtered to cost > 0 AND
|
||||
// selling = 0. We re-apply the --sku filter at the candidate
|
||||
// query so the smoke-test single-row case still works.
|
||||
const stockRows = await listMarkupCandidateStockItems(pb, {
|
||||
sku: opts.sku,
|
||||
limit: opts.limit,
|
||||
});
|
||||
console.log(
|
||||
`\nScanning ${stockRows.length} stock_items row(s) (cost_price > 0 AND selling_price = 0)…\n`,
|
||||
);
|
||||
totals.scanned = stockRows.length;
|
||||
|
||||
for (const stockRow of stockRows) {
|
||||
const decision = decideMarkupForStockItem(stockRow);
|
||||
|
||||
if (decision.kind === "patch") {
|
||||
if (opts.commit) {
|
||||
try {
|
||||
await pb.collection("stock_items").update(stockRow.id, {
|
||||
selling_price: decision.patch.selling_price.to,
|
||||
markup: decision.patch.markup.to,
|
||||
});
|
||||
console.log(
|
||||
` ${formatPatchLine({
|
||||
decision,
|
||||
costPrice: Number(stockRow.cost_price ?? 0),
|
||||
verb: "patched",
|
||||
})} [OK]`,
|
||||
);
|
||||
totals.patched += 1;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.log(
|
||||
` ${formatPatchLine({
|
||||
decision,
|
||||
costPrice: Number(stockRow.cost_price ?? 0),
|
||||
verb: "patched",
|
||||
})} [ERR] ${msg}`,
|
||||
);
|
||||
totals.errors += 1;
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
` ${formatPatchLine({
|
||||
decision,
|
||||
costPrice: Number(stockRow.cost_price ?? 0),
|
||||
verb: "would_patch",
|
||||
})}`,
|
||||
);
|
||||
totals.wouldPatch += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (decision.kind === "skipped_idempotent") {
|
||||
console.log(` ${formatDecisionLine({ decision })}`);
|
||||
totals.skippedIdempotent += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// no_match — shouldn't fire here because the PB filter already
|
||||
// restricts to cost_price > 0, but we keep the branch for
|
||||
// safety (e.g. someone passes a hand-built snapshot in a test).
|
||||
console.log(` ${formatDecisionLine({ decision })}`);
|
||||
totals.noMatch += 1;
|
||||
}
|
||||
|
||||
printTotals(totals, opts.commit, startTs);
|
||||
return totals;
|
||||
}
|
||||
|
||||
function printTotals(totals: RunTotals, commit: boolean, startTs: number): void {
|
||||
const duration = ((Date.now() - startTs) / 1000).toFixed(1);
|
||||
console.log(`\n=== Totals ===`);
|
||||
console.log(` scanned: ${totals.scanned}`);
|
||||
console.log(
|
||||
` ${commit ? "patched" : "would_patch"}: ${commit ? totals.patched : totals.wouldPatch}`,
|
||||
);
|
||||
console.log(` skipped_idempotent: ${totals.skippedIdempotent}`);
|
||||
console.log(` no_match: ${totals.noMatch}`);
|
||||
console.log(` errors: ${totals.errors}`);
|
||||
console.log(` duration: ${duration}s`);
|
||||
if (commit && totals.patched > 0) {
|
||||
console.log(
|
||||
`\nWhat this script changed: ${totals.patched} stock_items row(s) had selling_price set to round(cost_price * ${DEFAULT_MARKUP_MULTIPLIER.toFixed(2)}, 2) and markup set to ${DEFAULT_MARKUP_PERCENT}.`,
|
||||
);
|
||||
}
|
||||
if (!commit && totals.wouldPatch > 0) {
|
||||
console.log(
|
||||
`\n(DRY RUN — re-run with --commit to apply the ${totals.wouldPatch} patch(es) above.)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Entry point ──────────────────────────────────────────────────────
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const opts = parseCli(process.argv.slice(2));
|
||||
await runApplyDefaultMarkup(opts);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("FATAL:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
317
scripts/list-irda-stock.ts
Normal file
317
scripts/list-irda-stock.ts
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
/**
|
||||
* One-off stock_items query: list every active `stock_items` row
|
||||
* whose `sku`, `name`, or `description` matches a configurable
|
||||
* substring (default "irda", override with `--term=`), then narrow
|
||||
* the list to spray cans. Read-only — never writes to PB.
|
||||
*
|
||||
* Why this exists
|
||||
* ────────────────
|
||||
* The operator wanted a quick read-out of the IRDA spray-paint line
|
||||
* without going through the UI. This script mirrors the CLI pattern
|
||||
* from `apply-default-markup.ts` (admin PB, side-effect env load,
|
||||
* pure filter + expand) so it can be deleted without leaving any
|
||||
* shared code behind.
|
||||
*
|
||||
* Matching rules
|
||||
* ──────────────
|
||||
* - Search term (default `irda`) is matched case-insensitively
|
||||
* against `sku`, `name`, OR `description` via PB's `~` operator.
|
||||
* Override with `--term=...` if the actual brand in the catalogue
|
||||
* is spelled differently (the IRDA line in this catalogue is
|
||||
* actually "IRIDA" — pass `--term=irida` to surface it).
|
||||
* - Only `is_active = true`.
|
||||
* - Spray narrowing happens client-side. The script prints three
|
||||
* views so the operator can see which sub-marker matches each
|
||||
* row: literal phrase "spray paint", standalone word "spray",
|
||||
* and the union ("spray" OR the H/HEAT variant marker). The
|
||||
* summary uses the union view.
|
||||
*
|
||||
* Output
|
||||
* ──────
|
||||
* - Markdown table: SKU | Name | Current Qty | Cost (R) | Selling
|
||||
* (R) | Markup | Supplier, sorted by `name` ASC.
|
||||
* - Summary block: total, items WITHOUT `selling_price`, items
|
||||
* WITHOUT `cost_price`, zero-stock items.
|
||||
*
|
||||
* Run with:
|
||||
* npx tsx scripts/list-irda-stock.ts # default term: "irda"
|
||||
* npx tsx scripts/list-irda-stock.ts --term=irida # override term
|
||||
*
|
||||
* Requires .env.local with NEXT_PUBLIC_POCKETBASE_URL,
|
||||
* PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD.
|
||||
*/
|
||||
|
||||
import "./_env"; // side-effect import: loads .env.local before anything else
|
||||
|
||||
import { getAdminPocketBase } from "../src/lib/server/pocketbase-admin";
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────
|
||||
|
||||
/** Default search term. PB's `~` operator is case-insensitive, so
|
||||
* casing here doesn't matter for matching — but the literal does
|
||||
* appear in the report headers. */
|
||||
const DEFAULT_SEARCH_TERM = "irda";
|
||||
|
||||
/** Narrowing marker. The user's request was for "irda spray paints"
|
||||
* (literal phrase). The IRIDA line in stock_items labels every
|
||||
* product with one of two sub-markers:
|
||||
* - "SPRAY" (e.g. "HB IRIDA SPRAY 9010 WHITE GLOS") — the
|
||||
* standard aerosol cans.
|
||||
* - "H/HEAT" (e.g. "HB IRIDA H/HEAT 420 BLACK") — the heat-
|
||||
* resistant variant, still an aerosol spray can, just a
|
||||
* different formulation under the same IRIDA product line.
|
||||
* Neither sub-marker uses the literal phrase "spray paint". So
|
||||
* the narrow view in the report accepts EITHER:
|
||||
* - the literal phrase "spray paint" (catches 0 of the IRIDA
|
||||
* rows — useful as a baseline showing the phrase is wrong
|
||||
* for this catalogue), OR
|
||||
* - either of the two IRIDA sub-markers (catches all 28
|
||||
* IRIDA rows).
|
||||
* Both views are printed so the operator can see exactly which
|
||||
* filter triggered for each row. */
|
||||
const SPRAY_PHRASE = "spray paint";
|
||||
const SPRAY_WORD = "spray";
|
||||
const HEAT_VARIANT_WORD = "h/heat";
|
||||
|
||||
// ─── CLI parsing ──────────────────────────────────────────────────────
|
||||
|
||||
type CliOptions = { term: string };
|
||||
|
||||
function parseCli(argv: string[]): CliOptions {
|
||||
const opts: CliOptions = { term: DEFAULT_SEARCH_TERM };
|
||||
for (const arg of argv) {
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
} else if (arg.startsWith("--term=")) {
|
||||
const raw = arg.slice("--term=".length).trim();
|
||||
if (!raw) throw new Error(`--term must be non-empty, got "${arg}"`);
|
||||
opts.term = raw;
|
||||
} else {
|
||||
throw new Error(`Unknown CLI flag: ${arg}`);
|
||||
}
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
function printHelp(): void {
|
||||
console.log(
|
||||
`List active stock_items rows whose sku/name/description match a search term,\nnarrowed to spray paints.\n`,
|
||||
);
|
||||
console.log(`Usage:
|
||||
tsx scripts/list-irda-stock.ts # default: term="irda"
|
||||
tsx scripts/list-irda-stock.ts --term=irida # override term
|
||||
|
||||
Flags:
|
||||
--term=XYZ Substring to match (case-insensitive) against
|
||||
sku/name/description. Default: "irda".
|
||||
--help, -h Show this help.`);
|
||||
}
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Single `stock_items` row shape — the fields we read for the
|
||||
* table. `expand.supplier` is the joined supplier name. */
|
||||
type IrdaStockRow = {
|
||||
id: string;
|
||||
sku: string;
|
||||
name: string;
|
||||
description: string;
|
||||
current_qty: number;
|
||||
cost_price?: number | null;
|
||||
selling_price?: number | null;
|
||||
markup?: number | null;
|
||||
supplier?: string;
|
||||
expand?: {
|
||||
supplier?: { id?: string; suppliername?: string };
|
||||
};
|
||||
};
|
||||
|
||||
// ─── Formatting ────────────────────────────────────────────────────────
|
||||
|
||||
/** Format a number as R X.XX, or em-dash for missing/non-finite
|
||||
* values (so the table reads cleanly when a row has no selling
|
||||
* price). */
|
||||
function fmtMoney(n: number | null | undefined): string {
|
||||
if (typeof n !== "number" || !Number.isFinite(n)) return "—";
|
||||
return `R ${n.toFixed(2)}`;
|
||||
}
|
||||
|
||||
/** Render the markdown table row. SKU/Name left-aligned; numbers
|
||||
* right-aligned via the `---:` colon convention. Supplier falls
|
||||
* back to "—" when the relation is unset so a missing supplier
|
||||
* reads as such in the table rather than blowing up. */
|
||||
function renderTable(rows: IrdaStockRow[]): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(
|
||||
"| SKU | Name | Current Qty | Cost (R) | Selling (R) | Markup | Supplier |",
|
||||
);
|
||||
lines.push(
|
||||
"| --- | --- | ---: | ---: | ---: | ---: | --- |",
|
||||
);
|
||||
|
||||
for (const r of rows) {
|
||||
const sku = r.sku || "(no sku)";
|
||||
const name = r.name || "(no name)";
|
||||
const qty = Number.isFinite(r.current_qty) ? r.current_qty : 0;
|
||||
const supplier =
|
||||
r.expand?.supplier?.suppliername?.trim() || "—";
|
||||
|
||||
lines.push(
|
||||
`| ${sku} | ${name} | ${qty} | ${fmtMoney(r.cost_price)} | ${fmtMoney(r.selling_price)} | ${r.markup ?? "—"} | ${supplier} |`,
|
||||
);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ─── PB plumbing ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pull every `stock_items` row matching the configured search term
|
||||
* (case-insensitive `~` on `sku`, `name`, or `description`) where
|
||||
* `is_active = true`.
|
||||
*
|
||||
* Server-side filter (rather than full-table scan + JS filter)
|
||||
* keeps the round-trip small even if the catalogue grows. The
|
||||
* `~` operator is PB's case-insensitive contains.
|
||||
*
|
||||
* The "spray paint" narrowing happens client-side on top of the
|
||||
* filtered result. Two reasons:
|
||||
* - Mixing two `~` clauses with `||` chains is fine in PB but
|
||||
* `(A ~ x || A ~ y || B ~ x || B ~ y)` gets ugly fast and is
|
||||
* easy to get wrong when someone adjusts the term.
|
||||
* - We want the script's "non-spray-paint matches" count in
|
||||
* the summary to be authoritative without a second PB round-
|
||||
* trip — that count comes from the same client-side pass.
|
||||
*/
|
||||
async function listMatchingStockItems(term: string): Promise<IrdaStockRow[]> {
|
||||
const pb = await getAdminPocketBase();
|
||||
// Escape `"` so a hostile/malformed term can't break out of the
|
||||
// filter string. `~` itself takes care of case-insensitivity;
|
||||
// no other special chars in the term are filter-significant.
|
||||
const safeTerm = term.replace(/"/g, '\\"');
|
||||
// We deliberately do NOT pass a `fields` projection here: PB's
|
||||
// `fields` projection drops the `expand` payload, so passing
|
||||
// `fields=...` together with `expand=supplier` returns rows
|
||||
// with `expand: undefined` even though the relation field is
|
||||
// populated. The full record is small (28 rows max for the
|
||||
// "irida" case) so the round-trip cost is negligible.
|
||||
const rows = await pb.collection("stock_items").getFullList<IrdaStockRow>({
|
||||
filter:
|
||||
`(sku ~ "${safeTerm}" || name ~ "${safeTerm}" || description ~ "${safeTerm}") && is_active = true`,
|
||||
expand: "supplier",
|
||||
sort: "name",
|
||||
batch: 500,
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ─── Main ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const opts = parseCli(process.argv.slice(2));
|
||||
const startTs = Date.now();
|
||||
const allMatchingRows = await listMatchingStockItems(opts.term);
|
||||
|
||||
// Client-side spray-paint narrowing. Three filters:
|
||||
// - `isSprayPaintPhrase`: literal phrase "spray paint" in
|
||||
// name or description (case-insensitive).
|
||||
// - `isSpray`: standalone word "spray" — catches the
|
||||
// standard IRIDA spray cans ("HB IRIDA SPRAY ...").
|
||||
// - `isSprayOrHeatVariant`: word "spray" OR "h/heat" —
|
||||
// catches BOTH the standard and high-heat IRIDA aerosol
|
||||
// cans. This is the view the user actually wanted for
|
||||
// "irda spray paints" — every IRIDA product in the
|
||||
// catalogue is one or the other.
|
||||
const isSprayPaintPhrase = (row: IrdaStockRow): boolean => {
|
||||
const hay = `${row.name ?? ""} ${row.description ?? ""}`.toLowerCase();
|
||||
return hay.includes(SPRAY_PHRASE);
|
||||
};
|
||||
const isSpray = (row: IrdaStockRow): boolean => {
|
||||
const hay = `${row.name ?? ""} ${row.description ?? ""}`.toLowerCase();
|
||||
return hay.includes(SPRAY_WORD);
|
||||
};
|
||||
const isSprayOrHeatVariant = (row: IrdaStockRow): boolean => {
|
||||
const hay = `${row.name ?? ""} ${row.description ?? ""}`.toLowerCase();
|
||||
return hay.includes(SPRAY_WORD) || hay.includes(HEAT_VARIANT_WORD);
|
||||
};
|
||||
|
||||
const phraseRows = allMatchingRows.filter(isSprayPaintPhrase);
|
||||
const sprayRows = allMatchingRows.filter(isSpray);
|
||||
const familyRows = allMatchingRows.filter(isSprayOrHeatVariant);
|
||||
const nonSprayRows = allMatchingRows.filter((r) => !isSprayOrHeatVariant(r));
|
||||
|
||||
console.log(
|
||||
`=== stock_items matching "${opts.term}" (active only) — ALL rows ===\n`,
|
||||
);
|
||||
|
||||
console.log(renderTable(allMatchingRows));
|
||||
console.log("");
|
||||
|
||||
console.log(
|
||||
`\n=== spray-paint narrow (literal phrase "${SPRAY_PHRASE}") ===\n`,
|
||||
);
|
||||
console.log(renderTable(phraseRows));
|
||||
console.log("");
|
||||
|
||||
console.log(
|
||||
`\n=== spray-paint narrow (word "${SPRAY_WORD}" only — catches IRIDA standard cans) ===\n`,
|
||||
);
|
||||
console.log(renderTable(sprayRows));
|
||||
console.log("");
|
||||
|
||||
console.log(
|
||||
`\n=== spray-paint narrow (word "${SPRAY_WORD}" OR "${HEAT_VARIANT_WORD}" — full IRIDA spray family) ===\n`,
|
||||
);
|
||||
console.log(renderTable(familyRows));
|
||||
console.log("");
|
||||
|
||||
// The user-facing summary uses the FULL-FAMILY view: every IRIDA
|
||||
// product in the catalogue is either a standard spray can or
|
||||
// the H/HEAT variant. The other views are printed so the
|
||||
// operator can compare and confirm the data is what they
|
||||
// expected.
|
||||
const summaryRows = familyRows;
|
||||
|
||||
// Summary counts. "WITHOUT" is read as `null OR undefined OR 0`
|
||||
// — the same convention the `apply-default-markup.ts` report uses
|
||||
// (`cost_price <= 0`), so the two reports can be eyeballed
|
||||
// against each other.
|
||||
const total = summaryRows.length;
|
||||
const noSelling = summaryRows.filter(
|
||||
(r) => !(Number(r.selling_price) > 0),
|
||||
).length;
|
||||
const noCost = summaryRows.filter(
|
||||
(r) => !(Number(r.cost_price) > 0),
|
||||
).length;
|
||||
const zeroStock = summaryRows.filter(
|
||||
(r) => !(Number(r.current_qty) > 0),
|
||||
).length;
|
||||
|
||||
console.log(`\n=== Summary (full IRIDA spray family: "${SPRAY_WORD}" OR "${HEAT_VARIANT_WORD}") ===`);
|
||||
console.log(` Total matching rows: ${total}`);
|
||||
console.log(` Items WITHOUT selling_price (gap): ${noSelling}`);
|
||||
console.log(` Items WITHOUT cost_price (gap): ${noCost}`);
|
||||
console.log(` Items with zero current_qty: ${zeroStock}`);
|
||||
if (nonSprayRows.length > 0) {
|
||||
console.log(
|
||||
` Matching rows that are NOT spray cans: ${nonSprayRows.length}`,
|
||||
);
|
||||
console.log(
|
||||
` (these are listed in the ALL table above but excluded from the spray tables)`,
|
||||
);
|
||||
} else if (allMatchingRows.length > 0) {
|
||||
console.log(
|
||||
` Matching rows that are NOT spray cans: 0 (every match is a spray can)`,
|
||||
);
|
||||
}
|
||||
|
||||
const duration = ((Date.now() - startTs) / 1000).toFixed(1);
|
||||
console.log(`\n duration: ${duration}s`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("FATAL:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -12,7 +12,7 @@ import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-li
|
|||
// Module-level shared state is created with vi.hoisted so both mock
|
||||
// factories (pocketbase and @/lib/pocketbase) can read/write it.
|
||||
|
||||
const { _state, _collectionMock, _resetState } = vi.hoisted(() => {
|
||||
const { _state, _collectionMock, _resetState, _routerMock } = vi.hoisted(() => {
|
||||
const state = {
|
||||
items: [] as unknown[],
|
||||
error: undefined as string | undefined,
|
||||
|
|
@ -33,6 +33,11 @@ const { _state, _collectionMock, _resetState } = vi.hoisted(() => {
|
|||
subscribe: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
};
|
||||
const routerMock = {
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
back: vi.fn(),
|
||||
};
|
||||
return {
|
||||
_state: state,
|
||||
_collectionMock: collectionMock,
|
||||
|
|
@ -50,7 +55,11 @@ const { _state, _collectionMock, _resetState } = vi.hoisted(() => {
|
|||
totalPages: 1,
|
||||
};
|
||||
});
|
||||
routerMock.push.mockClear();
|
||||
routerMock.replace.mockClear();
|
||||
routerMock.back.mockClear();
|
||||
},
|
||||
_routerMock: routerMock,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -78,11 +87,7 @@ vi.mock("@/lib/pocketbase", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
back: vi.fn(),
|
||||
}),
|
||||
useRouter: () => _routerMock,
|
||||
}));
|
||||
|
||||
import Page from "./page";
|
||||
|
|
@ -377,6 +382,54 @@ describe("StockListPage", () => {
|
|||
expect(await screen.findByText(/Apply changes/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/1 row/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not navigate to the detail page when clicking the markup input in edit mode", async () => {
|
||||
// Regression: the row used to have onClick={onOpen} unconditionally,
|
||||
// so clicking a pricing input bubbled up to the row and dispatched
|
||||
// router.push('/stock/:id') — kicking the operator off the list
|
||||
// before they could type. The row is no longer a button in edit
|
||||
// mode, and the input cells stopPropagation for defence in depth.
|
||||
render(<Page />);
|
||||
await screen.findByText("M10 Hex Bolt");
|
||||
fireEvent.click(screen.getByRole("button", { name: /Edit pricing/i }));
|
||||
|
||||
// Sanity-check the row is no longer a button while in edit mode.
|
||||
// The "Open M10 Hex Bolt" aria-label only renders when the row
|
||||
// is in button-role mode.
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /Open M10 Hex Bolt/i }),
|
||||
).toBeNull();
|
||||
|
||||
const markupInput = await screen.findByLabelText(/Markup for B-001/i);
|
||||
fireEvent.click(markupInput);
|
||||
|
||||
// The operator is still on the list — no detail-page navigation.
|
||||
expect(_routerMock.push).not.toHaveBeenCalled();
|
||||
|
||||
// And typing into the markup input still works (it didn't lose
|
||||
// focus or disappear because of the click).
|
||||
fireEvent.change(markupInput, { target: { value: "25" } });
|
||||
expect(markupInput).toHaveValue(25);
|
||||
|
||||
// Edit mode is still active — the toggle now reads "Done editing".
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Done editing/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("still navigates to the detail page when a row is clicked outside edit mode", async () => {
|
||||
// Companion to the above: the row click → detail navigation must
|
||||
// still work when the operator is NOT editing pricing. (Guards
|
||||
// against the gating being too aggressive.)
|
||||
render(<Page />);
|
||||
await screen.findByText("M10 Hex Bolt");
|
||||
|
||||
// In default (non-edit) mode the row IS a button labelled
|
||||
// "Open M10 Hex Bolt" — click it and verify the navigation.
|
||||
const row = await screen.findByRole("button", { name: /Open M10 Hex Bolt/i });
|
||||
fireEvent.click(row);
|
||||
expect(_routerMock.push).toHaveBeenCalledWith("/stock/aaaaaaaaaaaaaaa");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Stock list — localStorage draft persistence", () => {
|
||||
|
|
@ -449,4 +502,199 @@ describe("StockListPage", () => {
|
|||
expect(addSpy).toHaveBeenCalledWith("beforeunload", expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Stock list — sort headers (cost / markup / selling)", () => {
|
||||
beforeEach(() => {
|
||||
_resetState();
|
||||
window.localStorage.clear();
|
||||
_state.items = [
|
||||
{ id: "aaaaaaaaaaaaaaa", sku: "B-001", name: "M10 Hex Bolt", cost_price: 2.5, markup: 30, selling_price: 3.25, current_qty: 5, min_qty: 0, is_active: true, supplier: "" },
|
||||
];
|
||||
});
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it("passes a `sort` param to PB with the cost ascending when Cost header is clicked once", async () => {
|
||||
render(<Page />);
|
||||
await screen.findByText("M10 Hex Bolt");
|
||||
// Show pricing columns so the sort headers render.
|
||||
fireEvent.click(screen.getByRole("switch", { name: /Show pricing columns/i }));
|
||||
await screen.findByTestId("sort-cost");
|
||||
|
||||
// Capture every getList invocation so we can read the sort arg.
|
||||
const sortArgs: Array<string | undefined> = [];
|
||||
const orig = _collectionMock.getList.getMockImplementation();
|
||||
_collectionMock.getList.mockImplementation(async (...args: unknown[]) => {
|
||||
const opts = (args[2] ?? {}) as { sort?: string };
|
||||
sortArgs.push(opts.sort);
|
||||
if (orig) return await (orig as (...a: unknown[]) => unknown)(...args);
|
||||
return { items: [], totalItems: 0, page: 1, perPage: 200, totalPages: 1 };
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("sort-cost"));
|
||||
|
||||
// The sort cycle is none → asc → desc → none, so the first
|
||||
// click picks ascending. We assert the most recent call, not
|
||||
// the first (the initial fetch uses `-updated`).
|
||||
await waitFor(() => {
|
||||
expect(sortArgs[sortArgs.length - 1]).toBe("cost_price");
|
||||
});
|
||||
});
|
||||
|
||||
it("cycles to descending on the second click and clears on the third", async () => {
|
||||
render(<Page />);
|
||||
await screen.findByText("M10 Hex Bolt");
|
||||
fireEvent.click(screen.getByRole("switch", { name: /Show pricing columns/i }));
|
||||
await screen.findByTestId("sort-markup");
|
||||
const sortArgs: Array<string | undefined> = [];
|
||||
const orig = _collectionMock.getList.getMockImplementation();
|
||||
_collectionMock.getList.mockImplementation(async (...args: unknown[]) => {
|
||||
const opts = (args[2] ?? {}) as { sort?: string };
|
||||
sortArgs.push(opts.sort);
|
||||
if (orig) return await (orig as (...a: unknown[]) => unknown)(...args);
|
||||
return { items: [], totalItems: 0, page: 1, perPage: 200, totalPages: 1 };
|
||||
});
|
||||
|
||||
// Helper: click the sort-markup header and wait for the
|
||||
// current sort direction (encoded in the button's aria-label).
|
||||
// We re-query the button each time because the component
|
||||
// re-renders on every state change — a stale handle would
|
||||
// still fire clicks, but reading its attrs would reflect the
|
||||
// previous render's label.
|
||||
const clickAndWaitForLabel = async (matcher: RegExp) => {
|
||||
const btn = screen.getByTestId("sort-markup");
|
||||
fireEvent.click(btn);
|
||||
await waitFor(() => {
|
||||
expect(btn.getAttribute("aria-label")).toMatch(matcher);
|
||||
});
|
||||
};
|
||||
|
||||
// 1st click: ascending → label flips to "currently ascending".
|
||||
await clickAndWaitForLabel(/currently ascending/);
|
||||
// 2nd click: descending → label flips to "currently descending".
|
||||
await clickAndWaitForLabel(/currently descending/);
|
||||
// 3rd click: cleared → label flips back to the plain "Sort by" form.
|
||||
await clickAndWaitForLabel(/^Sort by/);
|
||||
|
||||
// All three transitions produced a fetch with the matching sort arg.
|
||||
await waitFor(() => {
|
||||
expect(sortArgs).toContain("markup");
|
||||
expect(sortArgs).toContain("-markup");
|
||||
expect(sortArgs).toContain("-updated");
|
||||
});
|
||||
});
|
||||
|
||||
it("does NOT navigate when a sort header is clicked (header is its own button)", async () => {
|
||||
render(<Page />);
|
||||
await screen.findByText("M10 Hex Bolt");
|
||||
fireEvent.click(screen.getByRole("switch", { name: /Show pricing columns/i }));
|
||||
const header = await screen.findByTestId("sort-selling");
|
||||
fireEvent.click(header);
|
||||
expect(_routerMock.push).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Stock list — qty edit (current / min / target)", () => {
|
||||
let fetchSpy: ReturnType<typeof vi.fn>;
|
||||
beforeEach(() => {
|
||||
_resetState();
|
||||
_state.items = [
|
||||
{
|
||||
id: "aaaaaaaaaaaaaaa",
|
||||
sku: "B-001",
|
||||
name: "M10 Hex Bolt",
|
||||
current_qty: 5,
|
||||
min_qty: 2,
|
||||
target_qty: 10,
|
||||
selling_price: 0,
|
||||
cost_price: 0,
|
||||
markup: 0,
|
||||
is_active: true,
|
||||
supplier: "",
|
||||
},
|
||||
];
|
||||
window.localStorage.clear();
|
||||
// Stub fetch — apply calls both endpoints in parallel.
|
||||
fetchSpy = vi.fn().mockImplementation(async (url: string) => {
|
||||
if (url.includes("set-quantities")) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
results: [
|
||||
{
|
||||
id: "aaaaaaaaaaaaaaa",
|
||||
ok: true,
|
||||
item: { current_qty: 12, min_qty: 4, target_qty: 20 },
|
||||
previous: { current_qty: 5, min_qty: 2, target_qty: 10 },
|
||||
},
|
||||
],
|
||||
summary: { total: 1, succeeded: 1, failed: 0 },
|
||||
}),
|
||||
} as Response;
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
results: [],
|
||||
summary: { total: 0, succeeded: 0, failed: 0 },
|
||||
}),
|
||||
} as Response;
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("renders edit-mode inputs for current / min / target when Edit pricing is on", async () => {
|
||||
render(<Page />);
|
||||
await screen.findByText("M10 Hex Bolt");
|
||||
fireEvent.click(screen.getByRole("button", { name: /Edit pricing/i }));
|
||||
expect(await screen.findByLabelText(/Current qty for B-001/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Min qty for B-001/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Target qty for B-001/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does NOT navigate when clicking a qty input (input cells stopPropagation)", async () => {
|
||||
render(<Page />);
|
||||
await screen.findByText("M10 Hex Bolt");
|
||||
fireEvent.click(screen.getByRole("button", { name: /Edit pricing/i }));
|
||||
const currentInput = await screen.findByLabelText(/Current qty for B-001/i);
|
||||
fireEvent.click(currentInput);
|
||||
// No detail-page navigation — same defence as pricing inputs.
|
||||
expect(_routerMock.push).not.toHaveBeenCalled();
|
||||
// And the input is still functional (focus + typing works).
|
||||
fireEvent.change(currentInput, { target: { value: "12" } });
|
||||
expect(currentInput).toHaveValue(12);
|
||||
});
|
||||
|
||||
it("POSTs qty changes to /set-quantities when Apply changes is clicked", async () => {
|
||||
render(<Page />);
|
||||
await screen.findByText("M10 Hex Bolt");
|
||||
fireEvent.click(screen.getByRole("button", { name: /Edit pricing/i }));
|
||||
|
||||
const currentInput = await screen.findByLabelText(/Current qty for B-001/i);
|
||||
fireEvent.change(currentInput, { target: { value: "12" } });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /^Apply changes$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
const qtyCall = fetchSpy.mock.calls.find(
|
||||
(c) => typeof c[0] === "string" && c[0].includes("set-quantities"),
|
||||
);
|
||||
expect(qtyCall).toBeDefined();
|
||||
});
|
||||
|
||||
const qtyCall = fetchSpy.mock.calls.find(
|
||||
(c) => typeof c[0] === "string" && c[0].includes("set-quantities"),
|
||||
);
|
||||
const body = JSON.parse((qtyCall?.[1] as RequestInit).body as string);
|
||||
expect(body.updates[0]).toMatchObject({
|
||||
id: "aaaaaaaaaaaaaaa",
|
||||
currentQty: 12,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -46,6 +46,9 @@ import {
|
|||
Download,
|
||||
Upload,
|
||||
FileSpreadsheet,
|
||||
ArrowUpDown,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -95,10 +98,45 @@ export default function StockListPage() {
|
|||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [bulkImportOpen, setBulkImportOpen] = useState(false);
|
||||
const [editPricing, setEditPricing] = useState(false);
|
||||
type DraftRow = { costPrice?: number; sellingPrice?: number; markup?: number; sellingLocked?: boolean };
|
||||
type DraftRow = {
|
||||
costPrice?: number;
|
||||
sellingPrice?: number;
|
||||
markup?: number;
|
||||
sellingLocked?: boolean;
|
||||
currentQty?: number;
|
||||
minQty?: number;
|
||||
targetQty?: number;
|
||||
};
|
||||
const [pricingDraft, setPricingDraft] = useState<Record<string, DraftRow>>({});
|
||||
const [pricingApplyState, setPricingApplyState] = useState<"idle" | "applying" | "applied" | "partial">("idle");
|
||||
|
||||
/**
|
||||
* Column sort for the cost/markup/selling headers. Cycles
|
||||
* `none → asc → desc → none` on each click so the operator can
|
||||
* return to the default "most recently updated first" view
|
||||
* without a separate "clear sort" button. `null` means "no sort
|
||||
* applied" — the PB query falls back to `-updated` in that case
|
||||
* (the original behaviour).
|
||||
*/
|
||||
type SortKey = "cost_price" | "markup" | "selling_price";
|
||||
type SortDir = "asc" | "desc";
|
||||
const [sortKey, setSortKey] = useState<SortKey | null>(null);
|
||||
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
||||
const cycleSort = (key: SortKey) => {
|
||||
if (sortKey !== key) {
|
||||
setSortKey(key);
|
||||
setSortDir("asc");
|
||||
return;
|
||||
}
|
||||
if (sortDir === "asc") {
|
||||
setSortDir("desc");
|
||||
return;
|
||||
}
|
||||
// Was "desc" → clear and fall back to default sort.
|
||||
setSortKey(null);
|
||||
setSortDir("asc");
|
||||
};
|
||||
|
||||
const fetchItems = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
|
@ -123,7 +161,11 @@ export default function StockListPage() {
|
|||
200,
|
||||
{
|
||||
filter: filters.join(" && "),
|
||||
sort: "-updated",
|
||||
// PB's `sort` is one field with optional `-` prefix for
|
||||
// descending. When no sort key is selected, fall back to
|
||||
// `-updated` (most recently updated first) — same as
|
||||
// before this feature shipped.
|
||||
sort: sortKey ? `${sortDir === "desc" ? "-" : ""}${sortKey}` : "-updated",
|
||||
expand: "supplier",
|
||||
}
|
||||
);
|
||||
|
|
@ -138,7 +180,7 @@ export default function StockListPage() {
|
|||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [search, lowStockOnly, missingPricingOnly]);
|
||||
}, [search, lowStockOnly, missingPricingOnly, sortKey, sortDir]);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- data fetch on dep change
|
||||
|
|
@ -255,38 +297,107 @@ export default function StockListPage() {
|
|||
const clearSearch = () => setSearchInput("");
|
||||
|
||||
const applyPricingDraft = async () => {
|
||||
const updates = Object.entries(pricingDraft).flatMap(([id, draft]) => {
|
||||
const out: { id: string; costPrice?: number; sellingPrice?: number; markup?: number }[] = [{ id }];
|
||||
const o = out[0]!;
|
||||
if (draft.costPrice !== undefined) o.costPrice = draft.costPrice;
|
||||
if (draft.sellingPrice !== undefined) o.sellingPrice = draft.sellingPrice;
|
||||
if (draft.markup !== undefined) o.markup = draft.markup;
|
||||
if (o.costPrice === undefined && o.sellingPrice === undefined && o.markup === undefined) return [];
|
||||
return out;
|
||||
});
|
||||
if (updates.length === 0) return;
|
||||
// Split the draft into pricing vs qty updates so each row
|
||||
// gets sent to the right endpoint. The endpoints have
|
||||
// different audit requirements (qty changes write a
|
||||
// stock_ledger row; pricing changes don't, except for the
|
||||
// existing cost-move ledger row written by set-pricing
|
||||
// itself), so we keep them as separate round-trips.
|
||||
const pricingUpdates: Array<{
|
||||
id: string;
|
||||
costPrice?: number;
|
||||
sellingPrice?: number;
|
||||
markup?: number;
|
||||
}> = [];
|
||||
const quantityUpdates: Array<{
|
||||
id: string;
|
||||
currentQty?: number;
|
||||
minQty?: number;
|
||||
targetQty?: number;
|
||||
}> = [];
|
||||
for (const [id, draft] of Object.entries(pricingDraft)) {
|
||||
const p: (typeof pricingUpdates)[number] = { id };
|
||||
if (draft.costPrice !== undefined) p.costPrice = draft.costPrice;
|
||||
if (draft.sellingPrice !== undefined) p.sellingPrice = draft.sellingPrice;
|
||||
if (draft.markup !== undefined) p.markup = draft.markup;
|
||||
if (p.costPrice !== undefined || p.sellingPrice !== undefined || p.markup !== undefined) {
|
||||
pricingUpdates.push(p);
|
||||
}
|
||||
const q: (typeof quantityUpdates)[number] = { id };
|
||||
if (draft.currentQty !== undefined) q.currentQty = draft.currentQty;
|
||||
if (draft.minQty !== undefined) q.minQty = draft.minQty;
|
||||
if (draft.targetQty !== undefined) q.targetQty = draft.targetQty;
|
||||
if (q.currentQty !== undefined || q.minQty !== undefined || q.targetQty !== undefined) {
|
||||
quantityUpdates.push(q);
|
||||
}
|
||||
}
|
||||
if (pricingUpdates.length === 0 && quantityUpdates.length === 0) return;
|
||||
setPricingApplyState("applying");
|
||||
try {
|
||||
const res = await fetch("/api/v1/stock/items/set-pricing", {
|
||||
const actor = { id: "system", name: "Operator" };
|
||||
const postJson = async (url: string, body: Record<string, unknown>) => {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ updates, actor: { id: "system", name: "Operator" } }),
|
||||
body: JSON.stringify({ ...body, actor }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) {
|
||||
toast.error(json?.message ?? "Apply failed");
|
||||
setPricingApplyState("partial");
|
||||
return;
|
||||
return { res, json: await res.json().catch(() => ({})) };
|
||||
};
|
||||
try {
|
||||
// Fire both endpoints in parallel — the operator has been
|
||||
// staring at the apply bar long enough; no need to do them
|
||||
// sequentially when the call sites are independent.
|
||||
const [pricingRes, qtyRes] = await Promise.all([
|
||||
pricingUpdates.length > 0
|
||||
? postJson("/api/v1/stock/items/set-pricing", { updates: pricingUpdates })
|
||||
: Promise.resolve(null),
|
||||
quantityUpdates.length > 0
|
||||
? postJson("/api/v1/stock/items/set-quantities", { updates: quantityUpdates })
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
const pricingOk = pricingRes ? pricingRes.res.ok : true;
|
||||
const qtyOk = qtyRes ? qtyRes.res.ok : true;
|
||||
if (!pricingOk) {
|
||||
toast.error(pricingRes?.json?.message ?? "Pricing apply failed");
|
||||
}
|
||||
if (!qtyOk) {
|
||||
toast.error(qtyRes?.json?.message ?? "Quantity apply failed");
|
||||
}
|
||||
// Merge per-row outcomes. We track which ids FAILED so the
|
||||
// corresponding draft entry is preserved for a re-apply.
|
||||
const failedIds = new Set<string>();
|
||||
const newItems = items.map((it) => {
|
||||
const row = json.results?.find?.((r: { id: string }) => r.id === it.id);
|
||||
if (!row) return it;
|
||||
if (!row.ok) {
|
||||
failedIds.add(it.id);
|
||||
return it;
|
||||
let next = it;
|
||||
if (pricingRes) {
|
||||
const row = pricingRes.json.results?.find?.(
|
||||
(r: { id: string }) => r.id === it.id,
|
||||
);
|
||||
if (row && !row.ok) failedIds.add(it.id);
|
||||
if (row?.ok) {
|
||||
next = {
|
||||
...next,
|
||||
...row.item,
|
||||
cost_price: row.item.cost_price,
|
||||
selling_price: row.item.selling_price,
|
||||
markup: row.item.markup,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { ...it, ...row.item, cost_price: row.item.cost_price, selling_price: row.item.selling_price, markup: row.item.markup };
|
||||
if (qtyRes) {
|
||||
const row = qtyRes.json.results?.find?.(
|
||||
(r: { id: string }) => r.id === it.id,
|
||||
);
|
||||
if (row && !row.ok) failedIds.add(it.id);
|
||||
if (row?.ok) {
|
||||
next = {
|
||||
...next,
|
||||
...row.item,
|
||||
current_qty: row.item.current_qty,
|
||||
min_qty: row.item.min_qty,
|
||||
target_qty: row.item.target_qty,
|
||||
};
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setItems(newItems);
|
||||
const failedDraftEntries = Object.fromEntries(
|
||||
|
|
@ -296,14 +407,29 @@ export default function StockListPage() {
|
|||
if (failedIds.size === 0) {
|
||||
window.localStorage.removeItem("wfdash.stock.pricingDraft");
|
||||
} else {
|
||||
window.localStorage.setItem("wfdash.stock.pricingDraft", JSON.stringify(failedDraftEntries));
|
||||
window.localStorage.setItem(
|
||||
"wfdash.stock.pricingDraft",
|
||||
JSON.stringify(failedDraftEntries),
|
||||
);
|
||||
}
|
||||
const pricingSummary = pricingRes?.json?.summary as
|
||||
| { succeeded?: number; failed?: number }
|
||||
| undefined;
|
||||
const qtySummary = qtyRes?.json?.summary as
|
||||
| { succeeded?: number; failed?: number }
|
||||
| undefined;
|
||||
const totalSucceeded =
|
||||
(pricingSummary?.succeeded ?? 0) + (qtySummary?.succeeded ?? 0);
|
||||
const totalFailed =
|
||||
(pricingSummary?.failed ?? 0) + (qtySummary?.failed ?? 0);
|
||||
setPricingApplyState(totalFailed > 0 ? "partial" : "applied");
|
||||
if (pricingOk && qtyOk) {
|
||||
if (totalFailed === 0 && totalSucceeded > 0) {
|
||||
toast.success(`Saved ${totalSucceeded} change${totalSucceeded === 1 ? "" : "s"}`);
|
||||
} else if (totalFailed > 0) {
|
||||
toast.error(`Saved ${totalSucceeded} · ${totalFailed} failed`);
|
||||
}
|
||||
}
|
||||
setPricingApplyState(failedIds.size > 0 ? "partial" : "applied");
|
||||
toast.success(
|
||||
failedIds.size > 0
|
||||
? `Saved ${json.summary.succeeded} · ${json.summary.failed} failed`
|
||||
: `Saved ${json.summary.succeeded} row${json.summary.succeeded === 1 ? "" : "s"}`,
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Apply failed");
|
||||
setPricingApplyState("partial");
|
||||
|
|
@ -484,9 +610,33 @@ export default function StockListPage() {
|
|||
<TableHead className="text-right w-24">Target</TableHead>
|
||||
{showPricingColumns && (
|
||||
<>
|
||||
<TableHead className="text-right w-28">Cost (R)</TableHead>
|
||||
<TableHead className="text-right w-24">Markup %</TableHead>
|
||||
<TableHead className="text-right w-28">Selling (R)</TableHead>
|
||||
<SortHeader
|
||||
label="Cost (R)"
|
||||
sortKey="cost_price"
|
||||
activeKey={sortKey}
|
||||
activeDir={sortDir}
|
||||
onCycle={cycleSort}
|
||||
className="w-28"
|
||||
testId="sort-cost"
|
||||
/>
|
||||
<SortHeader
|
||||
label="Markup %"
|
||||
sortKey="markup"
|
||||
activeKey={sortKey}
|
||||
activeDir={sortDir}
|
||||
onCycle={cycleSort}
|
||||
className="w-24"
|
||||
testId="sort-markup"
|
||||
/>
|
||||
<SortHeader
|
||||
label="Selling (R)"
|
||||
sortKey="selling_price"
|
||||
activeKey={sortKey}
|
||||
activeDir={sortDir}
|
||||
onCycle={cycleSort}
|
||||
className="w-28"
|
||||
testId="sort-selling"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<TableHead className="w-36">Status</TableHead>
|
||||
|
|
@ -541,7 +691,7 @@ export default function StockListPage() {
|
|||
{editPricing && Object.keys(pricingDraft).length > 0 && (
|
||||
<div className="sticky bottom-0 left-0 right-0 z-10 -mx-4 sm:-mx-6 px-4 sm:px-6 py-3 bg-amber-50 border-t border-amber-300 shadow-md flex items-center justify-between gap-3">
|
||||
<span className="text-sm font-medium">
|
||||
{Object.keys(pricingDraft).length} row{Object.keys(pricingDraft).length === 1 ? "" : "s"} with unsaved pricing changes.
|
||||
{Object.keys(pricingDraft).length} row{Object.keys(pricingDraft).length === 1 ? "" : "s"} with unsaved changes.
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => setPricingDraft({})} data-testid="discard-pricing">
|
||||
|
|
@ -582,13 +732,24 @@ function StockRow({
|
|||
showPricingColumns: boolean;
|
||||
editPricing: boolean;
|
||||
draft:
|
||||
| { costPrice?: number; sellingPrice?: number; markup?: number; sellingLocked?: boolean }
|
||||
| {
|
||||
costPrice?: number;
|
||||
sellingPrice?: number;
|
||||
markup?: number;
|
||||
sellingLocked?: boolean;
|
||||
currentQty?: number;
|
||||
minQty?: number;
|
||||
targetQty?: number;
|
||||
}
|
||||
| undefined;
|
||||
onDraftChange: (patch: {
|
||||
costPrice?: number;
|
||||
sellingPrice?: number;
|
||||
markup?: number;
|
||||
sellingLocked?: boolean;
|
||||
currentQty?: number;
|
||||
minQty?: number;
|
||||
targetQty?: number;
|
||||
}) => void;
|
||||
dirty: boolean;
|
||||
onOpen: () => void;
|
||||
|
|
@ -600,22 +761,37 @@ function StockRow({
|
|||
const formatPercent = (n: number | undefined) =>
|
||||
typeof n === "number" && n > 0 ? `${n}%` : "—";
|
||||
|
||||
// In edit mode we render editable cells for current / min /
|
||||
// target too — the same draft state holds pricing + qty
|
||||
// patches and the apply bar commits both. Stock is whole-unit
|
||||
// by convention (matches `stock/items/[id]/adjust/route.ts`),
|
||||
// so the inputs are step=1 and min=0.
|
||||
const draftCurrent = draft?.currentQty ?? Number(item.current_qty ?? 0);
|
||||
const draftMin = draft?.minQty ?? Number(item.min_qty ?? 0);
|
||||
const draftTarget =
|
||||
draft?.targetQty ?? Number(item.target_qty ?? 0);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onOpen}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onOpen();
|
||||
}
|
||||
}}
|
||||
role={editPricing ? undefined : "button"}
|
||||
tabIndex={editPricing ? undefined : 0}
|
||||
onClick={editPricing ? undefined : onOpen}
|
||||
onKeyDown={
|
||||
editPricing
|
||||
? undefined
|
||||
: (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onOpen();
|
||||
}
|
||||
}
|
||||
}
|
||||
className={cn(
|
||||
"cursor-pointer hover:bg-accent/40 focus-visible:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
|
||||
!editPricing &&
|
||||
"cursor-pointer hover:bg-accent/40 focus-visible:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
|
||||
dirty && "bg-amber-50",
|
||||
)}
|
||||
aria-label={`Open ${item.name}`}
|
||||
aria-label={editPricing ? undefined : `Open ${item.name}`}
|
||||
>
|
||||
<TableCell className="font-mono text-xs">{item.sku || "—"}</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
|
|
@ -627,26 +803,91 @@ function StockRow({
|
|||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{supplierName}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{Number(item.current_qty ?? 0)}
|
||||
{item.unit && (
|
||||
<span className="text-xs text-muted-foreground ml-1">{item.unit}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-muted-foreground">
|
||||
{Number(item.min_qty ?? 0)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-muted-foreground">
|
||||
{item.target_qty !== undefined && item.target_qty !== null ? (
|
||||
Number(item.target_qty) > 0 ? (
|
||||
<span>{Number(item.target_qty)}</span>
|
||||
) : (
|
||||
<span className="italic">—</span>
|
||||
)
|
||||
) : (
|
||||
<span className="italic">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
{editPricing ? (
|
||||
<>
|
||||
<TableCell
|
||||
className="text-right"
|
||||
// Stop click bubbling so the row's button handler (when
|
||||
// not in edit mode) can't navigate to the detail page.
|
||||
// Defence in depth — the row handler is also gated on
|
||||
// `!editPricing` upstream.
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Input
|
||||
aria-label={`Current qty for ${item.sku ?? item.name}`}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
step="1"
|
||||
min="0"
|
||||
className="h-8 text-right tabular-nums"
|
||||
value={draftCurrent || ""}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value === "" ? 0 : Number(e.target.value);
|
||||
onDraftChange({ currentQty: v });
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="text-right"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Input
|
||||
aria-label={`Min qty for ${item.sku ?? item.name}`}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
step="1"
|
||||
min="0"
|
||||
className="h-8 text-right tabular-nums"
|
||||
value={draftMin || ""}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value === "" ? 0 : Number(e.target.value);
|
||||
onDraftChange({ minQty: v });
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="text-right"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Input
|
||||
aria-label={`Target qty for ${item.sku ?? item.name}`}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
step="1"
|
||||
min="0"
|
||||
className="h-8 text-right tabular-nums"
|
||||
value={draftTarget || ""}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value === "" ? 0 : Number(e.target.value);
|
||||
onDraftChange({ targetQty: v });
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{Number(item.current_qty ?? 0)}
|
||||
{item.unit && (
|
||||
<span className="text-xs text-muted-foreground ml-1">{item.unit}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-muted-foreground">
|
||||
{Number(item.min_qty ?? 0)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-muted-foreground">
|
||||
{item.target_qty !== undefined && item.target_qty !== null ? (
|
||||
Number(item.target_qty) > 0 ? (
|
||||
<span>{Number(item.target_qty)}</span>
|
||||
) : (
|
||||
<span className="italic">—</span>
|
||||
)
|
||||
) : (
|
||||
<span className="italic">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</>
|
||||
)}
|
||||
{showPricingColumns && !editPricing && (
|
||||
<>
|
||||
<TableCell className="text-right tabular-nums text-muted-foreground">
|
||||
|
|
@ -677,6 +918,74 @@ function StockRow({
|
|||
);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* SortHeader — clickable column header that cycles `none → asc →
|
||||
* desc → none` on each click. Used by the cost / markup / selling
|
||||
* columns on `/stock` so the operator can re-sort the result list
|
||||
* before they start editing.
|
||||
*
|
||||
* Visual rules:
|
||||
* - inactive: muted icon (`ArrowUpDown`) next to the label.
|
||||
* - active asc: solid `ArrowUp`, full opacity.
|
||||
* - active desc: solid `ArrowDown`, full opacity.
|
||||
*
|
||||
* Implementation notes:
|
||||
* - It's a real `<button>` so keyboard users can Tab to it and
|
||||
* press Enter / Space (the TableHead itself is non-interactive).
|
||||
* `type="button"` prevents any future form wrapper from treating
|
||||
* it as a submit.
|
||||
* - `aria-sort` on the TableHead would be the canonical a11y
|
||||
* signal — we don't have a TableHead handle here, but the
|
||||
* button's `aria-label` calls out the current sort state so
|
||||
* screen readers still get the gist.
|
||||
* ========================================================================== */
|
||||
function SortHeader({
|
||||
label,
|
||||
sortKey,
|
||||
activeKey,
|
||||
activeDir,
|
||||
onCycle,
|
||||
className,
|
||||
testId,
|
||||
}: {
|
||||
label: string;
|
||||
sortKey: "cost_price" | "markup" | "selling_price";
|
||||
activeKey: "cost_price" | "markup" | "selling_price" | null;
|
||||
activeDir: "asc" | "desc";
|
||||
onCycle: (key: "cost_price" | "markup" | "selling_price") => void;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
}) {
|
||||
const isActive = activeKey === sortKey;
|
||||
const Icon = !isActive ? ArrowUpDown : activeDir === "asc" ? ArrowUp : ArrowDown;
|
||||
const ariaLabel = isActive
|
||||
? `Sort by ${label} (currently ${activeDir === "asc" ? "ascending" : "descending"}, click to ${
|
||||
activeDir === "asc" ? "switch to descending" : "clear sort"
|
||||
})`
|
||||
: `Sort by ${label}`;
|
||||
return (
|
||||
<TableHead className={cn("text-right p-0", className)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCycle(sortKey)}
|
||||
aria-label={ariaLabel}
|
||||
data-testid={testId}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-end gap-1 w-full h-full px-3 py-2 text-xs font-medium uppercase tracking-wide",
|
||||
"hover:bg-accent/40 focus-visible:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset rounded-none",
|
||||
isActive ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span>{label}</span>
|
||||
<Icon
|
||||
aria-hidden="true"
|
||||
className={cn("h-3.5 w-3.5", isActive ? "opacity-100" : "opacity-50")}
|
||||
/>
|
||||
</button>
|
||||
</TableHead>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* PricingEditCells — inline edit inputs for the /stock list when the
|
||||
* operator has clicked "Edit pricing".
|
||||
|
|
@ -720,7 +1029,15 @@ function PricingEditCells({
|
|||
|
||||
return (
|
||||
<>
|
||||
<TableCell className="text-right">
|
||||
<TableCell
|
||||
className="text-right"
|
||||
// Stop click bubbling so the row's button handler can't navigate
|
||||
// to the detail page when the operator is trying to focus the
|
||||
// cost input. Defence in depth — the row handler is also gated
|
||||
// on `!editPricing` upstream, but this guarantees the input
|
||||
// always wins the click regardless of what the row does later.
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Input
|
||||
aria-label={`Cost for ${item.sku ?? item.name}`}
|
||||
type="number"
|
||||
|
|
@ -735,7 +1052,10 @@ function PricingEditCells({
|
|||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<TableCell
|
||||
className="text-right"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Input
|
||||
aria-label={`Markup for ${item.sku ?? item.name}`}
|
||||
type="number"
|
||||
|
|
@ -750,7 +1070,10 @@ function PricingEditCells({
|
|||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<TableCell
|
||||
className="text-right"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Input
|
||||
aria-label={`Selling for ${item.sku ?? item.name}`}
|
||||
type="number"
|
||||
|
|
|
|||
218
src/app/api/v1/stock/items/set-quantities/route.test.ts
Normal file
218
src/app/api/v1/stock/items/set-quantities/route.test.ts
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* Tests for `POST /api/v1/stock/items/set-quantities`.
|
||||
*
|
||||
* The route is a thin HTTP wrapper over
|
||||
* `updateStockItemsQuantities` — the real coverage of the
|
||||
* service-layer behaviour lives in
|
||||
* `src/lib/server/stock-quantities-service.test.ts`. These tests
|
||||
* focus on HTTP-shape concerns: request validation, status codes,
|
||||
* and JSON body shape.
|
||||
*/
|
||||
|
||||
// ── Mocks ────────────────────────────────────────────────────────────────
|
||||
|
||||
const getOne = vi.fn();
|
||||
const update = vi.fn();
|
||||
const ledgerCreate = vi.fn();
|
||||
|
||||
vi.mock("@/lib/server/pocketbase-admin", () => ({
|
||||
getAdminPocketBase: vi.fn().mockResolvedValue({
|
||||
collection: (name: string) => {
|
||||
if (name === "stock_items") {
|
||||
return { getOne, update };
|
||||
}
|
||||
if (name === "stock_ledger") {
|
||||
return { create: ledgerCreate };
|
||||
}
|
||||
throw new Error(`unexpected collection ${name}`);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
import { POST } from "./route";
|
||||
|
||||
const VALID_ID = "abcdefghijklmno";
|
||||
|
||||
function postRequest(body: unknown) {
|
||||
return new NextRequest(
|
||||
"http://localhost/api/v1/stock/items/set-quantities",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("POST /api/v1/stock/items/set-quantities — validation", () => {
|
||||
it("returns 400 when body is not valid JSON", async () => {
|
||||
const req = new NextRequest(
|
||||
"http://localhost/api/v1/stock/items/set-quantities",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "not-json",
|
||||
},
|
||||
);
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.code).toBe("invalid_json");
|
||||
});
|
||||
|
||||
it("returns 400 when updates is empty", async () => {
|
||||
const res = await POST(
|
||||
postRequest({ updates: [], actor: { id: "u1" } }),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.code).toBe("validation_error");
|
||||
});
|
||||
|
||||
it("returns 400 when updates is not an array", async () => {
|
||||
const res = await POST(
|
||||
postRequest({ updates: "not-an-array", actor: { id: "u1" } }),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when actor.id is missing", async () => {
|
||||
const res = await POST(
|
||||
postRequest({
|
||||
updates: [{ id: VALID_ID, currentQty: 5 }],
|
||||
actor: { name: "NoId" },
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when actor is missing entirely", async () => {
|
||||
const res = await POST(
|
||||
postRequest({ updates: [{ id: VALID_ID, currentQty: 5 }] }),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when an update.id is not a valid PB id", async () => {
|
||||
const res = await POST(
|
||||
postRequest({
|
||||
updates: [{ id: "not-a-valid-pb-id", currentQty: 5 }],
|
||||
actor: { id: "u1" },
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.code).toBe("validation_error");
|
||||
expect(body.message).toMatch(/15-char PB id/);
|
||||
});
|
||||
|
||||
it("returns 400 when more than 100 updates are submitted", async () => {
|
||||
const updates = Array.from({ length: 101 }, (_, i) => ({
|
||||
id: `aaaaaaaaaaaaaaaa`.slice(0, 15),
|
||||
currentQty: i,
|
||||
}));
|
||||
const res = await POST(
|
||||
postRequest({ updates, actor: { id: "u1" } }),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.message).toMatch(/At most 100 updates/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/v1/stock/items/set-quantities — happy path", () => {
|
||||
it("returns 200 with the service's per-row results", async () => {
|
||||
const before = { id: VALID_ID, current_qty: 5, min_qty: 2, target_qty: 10 };
|
||||
const updated = { ...before, current_qty: 8 };
|
||||
getOne.mockResolvedValueOnce(before);
|
||||
update.mockResolvedValueOnce(updated);
|
||||
ledgerCreate.mockResolvedValueOnce({ id: "ledger1" });
|
||||
|
||||
const res = await POST(
|
||||
postRequest({
|
||||
updates: [{ id: VALID_ID, currentQty: 8 }],
|
||||
actor: { id: "u1", name: "Gary" },
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.summary).toEqual({ total: 1, succeeded: 1, failed: 0 });
|
||||
expect(body.results[0]).toMatchObject({
|
||||
id: VALID_ID,
|
||||
ok: true,
|
||||
previous: { current_qty: 5, min_qty: 2, target_qty: 10 },
|
||||
});
|
||||
});
|
||||
|
||||
it("writes a stock_ledger row before updating stock_items when currentQty changes", async () => {
|
||||
const before = { id: VALID_ID, current_qty: 5 };
|
||||
const updated = { ...before, current_qty: 10 };
|
||||
getOne.mockResolvedValueOnce(before);
|
||||
update.mockResolvedValueOnce(updated);
|
||||
ledgerCreate.mockResolvedValueOnce({ id: "ledger1" });
|
||||
|
||||
await POST(
|
||||
postRequest({
|
||||
updates: [{ id: VALID_ID, currentQty: 10 }],
|
||||
actor: { id: "u1", name: "Gary" },
|
||||
}),
|
||||
);
|
||||
|
||||
// Ledger write should be the FIRST call after getOne. The
|
||||
// ordering is enforced by the service (audit-before-cache) so
|
||||
// a crash between the two writes leaves the audit trail
|
||||
// ahead of the cached total.
|
||||
expect(ledgerCreate).toHaveBeenCalledTimes(1);
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
// ledgerCreate mock is called before update mock — verify by
|
||||
// checking call order via mock.invocationCallOrder.
|
||||
const ledgerOrder = ledgerCreate.mock.invocationCallOrder[0];
|
||||
const updateOrder = update.mock.invocationCallOrder[0];
|
||||
expect(ledgerOrder).toBeLessThan(updateOrder);
|
||||
});
|
||||
|
||||
it("does NOT call the ledger mock when only min/target change", async () => {
|
||||
const before = { id: VALID_ID, current_qty: 5, min_qty: 2, target_qty: 10 };
|
||||
const updated = { ...before, min_qty: 3, target_qty: 12 };
|
||||
getOne.mockResolvedValueOnce(before);
|
||||
update.mockResolvedValueOnce(updated);
|
||||
|
||||
await POST(
|
||||
postRequest({
|
||||
updates: [{ id: VALID_ID, minQty: 3, targetQty: 12 }],
|
||||
actor: { id: "u1" },
|
||||
}),
|
||||
);
|
||||
expect(ledgerCreate).not.toHaveBeenCalled();
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/v1/stock/items/set-quantities — pb admin unavailable", () => {
|
||||
it("returns 500 when getAdminPocketBase throws", async () => {
|
||||
// Re-mock for this specific test only.
|
||||
const { getAdminPocketBase } = await import("@/lib/server/pocketbase-admin");
|
||||
(getAdminPocketBase as unknown as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||
new Error("admin creds missing"),
|
||||
);
|
||||
const res = await POST(
|
||||
postRequest({
|
||||
updates: [{ id: VALID_ID, currentQty: 5 }],
|
||||
actor: { id: "u1" },
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(500);
|
||||
const body = await res.json();
|
||||
expect(body.code).toBe("pb_error");
|
||||
expect(body.message).toMatch(/admin creds missing/);
|
||||
});
|
||||
});
|
||||
124
src/app/api/v1/stock/items/set-quantities/route.ts
Normal file
124
src/app/api/v1/stock/items/set-quantities/route.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { updateStockItemsQuantities } from "@/lib/server/stock-quantities-service";
|
||||
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
||||
|
||||
/**
|
||||
* POST /api/v1/stock/items/set-quantities
|
||||
*
|
||||
* Bulk-update `current_qty` / `min_qty` / `target_qty` on a set of
|
||||
* `stock_items` rows. Mirrors the shape of `/set-pricing` so the
|
||||
* `/stock` UI can drive both from a single draft + apply bar.
|
||||
*
|
||||
* Validation:
|
||||
* - `updates` is a non-empty array of `{ id, currentQty?, minQty?, targetQty? }`
|
||||
* - Each `id` is a 15-char PB id
|
||||
* - Each numeric field, if supplied, is a finite number >= 0
|
||||
* - `actor.id` is required (audit trail attribution)
|
||||
*
|
||||
* Side effects (per-row, conditional):
|
||||
* - `current_qty` changes write a `stock_ledger` row first so the
|
||||
* audit trail stays honest. See the docstring on
|
||||
* `updateStockItemsQuantities` for the rationale.
|
||||
* - `min_qty` / `target_qty` are simple patch-as-you-go fields.
|
||||
*
|
||||
* Failures are returned per-row inside `results[]` with a stable
|
||||
* `reason` so the UI can render a focused error toast. The route
|
||||
* itself returns 200 even when every row failed — the operator
|
||||
* wants to see WHICH rows failed and WHY, not a generic 4xx/5xx.
|
||||
*/
|
||||
|
||||
const MAX_UPDATES = 100;
|
||||
|
||||
const PB_ID_RE = /^[a-z0-9]{15}$/;
|
||||
|
||||
type Body = {
|
||||
updates?: Array<{
|
||||
id?: unknown;
|
||||
currentQty?: unknown;
|
||||
minQty?: unknown;
|
||||
targetQty?: unknown;
|
||||
}>;
|
||||
actor?: { id?: unknown; name?: unknown };
|
||||
};
|
||||
|
||||
export async function POST(req: Request) {
|
||||
let body: Body;
|
||||
try {
|
||||
body = (await req.json()) as Body;
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ code: "invalid_json", message: "Body is not valid JSON" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!Array.isArray(body.updates) || body.updates.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ code: "validation_error", message: "updates must be a non-empty array" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (body.updates.length > MAX_UPDATES) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
code: "validation_error",
|
||||
message: `At most ${MAX_UPDATES} updates per call`,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (!body.actor || typeof body.actor.id !== "string" || !body.actor.id) {
|
||||
return NextResponse.json(
|
||||
{ code: "validation_error", message: "actor.id is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const updates: Array<{
|
||||
id: string;
|
||||
currentQty?: number;
|
||||
minQty?: number;
|
||||
targetQty?: number;
|
||||
}> = [];
|
||||
for (let i = 0; i < body.updates.length; i++) {
|
||||
const u = body.updates[i];
|
||||
if (!u || typeof u.id !== "string" || !PB_ID_RE.test(u.id)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
code: "validation_error",
|
||||
message: `updates[${i}].id must be a 15-char PB id`,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const out: {
|
||||
id: string;
|
||||
currentQty?: number;
|
||||
minQty?: number;
|
||||
targetQty?: number;
|
||||
} = { id: u.id };
|
||||
if (u.currentQty !== undefined) out.currentQty = Number(u.currentQty);
|
||||
if (u.minQty !== undefined) out.minQty = Number(u.minQty);
|
||||
if (u.targetQty !== undefined) out.targetQty = Number(u.targetQty);
|
||||
updates.push(out);
|
||||
}
|
||||
|
||||
let pb;
|
||||
try {
|
||||
pb = await getAdminPocketBase();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return NextResponse.json(
|
||||
{ code: "pb_error", message },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const result = await updateStockItemsQuantities({
|
||||
pb,
|
||||
updates,
|
||||
actor: { id: body.actor.id, name: String(body.actor.name ?? body.actor.id) },
|
||||
});
|
||||
|
||||
return NextResponse.json(result, { status: 200 });
|
||||
}
|
||||
329
src/lib/server/stock-quantities-service.test.ts
Normal file
329
src/lib/server/stock-quantities-service.test.ts
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
286
src/lib/server/stock-quantities-service.ts
Normal file
286
src/lib/server/stock-quantities-service.ts
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
import type { StockItem } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* Stock Quantities service — backs the inline `current_qty` /
|
||||
* `min_qty` / `target_qty` edit path on `/stock`.
|
||||
*
|
||||
* Mirrors `stock-pricing-service.ts` shape: whitelist-validated
|
||||
* patches, per-row result rows with stable failure reasons, and a
|
||||
* `summary` for the toast. The big difference: when `current_qty`
|
||||
* changes we MUST write a `stock_ledger` row first so the audit
|
||||
* trail stays honest. `min_qty` and `target_qty` are simple
|
||||
* patch-as-you-go fields — no ledger row.
|
||||
*
|
||||
* Why a separate service instead of folding into
|
||||
* `updateStockItemsPricing`?
|
||||
*
|
||||
* - Inventory writes and catalogue writes have different
|
||||
* invariants. Conflating them invites a future bug where a
|
||||
* pricing fix accidentally drops a ledger row (or vice versa).
|
||||
* - The two callers in `/stock`'s `applyDraft` already need to
|
||||
* know which rows went where so the toast can say "Saved 5
|
||||
* prices · 2 qty" instead of swallowing the distinction.
|
||||
* - Tests stay focused: a pricing-only test never touches the
|
||||
* ledger mock, and a qty-only test never pretends to know
|
||||
* about markup.
|
||||
*
|
||||
* Atomicity: a single row's `stock_items.update` + `stock_ledger.create`
|
||||
* pair is NOT transactional (PB JS client doesn't expose server-side
|
||||
* transactions). We deliberately write the ledger row FIRST so a
|
||||
* crash between the two writes leaves the audit trail ahead of
|
||||
* the cached total — the same pattern `stock-surplus.ts` and
|
||||
* `stock-booking-service.ts` use. A future drift-repair job can
|
||||
* rebuild `current_qty` from `SUM(change_qty)` if the second
|
||||
* write ever fails.
|
||||
*/
|
||||
|
||||
export type StockQuantityUpdate = {
|
||||
id: string;
|
||||
currentQty?: number;
|
||||
minQty?: number;
|
||||
targetQty?: number;
|
||||
};
|
||||
|
||||
export type UpdateStockItemsQuantitiesInput = {
|
||||
/** PB client; passed in so tests can swap a stub. */
|
||||
pb: StockItemsPb;
|
||||
updates: StockQuantityUpdate[];
|
||||
actor: { id: string; name: string };
|
||||
};
|
||||
|
||||
export type StockItemsPb = {
|
||||
collection: (id: string) => {
|
||||
getOne: <T = unknown>(id: string, opts?: Record<string, unknown>) => Promise<T>;
|
||||
update: (id: string, patch: Record<string, unknown>) => Promise<unknown>;
|
||||
create: (data: Record<string, unknown>) => Promise<unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
export type UpdateStockItemsQuantitiesFailureReason =
|
||||
| "forbidden_field"
|
||||
| "validation_error"
|
||||
| "not_found"
|
||||
| "pb_error";
|
||||
|
||||
export type StockItemQuantitySnapshot = {
|
||||
current_qty?: number;
|
||||
min_qty?: number;
|
||||
target_qty?: number;
|
||||
};
|
||||
|
||||
export type UpdateStockItemsQuantitiesResultRow =
|
||||
| {
|
||||
id: string;
|
||||
ok: true;
|
||||
item: StockItem;
|
||||
previous: StockItemQuantitySnapshot;
|
||||
/** PB id of the ledger row we wrote (only set when current_qty changed). */
|
||||
ledgerId?: string;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
ok: false;
|
||||
reason: UpdateStockItemsQuantitiesFailureReason;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type UpdateStockItemsQuantitiesResult = {
|
||||
results: UpdateStockItemsQuantitiesResultRow[];
|
||||
summary: { total: number; succeeded: number; failed: number };
|
||||
};
|
||||
|
||||
const PATCHABLE_KEYS = ["currentQty", "minQty", "targetQty"] as const;
|
||||
|
||||
/**
|
||||
* Round a number to an integer (or throw if it's not finite).
|
||||
* Stock quantities are whole units by convention — see the same
|
||||
* `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.
|
||||
*/
|
||||
function coerceNonNegativeInt(
|
||||
key: string,
|
||||
value: unknown,
|
||||
): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
||||
throw new Error(`${key} must be a finite number >= 0`);
|
||||
}
|
||||
return Math.trunc(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a batch of `current_qty` / `min_qty` / `target_qty` patches
|
||||
* to `stock_items`, writing a `stock_ledger` row whenever
|
||||
* `current_qty` actually moves. See the file-level docstring for
|
||||
* the audit-first / cache-second ordering rationale.
|
||||
*
|
||||
* Per-row failures are surfaced in `results[]` with stable `reason`
|
||||
* codes; the route maps those to HTTP status. The function never
|
||||
* throws on per-row errors — a broken row in the middle of the
|
||||
* batch should not abort the rest.
|
||||
*/
|
||||
export async function updateStockItemsQuantities(
|
||||
input: UpdateStockItemsQuantitiesInput,
|
||||
): Promise<UpdateStockItemsQuantitiesResult> {
|
||||
const results: UpdateStockItemsQuantitiesResultRow[] = [];
|
||||
|
||||
for (const update of input.updates) {
|
||||
// 1. Whitelist + type validation. Reject the row on any
|
||||
// forbidden / non-finite / negative field — partial patches
|
||||
// with bad fields are confusing downstream (the operator
|
||||
// sees "Saved" but a field silently dropped), so we refuse
|
||||
// the whole row.
|
||||
let validationFailed = false;
|
||||
for (const key of Object.keys(update) as (keyof StockQuantityUpdate)[]) {
|
||||
if (
|
||||
key !== "id" &&
|
||||
!(PATCHABLE_KEYS as readonly string[]).includes(key)
|
||||
) {
|
||||
results.push({
|
||||
id: update.id,
|
||||
ok: false,
|
||||
reason: "forbidden_field",
|
||||
message: `forbidden_field: ${key}`,
|
||||
});
|
||||
validationFailed = true;
|
||||
break;
|
||||
}
|
||||
const value = (update as Record<string, unknown>)[key];
|
||||
if (key !== "id" && value !== undefined) {
|
||||
try {
|
||||
coerceNonNegativeInt(key, value);
|
||||
} catch (err) {
|
||||
results.push({
|
||||
id: update.id,
|
||||
ok: false,
|
||||
reason: "validation_error",
|
||||
message: err instanceof Error ? err.message : `${key} invalid`,
|
||||
});
|
||||
validationFailed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (validationFailed) continue;
|
||||
|
||||
// 2. Load the existing row for previous-snapshot + not_found.
|
||||
let before: StockItem;
|
||||
try {
|
||||
before = (await input.pb
|
||||
.collection("stock_items")
|
||||
.getOne(update.id)) as StockItem;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (/not found|404/i.test(message)) {
|
||||
results.push({
|
||||
id: update.id,
|
||||
ok: false,
|
||||
reason: "not_found",
|
||||
message: `stock_items row ${update.id} not found`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
results.push({
|
||||
id: update.id,
|
||||
ok: false,
|
||||
reason: "pb_error",
|
||||
message: `Failed to load: ${message}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. Normalise the requested numbers. `Math.trunc` happens here
|
||||
// too — the route may have received floats from the form
|
||||
// input even though we typed the inputs as integers.
|
||||
const requestedCurrent =
|
||||
update.currentQty !== undefined ? Math.trunc(update.currentQty) : undefined;
|
||||
const requestedMin =
|
||||
update.minQty !== undefined ? Math.trunc(update.minQty) : undefined;
|
||||
const requestedTarget =
|
||||
update.targetQty !== undefined ? Math.trunc(update.targetQty) : undefined;
|
||||
|
||||
const beforeCurrent = Number(before.current_qty ?? 0);
|
||||
const beforeMin = Number(before.min_qty ?? 0);
|
||||
const beforeTarget = Number(before.target_qty ?? 0);
|
||||
|
||||
const currentMoved =
|
||||
requestedCurrent !== undefined && requestedCurrent !== beforeCurrent;
|
||||
|
||||
// 4. Write the ledger row FIRST when current_qty actually
|
||||
// moved. Audit-before-cache so a crash between the two
|
||||
// writes leaves the audit trail ahead of the cached total.
|
||||
// We skip the ledger write when the value didn't change
|
||||
// (idempotent re-apply) — emitting a zero-delta ledger row
|
||||
// would pollute the audit log with no-ops.
|
||||
let ledgerId: string | undefined;
|
||||
if (currentMoved && requestedCurrent !== undefined) {
|
||||
const delta = requestedCurrent - beforeCurrent;
|
||||
try {
|
||||
const created = await input.pb.collection("stock_ledger").create({
|
||||
stock_item: update.id,
|
||||
change_qty: delta,
|
||||
reason: "adjustment",
|
||||
reference_type: "manual",
|
||||
notes: `${input.actor.name} edited qty ${beforeCurrent} → ${requestedCurrent} from /stock`,
|
||||
created_by: input.actor.id,
|
||||
});
|
||||
ledgerId = (created as { id: string }).id;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
results.push({
|
||||
id: update.id,
|
||||
ok: false,
|
||||
reason: "pb_error",
|
||||
message: `Failed to write stock_ledger: ${message}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Patch the row. Only fields the caller actually passed get
|
||||
// written — a no-op patch (every requested field matches
|
||||
// the existing value) still hits PB but with an empty
|
||||
// patch object, which PB accepts as a no-op.
|
||||
const patch: Record<string, number> = {};
|
||||
if (requestedCurrent !== undefined) patch.current_qty = requestedCurrent;
|
||||
if (requestedMin !== undefined) patch.min_qty = requestedMin;
|
||||
if (requestedTarget !== undefined) patch.target_qty = requestedTarget;
|
||||
|
||||
let updated: StockItem;
|
||||
try {
|
||||
updated = (await input.pb
|
||||
.collection("stock_items")
|
||||
.update(update.id, patch)) as StockItem;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
results.push({
|
||||
id: update.id,
|
||||
ok: false,
|
||||
reason: "pb_error",
|
||||
message: `Failed to update: ${message}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const previous: StockItemQuantitySnapshot = {
|
||||
current_qty: beforeCurrent,
|
||||
min_qty: beforeMin,
|
||||
target_qty: beforeTarget,
|
||||
};
|
||||
|
||||
results.push({
|
||||
id: update.id,
|
||||
ok: true,
|
||||
item: updated,
|
||||
previous,
|
||||
...(ledgerId ? { ledgerId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
const summary = {
|
||||
total: input.updates.length,
|
||||
succeeded: results.filter((r) => r.ok).length,
|
||||
failed: results.filter((r) => !r.ok).length,
|
||||
};
|
||||
return { results, summary };
|
||||
}
|
||||
Loading…
Reference in a new issue