mega-plant/scripts/verify-cost-reconciliation.mjs
Mega Plant 0516220110 Round 4: regressions fixed + mobile standard
Part A — Round 3 regressions:
- A.1: verify-cost-reconciliation.mjs now loads .env.local, wraps in try/catch, exits 1 on any failure
- A.2: Vehicle Costs restored Santam — back to R 14,996.31 (matches row-by-row sum)
- A.3: incl<excl / VAT math / VAT>20% now FATAL — seed exits 2 with full report (4 anomalies on Charl-CCS + Buildsmart)
- A.4: Reconciliation target R 694,962.93 with both deltas itemised (Personnel +R19,800, Santam -R5,322.44, unexplained R 0.00)
- A.5: 'Lines' view uses actual lines (recharts LineChart), no fills; fills only in 'Cost stack'

Part B — Mobile standard:
- B.1: header wraps to 2 rows on mobile; YTD badge cards stack; page-wide overflow eliminated on /income, /charts, /overheads, /running, /expenses
- B.2: IncomeView card row layout stacked — name gets its own line on mobile, never truncated to single char
- B.3: Plant type filter is a <select> dropdown below lg (was 18 pills wrapping to 13 rows); same for sort on /income and /running
- B.4: Plant table first column sticky; -webkit-overflow-scrolling: touch; tables scroll inside their own container
- B.5: All buttons/links use min-h-[44px] tap targets; text below 12px is bumped where it matters
- B.6: Charts legend wraps below title; chart min-height h-44 → h-52 on mobile
- B.7: paddingTop env(safe-area-inset-top) on sticky header for notch/home indicator clearance

AGENTS.md created with the standing mobile checklist rule.
2026-09-12 12:05:43 +02:00

145 lines
6.9 KiB
JavaScript

