TLB06 was paid off in October 2026, but the running-cost rule dropped a SETTLED agreement from EVERY month, so the ten instalments it was really paying (January-October, R290 360,80) vanished from the year to date. An agreement is now charged for the months it was live, and its paid-off date decides when the money stopped: - LIVE: from its start through its paid-off date, or through the current month when no paid-off date is on record. - SETTLED: up to its paid-off date - those instalments really were paid - and nothing at all when there is no paid-off date, because there is then no month we can say it paid for. "Settled" means paid off, or will be paid off by that date, so a settled agreement whose date is still ahead is still charged. TLB06 is charged January-October and NOT November. Nothing else moves: every other agreement's window already ran to November or beyond. - Overview YTD R6 241 894,70 -> R6 532 255,50. The November figure is unchanged at R441 493,12. - Financing register YTD instalments R4 395 038,56 -> R4 685 399,36, and "Committed for November" stays R399 548,96. The Overview and the register still read ONE rule, so their finance year to date is the same figure - asserted in lib/running-month.test.ts, together with the TLB06 case and the live-agreement cases. committedMonthly() in lib/financing.ts was never called and stated the old rule, so it is gone rather than left lying next to the real one.
238 lines
No EOL
10 KiB
TypeScript
238 lines
No EOL
10 KiB
TypeScript
import type { Asset, FinancingTerm, InsuranceRate, MonthlyRunningCost, Purchase, PurchaseLine } from "./data";
|
||
import { RUNNING_VAT_PCT, exclFromIncl } from "./purchase-booking";
|
||
import { hasMonth } from "./financing";
|
||
|
||
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"];
|
||
|
||
/** What each kind is called on screen. Shared so the Overview, the card and the grid
|
||
* name the same part the same way — and so the four parts always add up to the total. */
|
||
export const COST_KIND_LABELS: Record<CostKind, string> = {
|
||
repairs: "Repairs & parts",
|
||
purchases: "Purchases",
|
||
insurance: "Insurance",
|
||
finance: "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.
|
||
*/
|
||
/**
|
||
* The months of `year` an agreement is charged for — 1-based months, so 1 = January.
|
||
*
|
||
* An agreement runs from its start to the month it is paid off, and those dates are
|
||
* typed in by a human (no sheet records them), so a month outside that window is a
|
||
* month the money was NOT moving:
|
||
*
|
||
* - LIVE (not settled): charged from its start through its paid-off date, or through
|
||
* the current month when no paid-off date is on record.
|
||
* - SETTLED: the money has stopped. It is still charged for the months UP TO its
|
||
* paid-off date — those instalments really were paid — and contributes NOTHING when
|
||
* there is no paid-off date on record, because there is then no month we can say it
|
||
* paid for.
|
||
*
|
||
* TLB06 is the case this exists for: settled, paid off in October 2026. It is charged
|
||
* January–October and NOT November (client ruling, 2026-11-05).
|
||
*/
|
||
export function financeMonths(t: FinancingTerm, year: number, horizon: number): number[] {
|
||
const first = year * 12 + 1; // January of `year`, as an absolute month number
|
||
const last = year * 12 + horizon; // the last month the Overview shows
|
||
const start = hasMonth(t.start_year, t.start_month) ? t.start_year * 12 + t.start_month : null;
|
||
const end = hasMonth(t.end_year, t.end_month) ? t.end_year * 12 + t.end_month : null;
|
||
// A settled agreement with no paid-off date: we cannot say when it stopped, so we do
|
||
// not claim any month for it. A machine that is not being financed is not a running
|
||
// cost.
|
||
if (t.is_settled && end === null) return [];
|
||
const from = start === null ? first : Math.max(start, first);
|
||
const to = end === null ? last : Math.min(end, last);
|
||
const months: number[] = [];
|
||
for (let m = from; m <= to; m++) months.push(m - year * 12);
|
||
return months;
|
||
}
|
||
|
||
/**
|
||
* The finance cost per agreement, per month, up to the horizon.
|
||
*
|
||
* The rule is `financeMonths`: an agreement is charged while it was live, and the
|
||
* Overview and the financing register BOTH read this, so the fleet's finance for a
|
||
* month is the same figure on both screens and they cannot drift apart.
|
||
*/
|
||
export function financeByAsset(
|
||
terms: FinancingTerm[],
|
||
horizon: number,
|
||
year = 2026,
|
||
): Map<string, (number | null)[]> {
|
||
const out = new Map<string, (number | null)[]>();
|
||
for (const t of terms) {
|
||
if (!t.asset) continue;
|
||
const months = financeMonths(t, year, horizon);
|
||
if (!months.length) continue;
|
||
const arr = out.get(t.asset) || blank();
|
||
for (const m of months) {
|
||
const at = arr[m - 1];
|
||
arr[m - 1] = at == null ? (t.installment || 0) : at + (t.installment || 0);
|
||
}
|
||
out.set(t.asset, arr);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* The fleet's finance cost per month — every agreement's instalment for the months it
|
||
* was live. The financing register reads this for its own month and year-to-date
|
||
* figures, so the register and the Overview quote the same rands.
|
||
*/
|
||
export function fleetFinanceByMonth(terms: FinancingTerm[], horizon: number, year = 2026): number[] {
|
||
const months = Array(12).fill(0);
|
||
for (const perAsset of financeByAsset(terms, horizon, year).values()) {
|
||
perAsset.forEach((v, i) => {
|
||
if (v != null) months[i] += v;
|
||
});
|
||
}
|
||
return months;
|
||
}
|
||
|
||
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 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 the agreement was
|
||
// live. The rule lives in `financeMonths`: an agreement that was paid off in
|
||
// October is charged up to October and NOT in November, whether or not the
|
||
// settled flag is on. The financing register reads the same helper, so the two
|
||
// screens cannot disagree.
|
||
const finance = financeByAsset(input.finance, horizon, year);
|
||
|
||
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);
|
||
} |