The machine charts plotted the workbook's monthly_income.fixed_expenses, which
is NOT the app's running cost. So the charts and the /running Overview
disagreed about the same machine in the same month — CT01 by R 40 640, DT01 by
R 121 115. The workbook's figure also carries depreciation/ownership and an
operator allocation, which are capital recovery and overhead rather than
running cost.
Every cost value now comes from the running-cost register: fixed = insurance
+ financing, running = purchases. Profit is income - fixed - running, and it
is ONE formula on every surface, so /charts, /income and the dashboard quote
the same figure for the same machine.
R 8 456 243 fleet income, Jan-Oct
- R 4 705 292 fixed (insurance + financing, from the register)
- R 306 387 running (purchases, from the register)
= R 3 444 564 profit (was R 2 510 908 on the workbook's fixed)
The legacy repairs sheet comes out completely. It was the old workbook's
"Plant Monthly Running Cost" — read by no page, maintained by nobody — and a
repair is bought on a purchase order, so its money is already in Purchases.
The collection is still seeded, so the history is not lost, but it is no
longer in any total, any chart or any profit.
Also:
- the profit badge wears the CHART's own green, and profit is green and
loss red on every surface. It was hard-coded to the teal brand colour,
so the label and the line it labelled were two different colours.
- September and October now state a profit instead of a dash: the dash
existed because the workbook had not posted those months' fixed costs,
and the register has the premium and the instalment for every month.
- the containers and the water trailers show profit equal to their income,
because the running-cost model carries no cost for them.
Overheads roll-up — an audit found three things out of order, and the roll-up
total itself reconciles exactly (R 580 485,37, unexplained R 0,00):
- the dashboard's Overhead mix legend named only five of the six sections,
so the figures it listed added up to R 565 489 against a headline of
R 580 485, and the donut drew a slice nobody could name.
- the /overheads note claimed machine instalments were excluded, while the
Bastion Finance block on the same page carried R 115 575,94 of them and
R 11 204,76 of insurance for the same machines. The note now says so,
with the figures read off the rows at render time.
- two Subscriptions rows had VAT that did not balance — the sheet had the
TOTAL in the VAT cell (115%), and annual excl/VAT against a monthly
total. Both are corrected on the client's instruction; the roll-up total
does not move, only the VAT breakdown does, and the seed's VAT gate now
passes instead of exiting non-zero.
Verified: 161 tests pass (4 new ones pin the register join), the production
build is clean, the reconciliation script passes, and the charts, the profit
label and the overhead legend were checked on screen.
406 lines
16 KiB
TypeScript
406 lines
16 KiB
TypeScript
import type { Asset, FinancingTerm, InsuranceRate, InvoiceLine, MonthlyIncome, Operator } from "./data";
|
|
import { computeProfit } from "./profit";
|
|
import { currentRate, monthlyCost, validatePeriods, type RatePeriod } from "./insurance";
|
|
import { financeCalc, toInput, type FinanceCalc } from "./financing";
|
|
import { exclFromIncl } from "./purchase-booking";
|
|
import { COST_KINDS, type MachineCostSeries, type MachineCosts } from "./running-costs";
|
|
|
|
export type IncomeRow = {
|
|
id: string;
|
|
code: string;
|
|
name: string;
|
|
months: (number | null)[]; // Part A.2: null = no data (forecast), 0 = earned zero
|
|
fixedMonths: (number | null)[];
|
|
/** The machine's purchases per month — the running (variable) half of its cost. */
|
|
runningMonths: (number | null)[];
|
|
profit: number[];
|
|
income: number;
|
|
fixed: number;
|
|
running: number;
|
|
ytdProfit: number;
|
|
lastActualMonth: number; // Part A.3: clip x-axis
|
|
// Machines the client has stood down. They still hold their history, so they are kept
|
|
// in the data and filtered in the view rather than dropped here — /income defaults to
|
|
// active only, but the client can switch to All or Inactive.
|
|
isActive: boolean;
|
|
// Portion of `income` that came from invoices, and how much of that is VAT.
|
|
// Booked amounts are VAT-inclusive (see lib/invoice-booking-policy.json), so without
|
|
// this the per-machine figure is not explainable.
|
|
invoiceIncome: number;
|
|
invoiceVat: number;
|
|
// Every invoice that contributed to this machine, so the card can show the combined
|
|
// total and break it down by invoice and site without another query.
|
|
};
|
|
|
|
// Per Part A.2: months beyond lastActualMonth are null in `months`/`fixedMonths`.
|
|
//
|
|
// ⚠️ The two cost terms come from the RUNNING-COST REGISTER, not from the workbook's
|
|
// `monthly_income.fixed_expenses` (client ruling, 2026-11-05). `fixedMonths` is the
|
|
// machine's insurance + finance and `runningMonths` its purchases; the workbook's fixed
|
|
// cost also carries depreciation/ownership and an operator allocation, which are capital
|
|
// recovery and overhead rather than running cost, and using it made the machine charts
|
|
// disagree with the /running Overview about the same machine in the same month.
|
|
//
|
|
// Profit is `income - fixed - running` on every surface — see lib/profit.ts.
|
|
export function buildIncomeRows(
|
|
assets: Asset[],
|
|
income: MonthlyIncome[],
|
|
// The machine's cost series from the register: fixed (insurance + finance) and running
|
|
// (purchases) per month. A machine with no register record has no entry, so it draws no
|
|
// cost line and its profit is its income.
|
|
costSeries?: Map<string, MachineCostSeries>,
|
|
// Invoice line items, used only to attribute the VAT already inside each booked
|
|
// amount back to the machine that earned it.
|
|
invoiceLines?: InvoiceLine[],
|
|
): IncomeRow[] {
|
|
// Determine the last actual month globally (Part A.2/A.3)
|
|
const lastActualMonth = income.reduce((m, r) => r.income > 0 && r.month > m ? r.month : m, 0);
|
|
|
|
const byAsset = new Map<string, IncomeRow>();
|
|
for (const a of assets) {
|
|
if (!a.code) continue;
|
|
const series = costSeries?.get(a.id);
|
|
byAsset.set(a.id, {
|
|
id: a.id, code: a.code, name: a.name,
|
|
isActive: a.is_active !== false,
|
|
months: Array(12).fill(null),
|
|
// The register's series, month by month, clipped to the months that have ACTUALLY
|
|
// happened — the same months the income covers. A cost month past the last income
|
|
// month would be subtracted from a year-to-date figure that does not include it.
|
|
// (The old legacy running-cost series was clipped the same way.)
|
|
//
|
|
// A machine with no register record has no cost to show, so its months stay null —
|
|
// nothing to draw, not a cost of zero.
|
|
fixedMonths: series
|
|
? series.fixed.map((v, i) => (i + 1 <= lastActualMonth ? v : null))
|
|
: Array(12).fill(null),
|
|
runningMonths: series
|
|
? series.running.map((v, i) => (i + 1 <= lastActualMonth ? v : null))
|
|
: Array(12).fill(null),
|
|
profit: Array(12).fill(0),
|
|
income: 0, fixed: 0, running: 0, ytdProfit: 0,
|
|
lastActualMonth,
|
|
invoiceIncome: 0, invoiceVat: 0,
|
|
});
|
|
}
|
|
for (const r of income) {
|
|
const row = byAsset.get(r.asset);
|
|
if (!row) continue;
|
|
const i = r.month - 1;
|
|
// Part A.2: only null out months BEYOND the last actual month. Months up to and including
|
|
// lastActualMonth get their real value (which may be 0 — a real plotted zero).
|
|
//
|
|
// ACCUMULATE, do not assign: a machine can have several income rows in one month —
|
|
// a workbook row plus one per invoice line booked to it. Assigning made the cell show
|
|
// only the last row, so SS01 (two September invoices) displayed R20,700 while its own
|
|
// YTD total included both (R35,305), and the September column total came out short.
|
|
if (r.month <= lastActualMonth) {
|
|
row.months[i] = (row.months[i] ?? 0) + r.income;
|
|
}
|
|
row.income += r.income;
|
|
}
|
|
// The cost totals come off the register, once per machine — not off the income rows, so
|
|
// a machine with several income rows in a month cannot double-count its cost.
|
|
for (const row of byAsset.values()) {
|
|
for (let i = 0; i < 12; i++) {
|
|
row.fixed += row.fixedMonths[i] ?? 0;
|
|
row.running += row.runningMonths[i] ?? 0;
|
|
}
|
|
}
|
|
// Per-month profit is computed ONCE, after income has been accumulated, so it reflects
|
|
// the whole month rather than whichever row happened to be read last. Previously this
|
|
// lived inside the loop above and inherited the same last-write-wins bug.
|
|
for (const row of byAsset.values()) {
|
|
for (let i = 0; i < 12; i++) {
|
|
if (i + 1 > lastActualMonth) continue;
|
|
row.profit[i] = computeProfit({
|
|
income: row.months[i] ?? 0,
|
|
fixed: row.fixedMonths[i] ?? 0,
|
|
running: row.runningMonths[i] ?? 0,
|
|
});
|
|
}
|
|
}
|
|
// Attribute invoice-derived income and its embedded VAT per machine.
|
|
for (const l of invoiceLines ?? []) {
|
|
const row = byAsset.get(l.booked_asset);
|
|
if (!row) continue;
|
|
row.invoiceIncome += l.total_incl || 0;
|
|
row.invoiceVat += l.vat || 0;
|
|
}
|
|
|
|
for (const row of byAsset.values()) {
|
|
row.ytdProfit = computeProfit({ income: row.income, fixed: row.fixed, running: row.running });
|
|
}
|
|
return Array.from(byAsset.values());
|
|
}
|
|
|
|
export type RunningRow = {
|
|
id: string;
|
|
code: string;
|
|
name: string;
|
|
/** Per month, VAT-INCLUSIVE: purchases, insurance and finance combined. */
|
|
months: number[];
|
|
ytd: number;
|
|
/** Per month, net of VAT at the confirmed rate. Shown under `months`, never instead of it. */
|
|
monthsExcl: number[];
|
|
ytdExcl: number;
|
|
/** The purchased portion of each month, so the Overview can say what came from where. */
|
|
purchasesIncl: number[];
|
|
/** False means NO cost of any kind is recorded for that month — which is different
|
|
* from a month that genuinely costs R0. */
|
|
knownMonths: boolean[];
|
|
byKind: { purchases: number[]; insurance: number[]; finance: number[] };
|
|
/** The same three kinds summed for the year, so a card can show WHAT its spend is made
|
|
* of. The parts are the three kinds and nothing else, so they always add up to `ytd`. */
|
|
byKindYtd: { purchases: number; insurance: number; finance: number };
|
|
};
|
|
|
|
export function buildRunningRows(
|
|
assets: Asset[],
|
|
// The three cost kinds per machine per month — purchases, insurance and finance,
|
|
// assembled in lib/running-costs.ts. Everything a vehicle costs to run.
|
|
costs?: Map<string, MachineCosts>,
|
|
): RunningRow[] {
|
|
const byAsset = new Map<string, RunningRow>();
|
|
for (const a of assets) {
|
|
if (!a.code) continue;
|
|
byAsset.set(a.id, {
|
|
id: a.id, code: a.code, name: a.name,
|
|
months: Array(12).fill(0),
|
|
ytd: 0,
|
|
// Net of VAT at the confirmed rate, kept alongside so the Overview can show
|
|
// incl above excl rather than making a reader back the VAT out.
|
|
monthsExcl: Array(12).fill(0),
|
|
ytdExcl: 0,
|
|
purchasesIncl: Array(12).fill(0),
|
|
knownMonths: Array(12).fill(false),
|
|
byKind: { purchases: Array(12).fill(0), insurance: Array(12).fill(0), finance: Array(12).fill(0) },
|
|
byKindYtd: { purchases: 0, insurance: 0, finance: 0 },
|
|
});
|
|
}
|
|
if (!costs) return Array.from(byAsset.values());
|
|
|
|
for (const [assetId, kinds] of costs) {
|
|
const row = byAsset.get(assetId);
|
|
if (!row) continue;
|
|
for (let i = 0; i < 12; i++) {
|
|
let incl = 0;
|
|
for (const k of COST_KINDS) {
|
|
const v = kinds[k][i] ?? 0;
|
|
incl += v;
|
|
row.byKind[k][i] += v;
|
|
}
|
|
if (incl <= 0) continue;
|
|
row.months[i] += incl;
|
|
row.ytd += incl;
|
|
row.purchasesIncl[i] += kinds.purchases[i] ?? 0;
|
|
row.monthsExcl[i] += exclFromIncl(incl);
|
|
row.ytdExcl += exclFromIncl(incl);
|
|
row.knownMonths[i] = true;
|
|
}
|
|
}
|
|
// The three kinds summed for the year. These parts ARE the vehicle's spend — there is
|
|
// no fourth source — so they always add up to `ytd`, which is what the card shows.
|
|
for (const row of byAsset.values()) {
|
|
for (const k of COST_KINDS) row.byKindYtd[k] = row.byKind[k].reduce((s, v) => s + v, 0);
|
|
}
|
|
return Array.from(byAsset.values());
|
|
}
|
|
|
|
export type FinancingRow = {
|
|
id: string;
|
|
code: string;
|
|
name: string;
|
|
plant: string;
|
|
/** Monthly instalment incl VAT, straight from the rate sheet. Not editable in the app. */
|
|
installment: number;
|
|
startYear: number;
|
|
startMonth: number;
|
|
endYear: number;
|
|
endMonth: number;
|
|
isSettled: boolean;
|
|
calc: FinanceCalc;
|
|
};
|
|
|
|
/**
|
|
* One row per finance agreement, with its settlement maths already resolved.
|
|
*
|
|
* A machine with no agreement is left OUT, as everywhere else here: it was not
|
|
* financed, which is a different statement from being financed at R0.
|
|
*/
|
|
export function buildFinancingRows(
|
|
assets: Asset[],
|
|
terms: FinancingTerm[],
|
|
now: { year: number; month: number },
|
|
): FinancingRow[] {
|
|
const byId = new Map(assets.map((a) => [a.id, a]));
|
|
const out: FinancingRow[] = [];
|
|
for (const t of terms) {
|
|
const a = byId.get(t.asset);
|
|
if (!a || !a.code) continue;
|
|
out.push({
|
|
id: t.id,
|
|
code: a.code,
|
|
name: a.name,
|
|
plant: t.plant || "",
|
|
installment: t.installment || 0,
|
|
startYear: t.start_year || 0,
|
|
startMonth: t.start_month || 0,
|
|
endYear: t.end_year || 0,
|
|
endMonth: t.end_month || 0,
|
|
isSettled: Boolean(t.is_settled),
|
|
calc: financeCalc(toInput(t, now)),
|
|
});
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export type OperatorRow = {
|
|
/** null on a row that represents an operator with no machine of their own. */
|
|
machineId: string | null;
|
|
machineCode: string;
|
|
machineName: string;
|
|
operatorId: string | null;
|
|
operatorName: string;
|
|
employeeCode: string;
|
|
rate: number | null;
|
|
/** True when this row exists because of an operator, not a machine. */
|
|
isUnassignedOperator: boolean;
|
|
};
|
|
|
|
/**
|
|
* The operator register, keyed on the PLANT REGISTER but SCOPED to the sheet.
|
|
*
|
|
* Only machines the client's operator sheet lists appear — twenty vehicles. The
|
|
* register holds forty-one coded machines and the difference (containers, generators,
|
|
* truck trailers) can never have a person allocated to them, so listing them would
|
|
* present twenty-odd permanent blanks as if they were staffing gaps. The machine's
|
|
* NAME and CODE still come from the register, so the list stays linked to it.
|
|
*
|
|
* Every in-scope machine gets a row, whether or not anyone is on it: an unstaffed
|
|
* machine is exactly what a reader needs to see. A machine with no operator carries
|
|
* blank operator cells; that is NOT the same as an operator with no rate.
|
|
*
|
|
* Operators the sheet gave no machine get their own row at the end with blank machine
|
|
* cells, because they are people on the payroll and must not vanish from the register.
|
|
*
|
|
* Assets with NO code are skipped even if flagged: the sheet addresses machines by
|
|
* P/Unit, so an uncoded entry cannot be pointed at.
|
|
*/
|
|
export function buildOperatorRows(assets: Asset[], operators: Operator[]): OperatorRow[] {
|
|
const byMachine = new Map<string, Operator>();
|
|
const unassigned: Operator[] = [];
|
|
|
|
for (const o of operators) {
|
|
if (o.asset) byMachine.set(o.asset, o);
|
|
else unassigned.push(o);
|
|
}
|
|
|
|
// The sheet decides the baseline, but a machine someone has been allocated to ALWAYS
|
|
// appears — otherwise adding an operator to a machine that was not on the sheet would
|
|
// look like it silently did nothing. Adding a person must always be visible.
|
|
const inScope = assets.filter(
|
|
(a) => a.code && (a.on_operator_sheet || byMachine.has(a.id)),
|
|
);
|
|
|
|
const rows: OperatorRow[] = inScope.map((a) => {
|
|
const op = byMachine.get(a.id);
|
|
return {
|
|
machineId: a.id,
|
|
machineCode: a.code,
|
|
machineName: a.name,
|
|
operatorId: op?.id ?? null,
|
|
operatorName: op?.name ?? "",
|
|
employeeCode: op?.employee_code ?? "",
|
|
rate: op ? op.rate ?? null : null,
|
|
isUnassignedOperator: false,
|
|
};
|
|
});
|
|
|
|
for (const op of unassigned) {
|
|
rows.push({
|
|
machineId: null,
|
|
machineCode: "",
|
|
machineName: "",
|
|
operatorId: op.id,
|
|
operatorName: op.name,
|
|
employeeCode: op.employee_code,
|
|
rate: op.rate ?? null,
|
|
isUnassignedOperator: true,
|
|
});
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
export type InsuranceRow = {
|
|
id: string;
|
|
code: string;
|
|
name: string;
|
|
// The cost per month, resolved against the premium in force THAT month.
|
|
// `null` = no premium on record for that month — never 0 (see lib/insurance.ts).
|
|
months: (number | null)[];
|
|
// Sum of the months we actually know, INSIDE the year-to-date window. Months with
|
|
// no premium are excluded rather than counted as zero, so this is never quietly
|
|
// smaller than the time that has actually passed.
|
|
ytd: number;
|
|
/** Months of the whole year that carry a premium — gaps included. */
|
|
coveredMonths: number;
|
|
/** Months inside the year-to-date window that carry a premium. What `ytd` sums. */
|
|
chargedMonths: number;
|
|
currentRate: number | null;
|
|
// The machine's premium history, newest first — the point of the page.
|
|
periods: RatePeriod[];
|
|
problems: string[];
|
|
};
|
|
|
|
/**
|
|
* One row per insured machine, built from RATE PERIODS (`insurance_rates`), not from
|
|
* `cost_lines`. The rates are the source of truth; the monthly cost lines are
|
|
* derived from them by the seed, so reading the rates directly is what keeps this
|
|
* page showing a premium change rather than a flat rate re-keyed twelve times.
|
|
*
|
|
* A machine with no rate at all is left OUT, as `buildRunningRows` does for running
|
|
* cost: "no premium on record" is not "R0 of insurance".
|
|
*/
|
|
export function buildInsuranceRows(
|
|
assets: Asset[],
|
|
rates: InsuranceRate[],
|
|
// The last month that has HAPPENED, 1-based. The year-to-date figure sums only the
|
|
// months up to here: a premium for December is a rate that is on file, not a cost the
|
|
// client has incurred yet, and including it made this page's YTD disagree with the
|
|
// Overview's insurance column by exactly one month.
|
|
throughMonth = 12,
|
|
): InsuranceRow[] {
|
|
const byId = new Map(assets.map((a) => [a.id, a]));
|
|
const grouped = new Map<string, RatePeriod[]>();
|
|
|
|
for (const r of rates) {
|
|
const a = byId.get(r.asset);
|
|
if (!a || !a.code) continue;
|
|
const list = grouped.get(a.id) || [];
|
|
list.push({ from: r.month_from, to: r.month_to || 0, rate: r.rate, source: r.source || "" });
|
|
grouped.set(a.id, list);
|
|
}
|
|
|
|
const out: InsuranceRow[] = [];
|
|
for (const [assetId, periods] of grouped) {
|
|
const a = byId.get(assetId)!;
|
|
const cost = monthlyCost(periods);
|
|
const window = cost.slice(0, Math.max(0, Math.min(12, throughMonth)));
|
|
const known = window.filter((v): v is number => v !== null);
|
|
out.push({
|
|
id: assetId,
|
|
code: a.code,
|
|
name: a.name,
|
|
months: cost,
|
|
ytd: known.reduce((s, v) => s + v, 0),
|
|
coveredMonths: cost.filter((v) => v !== null).length,
|
|
chargedMonths: known.length,
|
|
currentRate: currentRate(periods)?.rate ?? null,
|
|
// Newest first — the period you most likely came to read.
|
|
periods: [...periods].sort((x, y) => y.from - x.from),
|
|
problems: validatePeriods(periods),
|
|
});
|
|
}
|
|
return out;
|
|
}
|