WFDASH-WEBAPP/scripts/2026-10-30-pos-zero-price-cleanup.ts
Dave afdfbce71d fix(pos): never sell or list an item at R0; customer lines go to the price list
Owner's rule: the POS counter only carries priced stock.

- POS catalogue lists only active stock items with selling_price > 0
  (POS_CATALOG_FILTER); checkout refuses any line priced at R0 or less.
- New stock order now gives a new stock item the PO line's cost and the
  default 80% markup. It used to drop the cost, which is how 157 stock
  items reached the till at R0.
- Collection Confirm "Add to POS" becomes "Add to price list": customer
  lines go to price_list at cost + 80% (needs SKU, cost and supplier),
  never into stock_items. Route renamed to add-to-price-list.
- scripts/2026-10-30-pos-zero-price-cleanup.ts: priced 79 items from their
  PO line cost, switched off 48 with no cost and no stock, left 30 stocked
  ones active for costing (already applied on live PB).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 09:56:03 +02:00

177 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Clear R0 items off the POS counter (owner's rule, 2026-10-30: nothing on
* the till may have a zero selling price).
*
* For every ACTIVE `stock_items` row whose `selling_price` is 0 or empty:
*
* 1. Find a cost: the row's own `cost_price`, else the newest
* `stock_purchase_items.cost_price` > 0 for that item. (New stock
* order used to create the stock item without the PO line's cost, so
* most of these rows have a cost on their PO line.)
* 2. Cost found → `selling_price = round(cost × 1.80, 2)`, `markup = 80`,
* and `cost_price = cost` when the row had none. Cashiers can change
* the price later.
* 3. No cost anywhere and nothing on hand → `is_active = false` (off the
* POS; reversible, the row is not deleted). With stock on hand the row
* stays active so it is still on the stock page to be costed; the POS
* filter keeps it off the till anyway (owner's call, 2026-10-31).
*
* The POS catalogue now also filters `selling_price > 0`, so this is the
* data side of the same rule. Rows with a price but no cost are reported,
* never changed.
*
* Run:
* npx tsx scripts/2026-10-30-pos-zero-price-cleanup.ts # dry-run
* npx tsx scripts/2026-10-30-pos-zero-price-cleanup.ts --apply # write
* ... --report=/path/report.csv # CSV of every row
*
* 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 { writeFileSync } from "node:fs";
import { getAdminPocketBase } from "../src/lib/server/pocketbase-admin";
import {
DEFAULT_MARKUP_MULTIPLIER,
DEFAULT_MARKUP_PERCENT,
round2,
} from "../src/lib/server/stock-default-markup-service";
type StockRow = {
id: string;
sku: string;
name: string;
cost_price: number | null;
selling_price: number | null;
current_qty: number | null;
notes: string;
};
type Plan =
| { kind: "price"; row: StockRow; cost: number; costFrom: "item" | "po_line"; selling: number }
| { kind: "deactivate"; row: StockRow }
| { kind: "keep"; row: StockRow };
const apply = process.argv.includes("--apply");
const reportArg = process.argv.find((a) => a.startsWith("--report="));
const reportPath = reportArg ? reportArg.slice("--report=".length) : null;
async function main(): Promise<void> {
const pb = await getAdminPocketBase();
const zero = await pb.collection("stock_items").getFullList<StockRow>({
filter: "is_active = true && (selling_price = 0 || selling_price = null)",
fields: "id,sku,name,cost_price,selling_price,current_qty,notes",
sort: "-created",
});
const pricedNoCost = await pb.collection("stock_items").getList(1, 1, {
filter: "is_active = true && selling_price > 0 && (cost_price = 0 || cost_price = null)",
fields: "id",
});
// Newest positive PO-line cost per stock item, for rows with no cost.
const needCost = zero.filter((r) => !(Number(r.cost_price ?? 0) > 0)).map((r) => r.id);
const lineCost = new Map<string, number>();
for (let i = 0; i < needCost.length; i += 40) {
const chunk = needCost.slice(i, i + 40);
const lines = await pb
.collection("stock_purchase_items")
.getFullList<{ stock_item: string; cost_price: number | null }>({
filter: `cost_price > 0 && (${chunk.map((id) => `stock_item = "${id}"`).join(" || ")})`,
fields: "stock_item,cost_price",
sort: "-created",
});
for (const l of lines) {
if (!lineCost.has(l.stock_item)) lineCost.set(l.stock_item, Number(l.cost_price));
}
}
const plans: Plan[] = zero.map((row) => {
const own = Number(row.cost_price ?? 0);
if (own > 0) {
return { kind: "price", row, cost: own, costFrom: "item", selling: round2(own * DEFAULT_MARKUP_MULTIPLIER) };
}
const fromLine = lineCost.get(row.id);
if (fromLine && fromLine > 0) {
return {
kind: "price",
row,
cost: fromLine,
costFrom: "po_line",
selling: round2(fromLine * DEFAULT_MARKUP_MULTIPLIER),
};
}
return Number(row.current_qty ?? 0) !== 0 ? { kind: "keep", row } : { kind: "deactivate", row };
});
const priced = plans.filter((p): p is Extract<Plan, { kind: "price" }> => p.kind === "price");
const off = plans.filter((p): p is Extract<Plan, { kind: "deactivate" }> => p.kind === "deactivate");
const kept = plans.filter((p) => p.kind === "keep");
console.log(`Mode: ${apply ? "APPLY" : "dry-run (no writes)"}`);
console.log(`Active stock items with a zero selling price: ${zero.length}`);
console.log(` price at cost + ${DEFAULT_MARKUP_PERCENT}%: ${priced.length}`);
console.log(` cost already on the item: ${priced.filter((p) => p.costFrom === "item").length}`);
console.log(` cost taken from the stock PO line: ${priced.filter((p) => p.costFrom === "po_line").length}`);
console.log(` no cost, nothing on hand, switch off (is_active = false): ${off.length}`);
console.log(` no cost but stock on hand, left active (hidden from the till): ${kept.length}`);
console.log(`Info only, not changed: priced items with no cost: ${pricedNoCost.totalItems}`);
console.log("");
for (const p of priced.slice(0, 15)) {
console.log(` price ${p.row.sku || "(no sku)"} ${p.row.name} cost ${p.cost} (${p.costFrom}) -> sell ${p.selling}`);
}
for (const p of off.slice(0, 15)) {
console.log(` off ${p.row.sku || "(no sku)"} ${p.row.name} qty ${p.row.current_qty ?? 0}`);
}
if (reportPath) {
const esc = (v: unknown) => `"${String(v ?? "").replace(/"/g, '""')}"`;
const lines = ["action,id,sku,name,current_qty,cost,cost_from,new_selling_price"];
for (const p of plans) {
lines.push(
p.kind === "price"
? ["price", p.row.id, p.row.sku, p.row.name, p.row.current_qty ?? 0, p.cost, p.costFrom, p.selling].map(esc).join(",")
: [p.kind, p.row.id, p.row.sku, p.row.name, p.row.current_qty ?? 0, "", "", ""].map(esc).join(","),
);
}
writeFileSync(reportPath, lines.join("\n") + "\n");
console.log(`\nReport: ${reportPath}`);
}
if (!apply) return;
let ok = 0;
const failed: string[] = [];
const stamp = "2026-10-30";
for (const p of plans) {
if (p.kind === "keep") continue;
try {
if (p.kind === "price") {
const patch: Record<string, unknown> = {
selling_price: p.selling,
markup: DEFAULT_MARKUP_PERCENT,
};
if (p.costFrom === "po_line") patch.cost_price = p.cost;
await pb.collection("stock_items").update(p.row.id, patch);
} else {
const note = `${stamp}: taken off the POS, no cost or selling price.`;
await pb.collection("stock_items").update(p.row.id, {
is_active: false,
notes: p.row.notes ? `${p.row.notes}\n${note}` : note,
});
}
ok += 1;
} catch (err) {
failed.push(`${p.row.id} ${p.row.sku}: ${err instanceof Error ? err.message : String(err)}`);
}
}
console.log(`\nWrote ${ok} rows, ${failed.length} failed.`);
for (const f of failed) console.log(` FAILED ${f}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});