WFDASH-WEBAPP/scripts/recent-stock-movements.ts

274 lines
11 KiB
TypeScript
Raw Normal View History

/**
* One-off diagnostic: list every `stock_items` row that was created
* or updated since 2026-09-14T22:00:00Z (~36h ago, "yesterday +
* today" Johannesburg time), and highlight the ones still missing
* a `cost_price` or `selling_price`.
*
* Why this exists
* ───────────────
* The operator noticed "new" items on the POS are showing without
* prices. The recent 80% markup commit (Mon night) patched a lot
* of rows in-place via `updated`, so the "updated in window" set
* conflates two populations:
* (a) genuinely-new items added recently, and
* (b) pre-existing rows whose `selling_price` / `markup` was just
* patched by the markup run.
* This script splits those two apart in the summary block and
* then prints a full table of every in-window row that STILL has
* `cost_price <= 0` OR `selling_price <= 0` — the actual gap the
* POS is exposing.
*
* Read-only — never writes to PB.
*
* Run with:
* npx tsx scripts/recent-stock-movements.ts
*
* 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 ────────────────────────────────────────────────────────
/**
* Lower bound of the "recently moved" window. Hard-coded to the
* Johannesburg "yesterday + today" cutoff the operator asked for
* (2026-09-14 22:00 UTC == 2026-09-15 00:00 SAST). Edit here if
* the operator wants a different window.
*/
const WINDOW_START_ISO = "2026-09-14T22:00:00.000Z";
// ─── Types ────────────────────────────────────────────────────────────
/** Single `stock_items` row shape — the fields we read for the
* report. `expand.supplier` is the joined supplier name. */
type StockRow = {
id: string;
sku: string;
name: string;
current_qty: number;
cost_price?: number | null;
selling_price?: number | null;
markup?: number | null;
created: string;
updated: string;
supplier?: string;
expand?: {
supplier?: { id?: string; suppliername?: string };
};
};
// ─── Formatting ────────────────────────────────────────────────────────
/** Format a number as R X.XX, or "—" for missing/non-finite values. */
function fmtMoney(n: number | null | undefined): string {
if (typeof n !== "number" || !Number.isFinite(n)) return "—";
return `R ${n.toFixed(2)}`;
}
/** Format a markup percent. PB stores markup as a plain number
* (e.g. 80 means 80%); render with a trailing "%" when present. */
function fmtMarkup(n: number | null | undefined): string {
if (typeof n !== "number" || !Number.isFinite(n)) return "—";
return `${n}%`;
}
/** Truncate an ISO timestamp to "YYYY-MM-DD HH:MM" in UTC. PB's
* autodate strings are already UTC, so no conversion is needed —
* we just slice the trailing ":00.000Z" off to keep the table
* compact. */
function fmtDateTime(iso: string): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const pad = (n: number): string => String(n).padStart(2, "0");
return (
`${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}` +
` ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`
);
}
/** Render the gap table. Numeric columns are right-aligned via the
* `---:` colon convention so the operator can scan decimals. */
function renderGapTable(rows: StockRow[]): string {
const lines: string[] = [];
lines.push(
"| SKU | Name | Supplier | Cost (R) | Selling (R) | Markup | Qty | Created | Updated |",
);
lines.push(
"| --- | --- | --- | ---: | ---: | ---: | ---: | --- | --- |",
);
for (const r of rows) {
const sku = r.sku || "(no sku)";
const name = (r.name || "(no name)").slice(0, 60); // keep names from blowing up the table
const supplier = r.expand?.supplier?.suppliername?.trim() || "—";
const qty = Number.isFinite(r.current_qty) ? r.current_qty : 0;
lines.push(
`| ${sku} | ${name} | ${supplier} | ${fmtMoney(r.cost_price)} | ${fmtMoney(r.selling_price)} | ${fmtMarkup(r.markup)} | ${qty} | ${fmtDateTime(r.created)} | ${fmtDateTime(r.updated)} |`,
);
}
return lines.join("\n");
}
// ─── PB plumbing ──────────────────────────────────────────────────────
/**
* Pull every `stock_items` row whose `created` or `updated` is on
* or after `WINDOW_START_ISO`. Both fields are autodate strings in
* PB and lexicographically compare correctly as ISO-8601 UTC, so
* the `>=` operator works without parsing.
*
* The `~ ` operator is NOT used here — PB's `~` is `contains`,
* not `regex`, and it's irrelevant for an exact-bound comparison.
*
* We do NOT pass a `fields` projection: passing `fields=` together
* with `expand=supplier` causes PB to drop the `expand` payload
* (see the comment in `list-irda-stock.ts`). Pulling the full row
* is fine here — the dataset is bounded by the ~36h window, not
* by the whole catalogue.
*
* Sort: `-updated` so the most-recently-moved rows float to the
* top. The client-side sub-sort for the gap table also uses
* `updated DESC` (and then `created DESC`) so an item patched
* moments ago reads before one patched yesterday morning.
*/
async function listRecentStockItems(windowStartIso: string): Promise<StockRow[]> {
const pb = await getAdminPocketBase();
const rows = await pb.collection("stock_items").getFullList<StockRow>({
filter: `created >= "${windowStartIso}" || updated >= "${windowStartIso}"`,
expand: "supplier",
sort: "-updated",
batch: 500,
});
return rows;
}
// ─── Main ─────────────────────────────────────────────────────────────
async function main(): Promise<void> {
const startTs = Date.now();
const windowStartIso = WINDOW_START_ISO;
const windowStart = new Date(windowStartIso);
console.log(
`=== Recently-moved stock items (since ${windowStartIso}) ===\n`,
);
const allRows = await listRecentStockItems(windowStartIso);
const totalInWindow = allRows.length;
// Split the in-window set into "newly created" vs "only patched"
// by comparing `created` against the window start. An item whose
// `created` is BEFORE the window but whose `updated` is IN the
// window was patched (the Monday-night 80% markup run hit a lot
// of these). An item whose `created` is IN the window is
// genuinely new.
const isInWindow = (iso: string): boolean => {
if (!iso) return false;
const d = new Date(iso);
return d.getTime() >= windowStart.getTime();
};
const newlyCreated = allRows.filter((r) => isInWindow(r.created));
const updatedOnly = allRows.filter(
(r) => !isInWindow(r.created) && isInWindow(r.updated),
);
// The gap = cost_price <= 0 OR selling_price <= 0. PB's autodate
// for missing numerics resolves to 0 / null on the typed shape;
// we treat anything `!(x > 0)` as missing, matching the
// convention used in `apply-default-markup.ts` and
// `list-irda-stock.ts`.
const isGapRow = (r: StockRow): boolean => {
const cost = Number(r.cost_price ?? 0);
const sell = Number(r.selling_price ?? 0);
return !(cost > 0) || !(sell > 0);
};
const gapRows = allRows
.filter(isGapRow)
// Sort newest-created/updated first within the gap. Compare on
// the max(created, updated) so a row created last week but
// patched yesterday sorts before one created last week and
// never touched again. Falls back to created if both are
// missing.
.toSorted((a, b) => {
const aMax = Math.max(
new Date(a.created).getTime() || 0,
new Date(a.updated).getTime() || 0,
);
const bMax = Math.max(
new Date(b.created).getTime() || 0,
new Date(b.updated).getTime() || 0,
);
return bMax - aMax;
});
const missingCost = gapRows.filter((r) => !(Number(r.cost_price ?? 0) > 0));
const missingSelling = gapRows.filter(
(r) => !(Number(r.selling_price ?? 0) > 0),
);
// Rows that have BOTH prices populated (the "happy path"
// population — what the markup commit was aiming for).
const fullyPriced = allRows.filter(
(r) =>
Number(r.cost_price ?? 0) > 0 && Number(r.selling_price ?? 0) > 0,
);
// Summary block. The operator specifically asked us to be loud
// about `Items missing cost_price` — if it's zero we say so.
console.log("Summary:");
console.log(` Total items changed in window: ${totalInWindow}`);
console.log(` Items still missing cost_price: ${missingCost.length}${missingCost.length === 0 ? " ← no missing-cost rows in window; the gap is purely on the SELL side" : ""}`);
console.log(` Items still missing selling_price: ${missingSelling.length}`);
console.log(` Items with both fields populated: ${fullyPriced.length}`);
console.log(` Items created fresh (not just patched): ${newlyCreated.length}`);
console.log(` Items updated only (cost/markup patch): ${updatedOnly.length}`);
console.log(
`\n=== Gap rows: cost_price <= 0 OR selling_price <= 0 (${gapRows.length} row${gapRows.length === 1 ? "" : "s"}) ===\n`,
);
if (gapRows.length === 0) {
console.log(
" (none — every in-window row has both cost_price and selling_price populated)",
);
} else {
console.log(renderGapTable(gapRows));
}
// A small pattern-recognition addendum: tally supplier frequency
// and qty=0 rate inside the gap, so the operator can eyeball
// whether the gap is "one supplier forgot to ship pricing" or
// "every supplier has a few stragglers".
if (gapRows.length > 0) {
const supplierCounts = new Map<string, number>();
let zeroQty = 0;
for (const r of gapRows) {
const name = r.expand?.supplier?.suppliername?.trim() || "(no supplier)";
supplierCounts.set(name, (supplierCounts.get(name) ?? 0) + 1);
if (!(Number(r.current_qty) > 0)) zeroQty += 1;
}
const supplierLines = [...supplierCounts.entries()]
.toSorted((a, b) => b[1] - a[1])
.map(([name, n]) => ` ${n}× ${name}`);
console.log(`\nGap breakdown by supplier:`);
console.log(supplierLines.join("\n"));
console.log(`\nGap rows with current_qty = 0: ${zeroQty} / ${gapRows.length}`);
}
const duration = ((Date.now() - startTs) / 1000).toFixed(1);
console.log(`\n duration: ${duration}s`);
}
main().catch((err) => {
console.error("FATAL:", err);
process.exit(1);
});