/** * 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; }): 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; 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>, args: { sku: string | null; limit: number | null }, ): Promise { 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>, ): Promise { 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 { 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 { const opts = parseCli(process.argv.slice(2)); await runApplyDefaultMarkup(opts); } main().catch((err) => { console.error("FATAL:", err); process.exit(1); });