mega-plant/lib/running-costs.ts
David b0c70e9210 Stop the Running Costs grid at the current month
Insurance and finance genuinely recur, so left to fill all twelve months they made
November and December look known — ~R470k a month of phantom figures for months that
have not started, which is the "it will confuse the people" case exactly.

Both halves of the fix are needed:

- machineRunningCosts() caps every source at the horizon, so no phantom figure is even
  computed. YTD is therefore genuinely year-to-date and moves as the year goes on.
- RunningView renders only that many columns. Future months are NOT rendered empty:
  an empty column still reads as "we have a figure of zero", which is the same
  confusion in a quieter form.

October is shown and is expected to grow as repairs and purchases land against a
vehicle. A PO dated beyond the horizon is skipped silently, which is not the same as
an undated PO — only a genuinely unreadable date is reported.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-09 12:35:52 +02:00

155 lines
No EOL
6.6 KiB
TypeScript

import type { Asset, FinancingTerm, InsuranceRate, MonthlyRunningCost, Purchase, PurchaseLine } from "./data";
import { RUNNING_VAT_PCT, exclFromIncl } from "./purchase-booking";
export { RUNNING_VAT_PCT, exclFromIncl };
/**
* Running cost = EVERYTHING linked to a vehicle.
*
* Client ruling, 2026-10-09: insurance, finance, purchases and repairs all belong here.
* A machine with no repairs and no purchase order still has an insurance premium and,
* while it is running, a monthly instalment — so a blank row on the Overview is
* "nothing recorded", never "this machine costs nothing".
*
* ⚠️ The workbook that bootstrapped this app is NOT a source here. It seeded
* `monthly_running_costs` once so the page was not empty on day one; from here the
* figure is built from the registers the client actually maintains in the app.
*
* ⚠️ This is a RE-CUT, not new spend. Insurance and instalments are ALREADY inside
* `monthly_income.fixed_expenses` (workbook term 1 + term 2). Adding the Overview
* total to fixed expenses would count the same premium twice.
*/
export type CostKind = "repairs" | "purchases" | "insurance" | "finance";
/** Per machine, per month, per kind. All VAT-INCLUSIVE (client confirmed 2026-10-09). */
export type MachineCosts = Record<CostKind, (number | null)[]>;
export const COST_KINDS: CostKind[] = ["repairs", "purchases", "insurance", "finance"];
function blank(): (number | null)[] {
return Array(12).fill(null);
}
function add(map: Map<string, (number | null)[]>, asset: string, month: number, amount: number) {
if (!asset || amount == null || Number.isNaN(amount)) return;
const months = map.get(asset) || blank();
const at = months[month - 1];
months[month - 1] = at == null ? amount : at + amount;
map.set(asset, months);
}
export type RunningCostInputs = {
assets: Asset[];
repairs: MonthlyRunningCost[];
purchases: Purchase[];
purchaseLines: PurchaseLine[];
insurance: InsuranceRate[];
finance: FinancingTerm[];
year?: number;
};
/**
* Which month a purchase order lands in: the PO date, i.e. the month it was ORDERED
* (client ruling). Not "Required By" — MEGPPO50532 is dated 07/10 but was required
* 29/08, and those are not the same month.
*/
function poMonth(p: Purchase, year: number): number | null {
const m = /^(\d{4})-(\d{2})/.exec(p.po_date || "");
if (!m) return null;
if (Number(m[1]) !== year) return null;
const month = Number(m[2]);
return month >= 1 && month <= 12 ? month : null;
}
/**
* Assemble all four cost kinds per machine per month.
*
* **Finance comes from `financing_terms`, NOT from `cost_lines` instalment rows.**
* Both exist and they OVERLAP: the `cost_lines` rows are the old workbook's subset
* (MG02, RV04, TLB06, TLB07 — R115 576,14/mo) while `financing_terms` is the
* client's current sheet (18 agreements — R428 585,04/mo). Using both double-counts
* the four shared machines, so the current sheet wins and the stale rows are ignored.
*/
export function machineRunningCosts(input: RunningCostInputs, lastMonth?: number): {
byAsset: Map<string, MachineCosts>;
undatedPurchases: string[];
financeFrom: string;
} {
const year = input.year ?? 2026;
// Never project past the month we are IN. Insurance and finance recur, so left alone
// they would silently fill November and December with ~R470k a month and make the
// sheet look like the year is known when those months have not started (client
// ruling, 2026-10-09). A month ahead cannot be calculated, only guessed at.
const horizon = Math.min(12, Math.max(1, lastMonth ?? 12));
const repairs = new Map<string, (number | null)[]>();
const purchases = new Map<string, (number | null)[]>();
const insurance = new Map<string, (number | null)[]>();
const finance = new Map<string, (number | null)[]>();
const undatedPurchases: string[] = [];
// 1. Repairs & parts — the workbook bootstrap, still the only repair history there is.
for (const r of input.repairs) {
if (r.month <= horizon) add(repairs, r.asset, r.month, r.amount);
}
// 2. Purchase orders — only lines allocated to a machine. An unallocated line is
// real money not yet on a plant item; it stays on /purchases rather than being
// hidden or added to the fleet.
const poById = new Map(input.purchases.map((p) => [p.id, p]));
for (const line of input.purchaseLines) {
if (!line.asset) continue;
const po = poById.get(line.purchase);
if (!po) continue;
const month = poMonth(po, year);
if (month === null) {
// Genuinely undated — reported, never guessed into January.
const label = `${po.po_no} #${line.line_no}`;
if (!undatedPurchases.includes(label)) undatedPurchases.push(label);
continue;
}
// Beyond the horizon: simply not shown yet, which is NOT the same as undated.
if (month > horizon) continue;
add(purchases, line.asset, month, line.total_incl ?? line.amount_excl ?? 0);
}
// 3. Insurance — resolved from the RATE PERIODS, so a rate that moved mid-year is
// charged at the price that applied that month rather than smeared across the year.
for (const rate of input.insurance) {
if (rate.year !== year) continue;
const end = Math.min(rate.month_to || 12, horizon);
for (let m = rate.month_from; m <= end; m++) add(insurance, rate.asset, m, rate.rate);
}
// 4. Finance — a MONTHLY instalment, so it recurs every month, and only while the
// agreement is live. Settled agreements have stopped, so they contribute nothing:
// a paid-off machine is not a running cost.
for (const t of input.finance) {
if (t.is_settled) continue;
for (let m = 1; m <= horizon; m++) add(finance, t.asset, m, t.installment || 0);
}
const byAsset = new Map<string, MachineCosts>();
const seen = new Set([...repairs.keys(), ...purchases.keys(), ...insurance.keys(), ...finance.keys()]);
for (const id of seen) {
byAsset.set(id, {
repairs: repairs.get(id) || blank(),
purchases: purchases.get(id) || blank(),
insurance: insurance.get(id) || blank(),
finance: finance.get(id) || blank(),
});
}
return { byAsset, undatedPurchases, financeFrom: "financing_terms" };
}
/** The combined incl figure for one month across all kinds. */
export function totalForMonth(costs: MachineCosts, monthIndex: number): number {
return COST_KINDS.reduce((s, k) => s + (costs[k][monthIndex] ?? 0), 0);
}
/** Everything that IS priced, for one month — used to tell "R0" from "nothing recorded". */
export function hasAnyForMonth(costs: MachineCosts, monthIndex: number): boolean {
return COST_KINDS.some((k) => (costs[k][monthIndex] ?? 0) > 0);
}