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

What's new:

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

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

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);
});