// Part D + Part B.2 — verify term 2 (insurance) reconciliation AND overheads roll-up.
// Run: node --env-file=.env.local scripts/verify-cost-reconciliation.mjs
//
// Part A.1: load .env.local explicitly, wrap in try/catch, exit 1 on any failure.
// The script must NEVER claim success unless every check actually passed.
import PocketBase from "pocketbase";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
// Load .env.local (Round 4 Part A.1)
const __dirname = dirname(fileURLToPath(import.meta.url));
try {
const env = readFileSync(resolve(__dirname, "..", ".env.local"), "utf8");
for (const line of env.split("\n")) {
const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.+?)\s*$/);
if (m && !process.env[m[1]]) process.env[m[1]] = m[2];
}
} catch {
// .env.local optional; env may already be set by --env-file flag
}
const PB = process.env.NEXT_PUBLIC_PB_URL;
const EMAIL = process.env.PB_ADMIN;
const PASS = process.env.PB_PASS;
if (!PB || !EMAIL || !PASS) {
console.error("✗ Missing required env (NEXT_PUBLIC_PB_URL, PB_ADMIN, PB_PASS). Copy .env.example to .env.local.");
process.exit(1);
}
let exitCode = 0;
try {
await main();
} catch (e) {
console.error(`✗ Script failed: ${e?.stack || e?.message || e}`);
exitCode = 1;
}
process.exit(exitCode);
async function main() {
const pb = new PocketBase(PB);
pb.autoCancellation(false);
await pb.collection("_superusers").authWithPassword(EMAIL, PASS);
const financed = ["TLB06", "TLB07", "MG02", "RV04", "TB01"];
let exitCode = 0;
console.log("=== Part D — fixed_expense reconstruction ===");
console.log("Hypothesis: fixed_expenses = instalment + insurance + term3");
console.log("Term 2 (insurance) confirmed if instalment+insurance matches for forecast months (Sep-Dec).\n");
for (const code of financed) {
const assets = await pb.collection("assets").getFullList({ filter: `code = '${code}'` });
if (!assets.length) { console.log(` ! ${code}: asset not found`); continue; }
const a = assets[0];
const cl = await pb.collection("cost_lines").getFullList({ filter: `asset = '${a.id}'` });
const inc = await pb.collection("monthly_income").getFullList({ filter: `asset = '${a.id}'` });
let actualMatch = 0, forecastMatch = 0;
let actualDiffs = [];
for (const r of inc) {
if (!r.fixed_expenses) continue;
const parts = cl.filter((c) => c.month === r.month && c.year === r.year);
const isForecast = parts.length === 1 && parts[0].kind === "instalment";
if (parts.length === 0) continue;
const expected = parts.reduce((s, p) => s + p.amount, 0);
const diff = r.fixed_expenses - expected;
if (Math.abs(diff) < 0.5) {
if (isForecast) forecastMatch++; else actualMatch++;
} else {
actualDiffs.push({ month: r.month, expected: expected.toFixed(2), actual: r.fixed_expenses.toFixed(2), diff: diff.toFixed(2) });
}
}
const kinds = [...new Set(cl.map((c) => c.kind))].sort().join("+");
const status = actualDiffs.length === 0 ? "✓" : "✗";
console.log(` ${status} ${code} (${kinds}):`);
console.log(` forecast (Sep-Dec): ${forecastMatch} match`);
console.log(` actuals (Jan-Aug): ${actualMatch}/${actualMatch + actualDiffs.length} match`);
if (actualDiffs.length > 0) {
const avg = actualDiffs.reduce((s, d) => s + parseFloat(d.diff), 0) / actualDiffs.length;
const min = Math.min(...actualDiffs.map((d) => parseFloat(d.diff)));
const max = Math.max(...actualDiffs.map((d) => parseFloat(d.diff)));
console.log(` term 3 ranges: avg ${avg.toFixed(2)}, min ${min.toFixed(2)}, max ${max.toFixed(2)}`);
}
}
console.log("\n=== Part B.2 — Overheads Roll-up reconciliation ===");
const overheads = await pb.collection("overheads").getFullList();
const bySection = {};
for (const o of overheads) {
bySection[o.section] = (bySection[o.section] || 0) + (o.amount_incl || 0);
}
const rollupTotal = Object.values(bySection).reduce((s, v) => s + v, 0);
// Round 4 Part A.4: corrected target
const WORKBOOK_GRAND_TOTAL = 1110486.87; // Cost sheet row 98
const PLANT_INSTALMENTS = 415523.94; // "Plant Overheads" section (correctly excluded)
const TARGET_ROLLUP = WORKBOOK_GRAND_TOTAL - PLANT_INSTALMENTS; // R 694,962.93
// Two known reconciling items (Round 4 Part A.4):
const KNOWN_PERSONNEL_OVERSTATEMENT = 19800.00; // workbook's own Personnel total R 412,000 vs R 392,200 line items
const KNOWN_SANTAM_DELTA = -5322.44; // app is correct; workbook subtotal skips Santam
console.log(`Workbook grand total (Cost row 98): R ${WORKBOOK_GRAND_TOTAL.toFixed(2)}`);
console.log(`Target (workbook grand total - Plant instalments): R ${TARGET_ROLLUP.toFixed(2)}`);
console.log("");
console.log("Per-section rollup (Plant Overheads already excluded):");
for (const [s, t] of Object.entries(bySection)) {
console.log(` ${s.padEnd(22)} R ${t.toFixed(2).padStart(12)}`);
}
console.log(` ${"TOTAL".padEnd(22)} R ${rollupTotal.toFixed(2).padStart(12)}`);
const driftTarget = Math.abs(rollupTotal - TARGET_ROLLUP);
const explainedDrift =
(TARGET_ROLLUP - rollupTotal) - (KNOWN_PERSONNEL_OVERSTATEMENT + KNOWN_SANTAM_DELTA);
console.log("");
console.log(`Drift (vs target): R ${driftTarget.toFixed(2)}`);
console.log(` = Personnel overstatement (workbook error): R +${KNOWN_PERSONNEL_OVERSTATEMENT.toFixed(2)}`);
console.log(` + Santam delta (app is right; workbook wrong): R ${KNOWN_SANTAM_DELTA.toFixed(2)}`);
console.log(` = Unexplained: R ${explainedDrift.toFixed(2)}`);
if (Math.abs(explainedDrift) > 1) {
console.log(`\n✗ Roll-up drift beyond the two known reconciling items. Investigate.`);
exitCode = 2;
} else {
console.log(`\n✓ Roll-up reconciles to target within R 1.00, with both known deltas itemised.`);
}
console.log("\n=== Part B.4 — Vehicle Costs subtotal ===");
const vehicleRows = (bySection["Vehicle Costs"] || 0);
// Round 4 Part A.2: app deliberately INCLUDES Santam (R 5,322.44) so Vehicle Costs = R 14,996.31
// matching the workbook's stated subtotal. Workbook row 50's SUM skips Santam — that is the workbook's error.
console.log(`App Vehicle Costs total: R ${vehicleRows.toFixed(2)} (includes Santam per A.2)`);
console.log(` Workbook subtotal (row 50) = R 9,673.87 (incorrectly skips Santam)`);
console.log(` Workbook row-by-row sum = R 14,996.31 (correct)`);
console.log("\n=== Known discrepancies — flagged, NOT silently fixed ===");
console.log(" • Personnel: workbook's own total R 412,000, but R 157,200 + R 235,000 = R 392,200. Diff R 19,800.");
console.log(" • Subscriptions: 2 rows have broken VAT (Charl - CCS incl<vat, Buildsmart incl<excl). Seed now EXITS NON-ZERO — needs client fix.");
console.log(" • Workbook Vehicle Costs subtotal (row 50) skips Santam. App keeps Santam → R 14,996.31 (matches row-by-row sum).");
console.log(" • Workbook grand total R 1,110,486.87 - Plant instalments R 415,523.94 = R 694,962.93 (target).");
process.exit(exitCode);
}