diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9c055de --- /dev/null +++ b/.dockerignore @@ -0,0 +1,39 @@ +# Reinstalled inside the image by `npm ci` +node_modules + +# Build output — must not leak into the image or it poisons the fresh build +.next +.next-build +out +dist + +# Secrets. Runtime config comes from Coolify's environment variables. +.env +.env.local +.env.*.local + +# Generated by next-pwa during the Docker build; a stale copy from a local +# dev run would be baked in over the fresh one. +public/sw.js +public/sw.js.map +public/workbox-*.js +public/workbox-*.js.map +public/swe-worker-*.js + +# Source workbook + extracted JSON. Only used by `npm run seed`, which is a +# local one-off against PocketBase — not something the container does. ~2MB. +data/source + +# Local tooling and docs +.git +.gitignore +.vscode +.mavis +.superpowers +*.log +.DS_Store +HANDOFF-PROMPT*.md +README.md +COOLIFY.md +tsconfig.tsbuildinfo +test-pb.mjs diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d7584d1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,54 @@ +# AGENTS.md — Mega Plant + +## Mobile checklist (Round 4 standing rule) + +Every page must pass the mobile checklist before it is considered complete: + +1. **No horizontal page scroll at 375px.** Individual tables may scroll inside their own container; the page itself may not. +2. **No text under 12px on mobile.** Bump `text-[10px]` labels up. +3. **No tap target under 44×44px.** Keep the visual size if you like — add padding to the hit area. +4. **Wide tables scroll inside their own container** with a sticky first column and `-webkit-overflow-scrolling: touch`. Add a visible scroll affordance (e.g. right-edge fade). +5. **Filter controls collapse** below `lg` — pills become a `` dropdown, or a + horizontally-scrolling single row of pills. Not a 13-row block. +- [ ] Same for the `Sort:` control on `/income` and the view toggle on `/charts`. + +## B.4 Wide tables: scroll inside the card, with an anchor column + +`/plant` does this right — the table is 917px inside a 375px `overflow-x-auto` +container, so the page itself doesn't move. Keep that pattern. But scrolling six +columns of financials on a phone is still hard work. + +- [ ] The first column (Code, or Description on expense pages) stays pinned with + `position: sticky; left: 0` and a background, so you always know which row + you're reading. +- [ ] Add `-webkit-overflow-scrolling: touch` and a visible affordance that the + table scrolls — a fade on the right edge is enough. +- [ ] On expense pages, consider hiding `GL Account` and `Supplier` below `md`. + Description and the three money columns are what matter on a phone. + +## B.5 Tap targets and text size + +Measured on `/charts` at 375px: **19 interactive elements under 44px tall.** +On `/` : **10 text elements under 11px.** + +- [ ] Every `button`, `a`, and `input` is at least 44×44px on touch. Keep the visual + size if you like — add padding to the hit area. +- [ ] No text below 12px on mobile. The `text-[10px]` labels need bumping. + +## B.6 Charts on a phone + +- [ ] The legend on `/charts` is cut off at the right edge ("Income · Fixed · Ru…"). + Wrap it below the title on small screens. +- [ ] Charts need a minimum height on mobile — squashing to 176px with 8 x-labels + makes them unreadable. Go taller on narrow screens, not shorter. +- [ ] The machine card grid must be a single column below `sm`. + +## B.7 The PWA angle + +The app is meant to install to the home screen, so mobile isn't a nice-to-have — +it's the primary surface. + +- [ ] Verify the installed standalone app at 375px, not just Safari/Chrome. Standalone + has no browser chrome, so sticky headers and safe-area insets behave differently. +- [ ] Add `viewport-fit=cover` and use `env(safe-area-inset-*)` padding on the sticky + header and the mobile drawer, so content clears the notch and home indicator. + +## B.8 How to verify — do this, and report it + +Do not report mobile work as done without this. For **every** route +(`/`, `/plant`, `/income`, `/running`, `/charts`, `/overheads`, `/expenses`, and each +`/expenses/*`): + +1. Set the viewport to **375×812**. +2. Run the overflow check from B.1 — record the number. +3. Screenshot the page. +4. Count tap targets under 44px and text under 12px. +5. Repeat at **768×1024** (tablet). + +- [ ] Paste a table of route → overflow → tap-target count for all routes. +- [ ] Attach 375px screenshots. +- [ ] Any route that still overflows is not done. + +--- + +# Standing rule for future work + +Add this to the project's `AGENTS.md` so it applies to every future change: + +> Every page must pass the mobile checklist before it is considered complete: +> no horizontal page scroll at 375px, no text under 12px, no tap target under 44px, +> wide tables scroll inside their own container with a sticky first column, and +> filter controls collapse rather than stacking into many rows. Verify at 375×812 +> and 768×1024 and report the measurements — do not assert it from reading the code. + +- [ ] Create `AGENTS.md` with that rule if it doesn't exist. + +--- + +# Acceptance criteria + +- [ ] `npm run typecheck` exits clean +- [ ] `npm run build` succeeds +- [ ] `verify-cost-reconciliation.mjs` runs, and exits **non-zero** when it fails +- [ ] Vehicle Costs is back to R14,996.31 with Santam included +- [ ] Seed rejects the Buildsmart row; failure output pasted +- [ ] Reconciliation targets R694,962.93 with both deltas itemised +- [ ] "Lines" view draws lines, not filled areas +- [ ] **Zero routes** report horizontal page overflow at 375px +- [ ] No tap target under 44px; no text under 12px +- [ ] Wide tables scroll in-container with a sticky first column +- [ ] Verification table + 375px screenshots pasted for every route +- [ ] **Desktop (≥1024px) is visually unchanged — palette, roundness, gradients, + spacing, dark mode all identical to today** diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 0000000..6be888f --- /dev/null +++ b/app/api/health/route.ts @@ -0,0 +1,13 @@ +// Liveness probe for the container healthcheck and Coolify's own monitor. +// Deliberately does NOT touch PocketBase — this answers "is the Next.js +// server up?", not "is the database reachable?". Mixing the two makes the +// container restart itself whenever PocketBase hiccups. +export const dynamic = "force-dynamic"; + +export async function GET() { + return Response.json({ + status: "ok", + uptime: process.uptime(), + timestamp: new Date().toISOString(), + }); +} diff --git a/app/charts/page.tsx b/app/charts/page.tsx index 94bed2f..e47dda2 100644 --- a/app/charts/page.tsx +++ b/app/charts/page.tsx @@ -2,6 +2,11 @@ import { getAssets, getActualMonthlyIncome, getRunningCosts } from "@/lib/data"; import { ChartsGrid } from "@/components/ChartsGrid"; import { buildIncomeRows } from "@/lib/rows"; +// Reads PocketBase on every request. Without this Next tries to prerender the +// page at build time, which makes `docker build` depend on the database being +// reachable and then serves a frozen build-time snapshot to users. +export const dynamic = "force-dynamic"; + export default async function ChartsPage() { const [assets, incomeActual, running] = await Promise.all([ getAssets(), diff --git a/app/expenses/finance/page.tsx b/app/expenses/finance/page.tsx index 2be4db3..acbf019 100644 --- a/app/expenses/finance/page.tsx +++ b/app/expenses/finance/page.tsx @@ -1,6 +1,11 @@ import { ExpenseSectionPage } from "@/components/ExpenseSectionPage"; import { CreditCard } from "lucide-react"; +// Reads PocketBase on every request. Without this Next tries to prerender the +// page at build time, which makes `docker build` depend on the database being +// reachable and then serves a frozen build-time snapshot to users. +export const dynamic = "force-dynamic"; + export default function Finance() { return ( -
-
-
-
- + {/* Round 4 Part B.1/B.6: title + subtitle stack above legend on mobile; legend wraps below */} +
+
+
+
+
+ +
+

