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>
155 lines
No EOL
6.6 KiB
TypeScript
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);
|
|
} |