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.
543 lines
No EOL
19 KiB
TypeScript
543 lines
No EOL
19 KiB
TypeScript
/**
|
|
* 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);
|
|
}); |