Machine Charts

-

Machine Charts

+

+ {stats.total} machines · {stats.profitable} profitable · {ZAR2(stats.totalIncome)} total income +

-

- {stats.total} machines · {stats.profitable} profitable · {ZAR2(stats.totalIncome)} total income -

-
+
{legend.map((l) => (
@@ -85,15 +88,15 @@ export function ChartsGrid({ items }: { items: Item[] }) {
- {/* Toolbar */} + {/* Toolbar — wraps to multiple rows on mobile. Round 4 Part B.1/B.3/B.5. */}
-
+
setQuery(e.target.value)} placeholder="Search by code or name…" - className="w-full h-9 pl-9 pr-3 bg-gray-50 dark:bg-white/5 border border-transparent rounded-lg text-sm placeholder:text-gray-400 focus:bg-white dark:focus:bg-[#0f1419] focus:border-[#30638E] focus:outline-none" + className="w-full min-h-[44px] pl-9 pr-3 bg-gray-50 dark:bg-white/5 border border-transparent rounded-lg text-sm placeholder:text-gray-400 focus:bg-white dark:focus:bg-[#0f1419] focus:border-[#30638E] focus:outline-none" />
@@ -105,7 +108,7 @@ export function ChartsGrid({ items }: { items: Item[] }) { ))}
-
- View +
+ View {[ { id: "lines", label: "Lines", icon: LineChartIcon }, { id: "stack", label: "Cost stack", icon: BarChart3 }, @@ -125,7 +128,7 @@ export function ChartsGrid({ items }: { items: Item[] }) {
-
+
Sort: {[ { id: "profit", label: "Profit" }, @@ -70,7 +73,7 @@ export function IncomeView({ rows, palette }: { rows: IncomeRow[]; palette: { in ))}
+ {/* Part B.3: mobile sort dropdown */} +
+ + +
@@ -115,42 +131,63 @@ function CardsView({ > {isOpen && ( diff --git a/components/MachineLineChart.tsx b/components/MachineLineChart.tsx index 23d235c..56eb9ca 100644 --- a/components/MachineLineChart.tsx +++ b/components/MachineLineChart.tsx @@ -1,7 +1,7 @@ "use client"; import { - ComposedChart, Line, Bar, AreaChart, Area, Cell, + ComposedChart, Line, LineChart, Bar, AreaChart, Area, Cell, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, } from "recharts"; import { MONTH_NAMES } from "@/lib/data"; @@ -14,15 +14,12 @@ type Props = { fixed: (number | null)[]; running: (number | null)[]; view?: ChartView; - // CSS-var-resolved hex strings for chart marks (Part A.4). - // Defaults fall back to validated hues if not provided. incomeColor?: string; fixedColor?: string; runningColor?: string; profitColor?: string; }; -// Helper: read a CSS custom property at runtime so we never hardcode hex in components. function readVar(name: string, fallback: string): string { if (typeof window === "undefined") return fallback; const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); @@ -34,13 +31,11 @@ export function MachineLineChart({ view = "lines", incomeColor, fixedColor, runningColor, profitColor, }: Props) { - // Resolve CSS vars to actual hex once per render (server falls back to defaults). const INC = incomeColor ?? readVar("--color-chart-income", "#0090A6"); const FIX = fixedColor ?? readVar("--color-chart-fixed", "#BE851F"); const RUN = runningColor ?? readVar("--color-chart-running", "#C13B4D"); const PROF = profitColor ?? readVar("--color-chart-running", "#D1495B"); - // Clip to last actual month (Part A.3) — months past that are null and we trim the array. const lastActual = Math.max( income.findLastIndex((v) => v != null), fixed.findLastIndex((v) => v != null), @@ -60,9 +55,8 @@ export function MachineLineChart({ new Intl.NumberFormat("en-ZA", { style: "currency", currency: "ZAR", maximumFractionDigits: 0, notation: "compact" }).format(v); if (view === "profit") { - // Single diverging bar series (Part A.5 — View 3): teal above 0, red below return ( -
+
@@ -81,17 +75,14 @@ export function MachineLineChart({ } if (view === "stack") { - // Part A.5 — View 2: stacked bars (fixed + running) with income as line. No 4th series for profit. return ( -
+
- {/* 2px surface-coloured gap between stacked segments via barCategoryGap + barGap */} fmt(v)} /> - {/* Surface-coloured spacer to create the 2px gap — drawn as transparent rect via offset */} @@ -101,35 +92,23 @@ export function MachineLineChart({ ); } - // Default: lines (View 1) + // Round 4 Part A.5: default "Lines" view draws ACTUAL LINES (stroked, no fill). + // The chart's whole purpose is to match the workbook — the workbook draws lines. return ( -
+
- - - - - - - - - - - - - - - + fmt(v)} /> - - - - + + + +
); } + diff --git a/components/PlantRegisterTable.tsx b/components/PlantRegisterTable.tsx index 9d80aa7..3c36b7f 100644 --- a/components/PlantRegisterTable.tsx +++ b/components/PlantRegisterTable.tsx @@ -95,33 +95,54 @@ export function PlantRegisterTable({ assets }: { assets: Asset[] }) { value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Filter code, name, supplier…" - className="h-9 pl-9 pr-3 w-64 bg-gray-50 dark:bg-white/5 border border-transparent rounded-lg text-sm placeholder:text-gray-400 focus:bg-white dark:focus:bg-[#0f1419] focus:border-[#30638E] focus:outline-none" + className="h-11 min-h-[44px] pl-9 pr-3 w-full sm:w-64 bg-gray-50 dark:bg-white/5 border border-transparent rounded-lg text-sm placeholder:text-gray-400 focus:bg-white dark:focus:bg-[#0f1419] focus:border-[#30638E] focus:outline-none" />
- {/* Type filter pills */} -
- {types.map((t) => { - const count = t === "All" ? assets.length : assets.filter((a) => a.type === t).length; - return ( - - ); - })} + {/* Type filter — Round 4 Part B.3: pills on lg+, dropdown below lg to avoid 13-row wrap */} +
+ {/* Desktop: pills */} +
+ {types.map((t) => { + const count = t === "All" ? assets.length : assets.filter((a) => a.type === t).length; + return ( + + ); + })} +
+ {/* Mobile: dropdown */} +
+ + +
- {/* Table */} -
+ {/* Table — Round 4 Part B.4: sticky first column + touch scroll + right-edge fade */} +
@@ -137,7 +158,7 @@ export function PlantRegisterTable({ assets }: { assets: Asset[] }) { {filtered.map((a) => ( -
+ {a.is_provisional && ( )} diff --git a/components/RunningView.tsx b/components/RunningView.tsx index dee4df7..f15818e 100644 --- a/components/RunningView.tsx +++ b/components/RunningView.tsx @@ -30,18 +30,19 @@ export function RunningView({ return ( <> -
-
+
+
{rows.filter((r) => r.ytd > 0).length} machines with costs · sorted by {sort === "ytd" ? "total spend" : "code"}
-
+
-
+
Sort: {[ { id: "ytd", label: "Total" }, @@ -69,7 +71,7 @@ export function RunningView({ ))}
+
+ + +
diff --git a/data/source/overheads.json b/data/source/overheads.json index 24b9fb6..66bf7b0 100644 --- a/data/source/overheads.json +++ b/data/source/overheads.json @@ -1,4 +1,15 @@ [ + { + "section": "Vehicle Costs", + "description": "Santam versekering", + "gl_account": "", + "supplier": "", + "qty": 0, + "unit_price": 0, + "amount_excl": 4524.0740000000005, + "vat": 798.366, + "amount_incl": 5322.4400000000005 + }, { "section": "Vehicle Costs", "description": "Tracking", diff --git a/lib/pb.ts b/lib/pb.ts index d0b8609..f415cb9 100644 --- a/lib/pb.ts +++ b/lib/pb.ts @@ -1,8 +1,17 @@ import PocketBase from "pocketbase"; -export const PB_URL = - process.env.NEXT_PUBLIC_PB_URL || - "http://pocketbase-v2usyugdbnffl6anlefm0f79.169.239.180.130.sslip.io"; +// No fallback on purpose. NEXT_PUBLIC_PB_URL is inlined at build time, so a +// default here would silently bake the wrong database into a production image +// whenever the build arg is missing. Better to fail at startup than to serve +// someone else's data. The Dockerfile checks for it before building too. +export const PB_URL = process.env.NEXT_PUBLIC_PB_URL ?? ""; + +if (!PB_URL) { + throw new Error( + "NEXT_PUBLIC_PB_URL is not set. Set it in .env.local for local dev, and as " + + "a Coolify Build Argument (not just an env var) for deploys — see COOLIFY.md.", + ); +} // Lazy singleton — the browser SDK auto-cancels in-flight requests when the // client is recreated, so we keep one instance for the whole browser session. diff --git a/next.config.ts b/next.config.ts index 744a662..5fc58e5 100644 --- a/next.config.ts +++ b/next.config.ts @@ -3,6 +3,12 @@ import withPWAInit from "@ducanh2912/next-pwa"; const nextConfig: NextConfig = { // No `env` block — NEXT_PUBLIC_ vars are picked up automatically at build time. + + // Emits `.next/standalone/server.js` with a trimmed node_modules, so the + // runtime image doesn't need the full dependency tree. Required by the + // Dockerfile. Note it does NOT bundle `.next/static` or `public/` — the + // Dockerfile copies those in separately. + output: "standalone", }; const withPWA = withPWAInit({ diff --git a/scripts/seed.mjs b/scripts/seed.mjs index 2cb51ed..2e0a794 100644 --- a/scripts/seed.mjs +++ b/scripts/seed.mjs @@ -196,21 +196,29 @@ const ohErrors = []; const VAT_WARN = []; // Part B.3: collect VAT anomalies — DO NOT silent-correct, report loudly for (const o of overheads) { if (o.section === "Plant Overheads") continue; // Part 1.3 - if (o.description === "Santam versekering") continue; // Part B.4 + // Note: Santam is kept (Round 4 Part A.2 — round 3 was wrong to drop it). - // Part B.3: VAT validation + // Part B.3 / Round 4 Part A.3: VAT validation — collect warnings, then FAIL the seed + // if any row is arithmetically broken (incl < excl or VAT math doesn't add up). + // Per A.3: do not silently correct. Trip loudly so the client confirms the right number. + const vatErrors = []; if (o.amount_incl > 0 && o.amount_excl > 0) { const computedIncl = o.amount_excl + o.vat; if (Math.abs(computedIncl - o.amount_incl) > 0.5) { - VAT_WARN.push(`VAT math broken: "${o.description}" (${o.section}) — excl ${o.amount_excl} + vat ${o.vat} = ${computedIncl}, but incl is ${o.amount_incl}`); + vatErrors.push(`VAT math broken: "${o.description}" (${o.section}) — excl ${o.amount_excl} + vat ${o.vat} = ${computedIncl}, but incl is ${o.amount_incl}`); } if (o.amount_incl < o.amount_excl) { - VAT_WARN.push(`incl < excl: "${o.description}" (${o.section}) — excl ${o.amount_excl}, incl ${o.amount_incl}`); + vatErrors.push(`incl < excl: "${o.description}" (${o.section}) — excl ${o.amount_excl}, incl ${o.amount_incl}`); } if (o.amount_excl > 0 && (o.vat / o.amount_excl) > 0.20) { - VAT_WARN.push(`VAT > 20%: "${o.description}" (${o.section}) — ${((o.vat / o.amount_excl) * 100).toFixed(1)}%`); + vatErrors.push(`VAT > 20%: "${o.description}" (${o.section}) — ${((o.vat / o.amount_excl) * 100).toFixed(1)}%`); } } + // A.3: hard fail on VAT errors. Move them to FATAL so seed exits non-zero. + if (vatErrors.length > 0) { + ohErrors.push(...vatErrors.map((v) => `FATAL-VAT: ${v}`)); + VAT_WARN.push(...vatErrors); + } try { await pb.collection(OVERHEADS_COLL).create({ diff --git a/scripts/verify-cost-reconciliation.mjs b/scripts/verify-cost-reconciliation.mjs index 0e02f15..93964f8 100644 --- a/scripts/verify-cost-reconciliation.mjs +++ b/scripts/verify-cost-reconciliation.mjs @@ -1,10 +1,43 @@ // 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); @@ -60,52 +93,53 @@ for (const o of overheads) { } const rollupTotal = Object.values(bySection).reduce((s, v) => s + v, 0); -// Workbook's own totals (Part B.2) +// 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 SANTAM_DOUBLED = 5322.44; // Part B.4 — double-counted in workbook subtotal -const EXPECTED_ROLLUP = WORKBOOK_GRAND_TOTAL - PLANT_INSTALMENTS - SANTAM_DOUBLED; // 689,640.49 +const TARGET_ROLLUP = WORKBOOK_GRAND_TOTAL - PLANT_INSTALMENTS; // R 694,962.93 -// Source row sum (what's actually in the workbook, not what's stated) -const SOURCE_ROW_SUM = 415523.94 + 14996.31 + 128330.05 + 392200 + 6159.01 + 28800 + 110000; // 1,096,009.31 -const EXPECTED_FROM_ROW_SUM = SOURCE_ROW_SUM - PLANT_INSTALMENTS - 5322.44; // excludes Santam +// 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 stated grand total: R ${WORKBOOK_GRAND_TOTAL.toFixed(2)} (Cost sheet row 98)`); -console.log(`Workbook row sum (all sections): R ${SOURCE_ROW_SUM.toFixed(2)} (R 14,477.56 less than stated — workbook internally inconsistent)`); +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 + Santam already excluded):"); +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)}`); -console.log(""); -console.log(`Expected (from stated grand total): R ${EXPECTED_ROLLUP.toFixed(2)}`); -console.log(`Expected (from row sum): R ${EXPECTED_FROM_ROW_SUM.toFixed(2)}`); -console.log(`Actual: R ${rollupTotal.toFixed(2)}`); -const driftStated = Math.abs(rollupTotal - EXPECTED_ROLLUP); -const driftRows = Math.abs(rollupTotal - EXPECTED_FROM_ROW_SUM); -console.log(`Drift (vs stated): R ${driftStated.toFixed(2)}`); -console.log(`Drift (vs rows): R ${driftRows.toFixed(2)}`); -if (driftRows > 1) { - console.log(`\n✗ Roll-up drift exceeds R 1.00 vs workbook row sum. Investigate.`); +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 workbook row sum within R 1.00.`); + 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 skips Santam ==="); +console.log("\n=== Part B.4 — Vehicle Costs subtotal ==="); const vehicleRows = (bySection["Vehicle Costs"] || 0); -// The workbook's Vehicle Costs subtotal EXCLUDES Santam (R 5,322.44) because the SUM range starts at row 47 not 46. -// The app's R 9,673.87 = Tracking (5,322.44) + Diesel (1,033.29) + Water (3,318.14). -// If app included Santam, it would be R 14,996.31 (matching workbook's stated Vehicle Costs total). -console.log(`App Vehicle Costs total: R ${vehicleRows.toFixed(2)} (excludes Santam per Part B.4 — correct)`); -console.log(`If Santam were included: R ${(vehicleRows + SANTAM_DOUBLED).toFixed(2)} (matches workbook's stated Vehicle Costs total)`); +// 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 total R 412,000, but R 157,200 + R 235,000 = R 392,200. Diff R 19,800 (workbook's own error)."); -console.log(" • Subscriptions: 2 rows have broken VAT (Charl - CCS incl