676 lines
24 KiB
TypeScript
676 lines
24 KiB
TypeScript
|
|
/**
|
||
|
|
* Backfill: mirror `price_list` rows onto `stock_items` rows whose
|
||
|
|
* shelf-price is still zero.
|
||
|
|
*
|
||
|
|
* Context
|
||
|
|
* ───────
|
||
|
|
* The till reads `stock_items.selling_price` but the team originally
|
||
|
|
* intended prices to live on `price_list`. The 2026-09-14 sync
|
||
|
|
* (price-list-service.syncPriceListToStockItem) closes the gap for
|
||
|
|
* *future* `addPriceListItems` calls — but ~393 stock rows already
|
||
|
|
* exist with `selling_price = 0` and a clean match waiting in
|
||
|
|
* `price_list`. This script is the one-shot operator tool to backfill
|
||
|
|
* them in one go.
|
||
|
|
*
|
||
|
|
* It also addresses a secondary gap: ~177 of those stock rows have
|
||
|
|
* `cost_price = 0` too (created in the pre-Fix-#1 gap). The same
|
||
|
|
* `price_list` match carries the supplier cost, so this script can
|
||
|
|
* seed `cost_price` AND bootstrap `cost_history` in one round-trip.
|
||
|
|
*
|
||
|
|
* What it does
|
||
|
|
* ────────────
|
||
|
|
* 1. Read every `stock_items` row where `selling_price = 0` (the
|
||
|
|
* --sku flag narrows this to one row for debugging).
|
||
|
|
* 2. For each row, look up `price_list` by `sku_normalised`
|
||
|
|
* (using `normaliseSku` from price-list-match.ts) AND
|
||
|
|
* `selling_price > 0` — the same canonical-form dedupe the rest
|
||
|
|
* of the catalogue code uses, so "JEITD-06" and "JEITD06" both
|
||
|
|
* hit the same row.
|
||
|
|
* 3. If a `price_list` row matches:
|
||
|
|
* - Propose `stock_items.selling_price` + `markup` patches
|
||
|
|
* when the stock values are zero (never overwrite a
|
||
|
|
* non-zero shelf price — same idempotency rule as the
|
||
|
|
* existing sync helper).
|
||
|
|
* - Also propose a `cost_price` patch + bootstrap one
|
||
|
|
* `cost_history` entry when the stock `cost_price` is zero
|
||
|
|
* AND the `price_list` row has a positive `cost_price`.
|
||
|
|
* 4. If multiple `price_list` rows match the same normalised SKU
|
||
|
|
* (rare; data-quality issue), take the newest and log a
|
||
|
|
* `multi_match` warning.
|
||
|
|
* 5. If no `price_list` row matches, report as `no_match` so the
|
||
|
|
* operator can resolve later (probably add a price_list row or
|
||
|
|
* fix the SKU on the stock row).
|
||
|
|
*
|
||
|
|
* Idempotency
|
||
|
|
* ───────────
|
||
|
|
* - Never overwrite a non-zero `stock_items.selling_price`.
|
||
|
|
* - Never overwrite a non-zero `stock_items.cost_price` (when
|
||
|
|
* we're proposing the cost-side sibling patch).
|
||
|
|
* - Always APPEND to `cost_history` when patching `cost_price`
|
||
|
|
* for the first time — matches the Quick Draft bootstrap shape
|
||
|
|
* so the accountant's later `bookStockFromInvoice` can
|
||
|
|
* recognise the seed entry.
|
||
|
|
* - Re-running the script produces no further changes once every
|
||
|
|
* row has either a match (patched) or a deliberate `no_match`.
|
||
|
|
*
|
||
|
|
* Modes
|
||
|
|
* ─────
|
||
|
|
* - Default (no flag): dry-run. Lists every candidate patch; does
|
||
|
|
* not touch PB. This is the operator's safety net — review the
|
||
|
|
* list, then re-run with --commit.
|
||
|
|
* - --commit: actually PATCH the matched stock_items rows.
|
||
|
|
* - --limit=N: process at most N stock rows (after --sku filter).
|
||
|
|
* Useful for a smoke test on the first 10 rows.
|
||
|
|
* - --sku=ABC: process only the single stock row whose `sku`
|
||
|
|
* equals ABC (case-insensitive, whitespace-trimmed).
|
||
|
|
*
|
||
|
|
* Run with:
|
||
|
|
* npx tsx scripts/backfill-stock-prices.ts # dry-run
|
||
|
|
* npx tsx scripts/backfill-stock-prices.ts --commit # write
|
||
|
|
* npx tsx scripts/backfill-stock-prices.ts --limit=10 # cap rows
|
||
|
|
* npx tsx scripts/backfill-stock-prices.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 type { StockCostHistoryEntry } from "@/lib/types";
|
||
|
|
import { getAdminPocketBase } from "../src/lib/server/pocketbase-admin";
|
||
|
|
import { normaliseSku, escapeForPbString } from "../src/lib/server/price-list-match";
|
||
|
|
|
||
|
|
// ─── 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(`Backfill stock_items prices from price_list.
|
||
|
|
|
||
|
|
Usage:
|
||
|
|
tsx scripts/backfill-stock-prices.ts # dry-run (default)
|
||
|
|
tsx scripts/backfill-stock-prices.ts --commit # write to PB
|
||
|
|
tsx scripts/backfill-stock-prices.ts --commit --limit=10 # cap rows (smoke)
|
||
|
|
tsx scripts/backfill-stock-prices.ts --sku=ABC123 # single SKU
|
||
|
|
|
||
|
|
Flags:
|
||
|
|
--commit Actually PATCH stock_items. Default is dry-run.
|
||
|
|
--limit=N Process at most N stock rows.
|
||
|
|
--sku=ABC Only the stock row whose sku matches ABC.
|
||
|
|
--help, -h Show this help.
|
||
|
|
|
||
|
|
Output: one line per scanned row + a totals block at the bottom.`);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── Types ─────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
/** Subset of a `stock_items` row the decision function needs. */
|
||
|
|
export type BackfillStockSnapshot = {
|
||
|
|
id: string;
|
||
|
|
sku: string;
|
||
|
|
selling_price?: number | null;
|
||
|
|
markup?: number | null;
|
||
|
|
cost_price?: number | null;
|
||
|
|
cost_history?: unknown;
|
||
|
|
};
|
||
|
|
|
||
|
|
/** Subset of a `price_list` row the decision function needs. */
|
||
|
|
export type BackfillPriceListCandidate = {
|
||
|
|
id: string;
|
||
|
|
sku: string;
|
||
|
|
sku_normalised: string;
|
||
|
|
cost_price: number;
|
||
|
|
selling_price: number;
|
||
|
|
markup: number;
|
||
|
|
updated?: string;
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The pure decision: given a stock_item snapshot and the price_list
|
||
|
|
* match (or null when no match exists), what should we patch?
|
||
|
|
*
|
||
|
|
* Returns a discriminated union:
|
||
|
|
* - `patch` — at least one field will change
|
||
|
|
* - `skipped_idempotent` — defensive skip (the row already had a
|
||
|
|
* non-zero value where we wanted to
|
||
|
|
* write; never clobber)
|
||
|
|
* - `no_match` — no price_list row matches; nothing to do
|
||
|
|
*
|
||
|
|
* Pure: no PB calls, no Date.now() side effects beyond the
|
||
|
|
* `purchased_at` stamp on a potential cost_history entry. That
|
||
|
|
* timestamp is exposed as a separate argument so tests can pin it
|
||
|
|
* deterministically; the script's runner passes `new Date().toISOString()`.
|
||
|
|
*/
|
||
|
|
export type BackfillDecision =
|
||
|
|
| {
|
||
|
|
kind: "patch";
|
||
|
|
stockItemId: string;
|
||
|
|
sku: string;
|
||
|
|
priceListId: string;
|
||
|
|
/** True when more than one price_list row matched the
|
||
|
|
* sku_normalised — the caller should log a warning. */
|
||
|
|
multiMatch: boolean;
|
||
|
|
patch: {
|
||
|
|
selling_price?: { from: number; to: number };
|
||
|
|
markup?: { from: number; to: number };
|
||
|
|
cost_price?: { from: number; to: number };
|
||
|
|
cost_history_append?: StockCostHistoryEntry;
|
||
|
|
};
|
||
|
|
}
|
||
|
|
| {
|
||
|
|
kind: "skipped_idempotent";
|
||
|
|
stockItemId: string;
|
||
|
|
sku: string;
|
||
|
|
priceListId: string;
|
||
|
|
reason: "selling_price_already_set";
|
||
|
|
}
|
||
|
|
| {
|
||
|
|
kind: "no_match";
|
||
|
|
stockItemId: string;
|
||
|
|
sku: string;
|
||
|
|
};
|
||
|
|
|
||
|
|
/** The patch fields carried on a `BackfillDecision` of kind `patch`. */
|
||
|
|
export type BackfillDecisionPatch = {
|
||
|
|
selling_price?: { from: number; to: number };
|
||
|
|
markup?: { from: number; to: number };
|
||
|
|
cost_price?: { from: number; to: number };
|
||
|
|
cost_history_append?: StockCostHistoryEntry;
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Pure decision function — exported for unit tests.
|
||
|
|
*
|
||
|
|
* Rules
|
||
|
|
* ─────
|
||
|
|
* - If `priceListCandidate` is null → `no_match`.
|
||
|
|
* - If `priceListCandidate.selling_price <= 0` → `no_match`
|
||
|
|
* (caller is supposed to filter these out at the PB level;
|
||
|
|
* we still guard here for safety).
|
||
|
|
* - If `stockItem.selling_price > 0` → `skipped_idempotent`
|
||
|
|
* (never clobber a shelf price already on the row).
|
||
|
|
* - Propose `selling_price` patch when stock has 0 AND candidate
|
||
|
|
* has positive value.
|
||
|
|
* - Propose `markup` patch when stock has 0 AND candidate has
|
||
|
|
* positive value.
|
||
|
|
* - Propose `cost_price` patch AND `cost_history_append` when
|
||
|
|
* stock `cost_price` is 0 AND candidate `cost_price` is
|
||
|
|
* positive. The cost_history entry uses the same shape as
|
||
|
|
* `stock-quick-draft-service` inline-create bootstrap: empty
|
||
|
|
* `purchase_id`, zero quantity, the stamp from `nowIso`.
|
||
|
|
*
|
||
|
|
* The PB-level filter (`selling_price > 0` on `price_list`) means
|
||
|
|
* a candidate will always have a sellable price — so once we
|
||
|
|
* reach the bottom of the function with `stock.selling_price = 0`,
|
||
|
|
* the `selling_price` patch is always at least proposed.
|
||
|
|
*/
|
||
|
|
export function decideBackfill(args: {
|
||
|
|
stockItem: BackfillStockSnapshot;
|
||
|
|
priceListCandidate: BackfillPriceListCandidate | null;
|
||
|
|
/** Multiple price_list rows matched the same sku_normalised
|
||
|
|
* (sorted newest-first; the caller picked one). Carried on the
|
||
|
|
* decision so the script can log the warning. */
|
||
|
|
multiMatch: boolean;
|
||
|
|
/** ISO timestamp used to stamp the bootstrap `cost_history`
|
||
|
|
* entry. Injected for testability — the runner passes
|
||
|
|
* `new Date().toISOString()`. */
|
||
|
|
nowIso: string;
|
||
|
|
}): BackfillDecision {
|
||
|
|
const { stockItem, priceListCandidate, multiMatch, nowIso } = args;
|
||
|
|
|
||
|
|
if (!priceListCandidate) {
|
||
|
|
return {
|
||
|
|
kind: "no_match",
|
||
|
|
stockItemId: stockItem.id,
|
||
|
|
sku: stockItem.sku,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
const stockSelling = Number(stockItem.selling_price ?? 0);
|
||
|
|
const stockMarkup = Number(stockItem.markup ?? 0);
|
||
|
|
const stockCost = Number(stockItem.cost_price ?? 0);
|
||
|
|
|
||
|
|
// Defensive: the PB filter should already exclude candidates
|
||
|
|
// with selling_price <= 0, but a race (or a manually-edited
|
||
|
|
// row) might surface one. Treat as no-match rather than
|
||
|
|
// silently writing 0.
|
||
|
|
if (!(Number(priceListCandidate.selling_price) > 0)) {
|
||
|
|
return {
|
||
|
|
kind: "no_match",
|
||
|
|
stockItemId: stockItem.id,
|
||
|
|
sku: stockItem.sku,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// Idempotency: the shelf price is already set on the stock
|
||
|
|
// row. We never overwrite — the till or a manual catalogue
|
||
|
|
// edit owns that number now.
|
||
|
|
if (stockSelling > 0) {
|
||
|
|
return {
|
||
|
|
kind: "skipped_idempotent",
|
||
|
|
stockItemId: stockItem.id,
|
||
|
|
sku: stockItem.sku,
|
||
|
|
priceListId: priceListCandidate.id,
|
||
|
|
reason: "selling_price_already_set",
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
const patch: BackfillDecisionPatch = {};
|
||
|
|
|
||
|
|
// Patch #1: selling_price.
|
||
|
|
const candidateSelling = Number(priceListCandidate.selling_price);
|
||
|
|
if (candidateSelling > 0) {
|
||
|
|
patch.selling_price = { from: stockSelling, to: candidateSelling };
|
||
|
|
}
|
||
|
|
|
||
|
|
// Patch #2: markup. Only when stock.markup is 0 AND
|
||
|
|
// candidate.markup is positive.
|
||
|
|
const candidateMarkup = Number(priceListCandidate.markup ?? 0);
|
||
|
|
if (stockMarkup <= 0 && candidateMarkup > 0) {
|
||
|
|
patch.markup = { from: stockMarkup, to: candidateMarkup };
|
||
|
|
}
|
||
|
|
|
||
|
|
// Patch #3: cost_price + cost_history. Only when stock.cost_price
|
||
|
|
// is 0 AND candidate.cost_price is positive. We APPEND to
|
||
|
|
// cost_history (rather than overwriting) — matches the Quick
|
||
|
|
// Draft bootstrap shape and lets the accountant's later
|
||
|
|
// `bookStockFromInvoice` recognise the seed entry as a
|
||
|
|
// "manual backfill" origin.
|
||
|
|
const candidateCost = Number(priceListCandidate.cost_price ?? 0);
|
||
|
|
if (stockCost <= 0 && candidateCost > 0) {
|
||
|
|
patch.cost_price = { from: stockCost, to: candidateCost };
|
||
|
|
patch.cost_history_append = {
|
||
|
|
cost_price: candidateCost,
|
||
|
|
quantity: 0,
|
||
|
|
purchased_at: nowIso,
|
||
|
|
purchase_id: "",
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
kind: "patch",
|
||
|
|
stockItemId: stockItem.id,
|
||
|
|
sku: stockItem.sku,
|
||
|
|
priceListId: priceListCandidate.id,
|
||
|
|
multiMatch,
|
||
|
|
patch,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── 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 decision. Used in both dry-run and
|
||
|
|
* --commit output so the operator can scan a dry-run and know
|
||
|
|
* exactly which rows would change before re-running with
|
||
|
|
* --commit.
|
||
|
|
*/
|
||
|
|
export function formatDecisionLine(args: {
|
||
|
|
decision: BackfillDecision;
|
||
|
|
/** "would_patch" in dry-run, "patched" in --commit. Other kinds
|
||
|
|
* are unchanged either way. */
|
||
|
|
verbWhenPatch: "would_patch" | "patched";
|
||
|
|
}): string {
|
||
|
|
const { decision, verbWhenPatch } = args;
|
||
|
|
const verb = (kind: "patch" | "skip") =>
|
||
|
|
kind === "patch" ? verbWhenPatch : "skipped_idempotent";
|
||
|
|
|
||
|
|
if (decision.kind === "no_match") {
|
||
|
|
return `no_match sku=${decision.sku}`;
|
||
|
|
}
|
||
|
|
if (decision.kind === "skipped_idempotent") {
|
||
|
|
return `${verb("skip")} sku=${decision.sku} match=price_list:${decision.priceListId} reason=${decision.reason}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
const parts: string[] = [];
|
||
|
|
parts.push(`${verb("patch")} sku=${decision.sku} match=price_list:${decision.priceListId}`);
|
||
|
|
if (decision.multiMatch) {
|
||
|
|
parts.push("(multi_match)");
|
||
|
|
}
|
||
|
|
const p = decision.patch;
|
||
|
|
if (p.selling_price) {
|
||
|
|
parts.push(`selling: ${fmtMoney(p.selling_price.from)} -> ${fmtMoney(p.selling_price.to)}`);
|
||
|
|
}
|
||
|
|
if (p.markup) {
|
||
|
|
parts.push(`markup: ${p.markup.from}% -> ${p.markup.to}%`);
|
||
|
|
}
|
||
|
|
if (p.cost_price) {
|
||
|
|
parts.push(`cost: ${fmtMoney(p.cost_price.from)} -> ${fmtMoney(p.cost_price.to)}`);
|
||
|
|
}
|
||
|
|
if (p.cost_history_append) {
|
||
|
|
parts.push(`cost_history+1`);
|
||
|
|
}
|
||
|
|
return parts.join(" ");
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── PB plumbing ───────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Read every `stock_items` row whose `selling_price = 0`. Filter
|
||
|
|
* further by --sku / --limit at the caller.
|
||
|
|
*
|
||
|
|
* `getFullList` paginates transparently so the whole ~393-row
|
||
|
|
* unpriced bucket comes back in one round-trip.
|
||
|
|
*/
|
||
|
|
async function listUnpricedStockItems(
|
||
|
|
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
||
|
|
args: { sku: string | null; limit: number | null },
|
||
|
|
): Promise<BackfillStockSnapshot[]> {
|
||
|
|
// NB: `stock_items` does NOT have a `sku_normalised` field (the
|
||
|
|
// previous price_list-service sync helper assumed one — that
|
||
|
|
// filter errors at PB). We only filter on the raw `sku` column
|
||
|
|
// here. Canonical-form matching ("JEITD-06" vs "JEITD06") happens
|
||
|
|
// client-side in `findPriceListCandidates`, where we look up
|
||
|
|
// `price_list` (which DOES have `sku_normalised`) by the
|
||
|
|
// normalised form.
|
||
|
|
const filters: string[] = ["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;
|
||
|
|
selling_price?: number;
|
||
|
|
markup?: number;
|
||
|
|
cost_price?: number;
|
||
|
|
cost_history?: unknown;
|
||
|
|
}>({
|
||
|
|
filter,
|
||
|
|
fields: "id,sku,selling_price,markup,cost_price,cost_history",
|
||
|
|
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 ?? "",
|
||
|
|
selling_price: r.selling_price ?? null,
|
||
|
|
markup: r.markup ?? null,
|
||
|
|
cost_price: r.cost_price ?? null,
|
||
|
|
cost_history: r.cost_history,
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Look up `price_list` rows by the normalised SKU + selling_price > 0.
|
||
|
|
*
|
||
|
|
* Returns the rows sorted newest-first (PB's `-updated`). The
|
||
|
|
* caller picks the first one; if `rows.length > 1`, the multi-match
|
||
|
|
* warning fires.
|
||
|
|
*/
|
||
|
|
async function findPriceListCandidates(
|
||
|
|
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
||
|
|
skuNormalised: string,
|
||
|
|
): Promise<BackfillPriceListCandidate[]> {
|
||
|
|
const rows = await pb.collection("price_list").getList<{
|
||
|
|
id: string;
|
||
|
|
sku: string;
|
||
|
|
sku_normalised: string;
|
||
|
|
cost_price: number;
|
||
|
|
selling_price: number;
|
||
|
|
markup: number;
|
||
|
|
updated?: string;
|
||
|
|
}>(1, 10, {
|
||
|
|
filter: `sku_normalised = "${escapeForPbString(skuNormalised)}" && selling_price > 0`,
|
||
|
|
sort: "-updated",
|
||
|
|
fields: "id,sku,sku_normalised,cost_price,selling_price,markup,updated",
|
||
|
|
});
|
||
|
|
return rows.items.map((r) => ({
|
||
|
|
id: r.id,
|
||
|
|
sku: r.sku,
|
||
|
|
sku_normalised: r.sku_normalised,
|
||
|
|
cost_price: Number(r.cost_price ?? 0),
|
||
|
|
selling_price: Number(r.selling_price ?? 0),
|
||
|
|
markup: Number(r.markup ?? 0),
|
||
|
|
updated: r.updated,
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Detect whether the decision wants to append a cost_history
|
||
|
|
* entry (which forces a read-merge-write round-trip). */
|
||
|
|
function decisionNeedsCostHistoryAppend(
|
||
|
|
decision: Extract<BackfillDecision, { kind: "patch" }>,
|
||
|
|
): boolean {
|
||
|
|
return Boolean(decision.patch.cost_history_append);
|
||
|
|
}
|
||
|
|
|
||
|
|
type RunTotals = {
|
||
|
|
scanned: number;
|
||
|
|
wouldPatch: number;
|
||
|
|
patched: number;
|
||
|
|
skippedIdempotent: number;
|
||
|
|
noMatch: number;
|
||
|
|
multiMatch: number;
|
||
|
|
errors: number;
|
||
|
|
};
|
||
|
|
|
||
|
|
function emptyTotals(): RunTotals {
|
||
|
|
return {
|
||
|
|
scanned: 0,
|
||
|
|
wouldPatch: 0,
|
||
|
|
patched: 0,
|
||
|
|
skippedIdempotent: 0,
|
||
|
|
noMatch: 0,
|
||
|
|
multiMatch: 0,
|
||
|
|
errors: 0,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── Main ──────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
export async function runBackfill(opts: CliOptions): Promise<RunTotals> {
|
||
|
|
const totals = emptyTotals();
|
||
|
|
const startTs = Date.now();
|
||
|
|
|
||
|
|
console.log(
|
||
|
|
`=== Backfill stock_items prices from price_list (${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();
|
||
|
|
|
||
|
|
const stockRows = await listUnpricedStockItems(pb, {
|
||
|
|
sku: opts.sku,
|
||
|
|
limit: opts.limit,
|
||
|
|
});
|
||
|
|
console.log(`\nScanning ${stockRows.length} stock_items row(s) (selling_price = 0)…\n`);
|
||
|
|
totals.scanned = stockRows.length;
|
||
|
|
|
||
|
|
for (const stockRow of stockRows) {
|
||
|
|
// Compute the canonical form client-side (stock_items doesn't
|
||
|
|
// have a sku_normalised column). The lookup against price_list
|
||
|
|
// is then a single PB round-trip per row.
|
||
|
|
const normalised = normaliseSku(stockRow.sku);
|
||
|
|
|
||
|
|
if (!normalised) {
|
||
|
|
console.log(` no_match sku=${stockRow.sku} reason=empty_normalised_sku`);
|
||
|
|
totals.noMatch += 1;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
let candidates: BackfillPriceListCandidate[];
|
||
|
|
try {
|
||
|
|
candidates = await findPriceListCandidates(pb, normalised);
|
||
|
|
} catch (err) {
|
||
|
|
const msg = err instanceof Error ? err.message : String(err);
|
||
|
|
console.log(` error sku=${stockRow.sku} reason=price_list_lookup_failed: ${msg}`);
|
||
|
|
totals.errors += 1;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
const candidate = candidates[0] ?? null;
|
||
|
|
const multiMatch = candidates.length > 1;
|
||
|
|
if (multiMatch) totals.multiMatch += 1;
|
||
|
|
|
||
|
|
const decision = decideBackfill({
|
||
|
|
stockItem: stockRow,
|
||
|
|
priceListCandidate: candidate,
|
||
|
|
multiMatch,
|
||
|
|
nowIso: new Date().toISOString(),
|
||
|
|
});
|
||
|
|
|
||
|
|
if (decision.kind === "patch") {
|
||
|
|
if (opts.commit) {
|
||
|
|
try {
|
||
|
|
await applyPatch(pb, stockRow, decision);
|
||
|
|
console.log(` ${formatDecisionLine({ decision, verbWhenPatch: "patched" })} [OK]`);
|
||
|
|
totals.patched += 1;
|
||
|
|
} catch (err) {
|
||
|
|
const msg = err instanceof Error ? err.message : String(err);
|
||
|
|
console.log(
|
||
|
|
` ${formatDecisionLine({ decision, verbWhenPatch: "patched" })} [ERR] ${msg}`,
|
||
|
|
);
|
||
|
|
totals.errors += 1;
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
console.log(` ${formatDecisionLine({ decision, verbWhenPatch: "would_patch" })}`);
|
||
|
|
totals.wouldPatch += 1;
|
||
|
|
}
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (decision.kind === "skipped_idempotent") {
|
||
|
|
console.log(` ${formatDecisionLine({ decision, verbWhenPatch: "would_patch" })}`);
|
||
|
|
totals.skippedIdempotent += 1;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
// no_match
|
||
|
|
console.log(` ${formatDecisionLine({ decision, verbWhenPatch: "would_patch" })}`);
|
||
|
|
totals.noMatch += 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
printTotals(totals, opts.commit, startTs);
|
||
|
|
return totals;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Apply a `patch` decision to PB.
|
||
|
|
*
|
||
|
|
* The cost_history append needs a read-merge-write round-trip
|
||
|
|
* because the existing array lives on the row. Everything else
|
||
|
|
* is a straight PATCH.
|
||
|
|
*/
|
||
|
|
async function applyPatch(
|
||
|
|
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
||
|
|
stockRow: BackfillStockSnapshot,
|
||
|
|
decision: Extract<BackfillDecision, { kind: "patch" }>,
|
||
|
|
): Promise<void> {
|
||
|
|
// Fast path: no cost_history append — one PATCH.
|
||
|
|
if (!decisionNeedsCostHistoryAppend(decision)) {
|
||
|
|
const payload: Record<string, unknown> = {};
|
||
|
|
if (decision.patch.selling_price) payload.selling_price = decision.patch.selling_price.to;
|
||
|
|
if (decision.patch.markup) payload.markup = decision.patch.markup.to;
|
||
|
|
if (decision.patch.cost_price) payload.cost_price = decision.patch.cost_price.to;
|
||
|
|
await pb.collection("stock_items").update(stockRow.id, payload);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Slow path: read the row, append to cost_history, write back
|
||
|
|
// with the price fields + the merged history in one PATCH.
|
||
|
|
const existing = await pb.collection("stock_items").getOne<{
|
||
|
|
cost_history?: unknown;
|
||
|
|
}>(stockRow.id, { fields: "cost_history" });
|
||
|
|
|
||
|
|
const existingHistory = Array.isArray(existing.cost_history)
|
||
|
|
? (existing.cost_history as unknown as StockCostHistoryEntry[]).filter(
|
||
|
|
(e): e is StockCostHistoryEntry =>
|
||
|
|
Boolean(e) &&
|
||
|
|
typeof e === "object" &&
|
||
|
|
typeof (e as { cost_price?: unknown }).cost_price === "number",
|
||
|
|
)
|
||
|
|
: [];
|
||
|
|
|
||
|
|
// decisionNeedsCostHistoryAppend guarantees the entry is present.
|
||
|
|
const appended = [
|
||
|
|
...existingHistory,
|
||
|
|
decision.patch.cost_history_append as StockCostHistoryEntry,
|
||
|
|
];
|
||
|
|
|
||
|
|
const payload: Record<string, unknown> = { cost_history: appended };
|
||
|
|
if (decision.patch.selling_price) payload.selling_price = decision.patch.selling_price.to;
|
||
|
|
if (decision.patch.markup) payload.markup = decision.patch.markup.to;
|
||
|
|
if (decision.patch.cost_price) payload.cost_price = decision.patch.cost_price.to;
|
||
|
|
|
||
|
|
await pb.collection("stock_items").update(stockRow.id, payload);
|
||
|
|
}
|
||
|
|
|
||
|
|
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(` multi_match: ${totals.multiMatch}`);
|
||
|
|
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 their price (and possibly cost) mirrored from price_list.`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
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 runBackfill(opts);
|
||
|
|
}
|
||
|
|
|
||
|
|
main().catch((err) => {
|
||
|
|
console.error("FATAL:", err);
|
||
|
|
process.exit(1);
|
||
|
|
});
|