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>
177 lines
7.1 KiB
TypeScript
177 lines
7.1 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|