The four kinds under running cost - repairs & parts, purchases, insurance and finance - ARE the whole of a vehicle's running cost. That was true of the arithmetic but not of the screen: a card showed one number with no visible parts, and the only breakdown was a hover tooltip on the grid's total row. - Each card now shows its four parts, so what the vehicle's spend is made of is visible without expanding it. A vehicle with no spend shows nothing: "nothing recorded" is already on the card. - The expanded panel shows the four parts against the vehicle's total. - The grid's footer shows the fleet's four parts above the fleet total, so a month column adds up on the screen instead of on trust. - The page header states the fleet's four parts and the total side by side. The parts ARE the spend and there is no fifth source, so the parts and the total can never disagree. Any change that touches a cost kind must keep that additivity true. Also: the labels on this page were text-[10px], under the 12px floor the mobile checklist sets. They are now 12px. Two rulings recorded in AGENTS.md: - FUEL IS NOT A PLANT RUNNING COST. The sites are invoiced for the fuel they use. The only fuel that is a plant cost is the fuel a plant vehicle takes from the tank for plant work, so a fuel log is never a running-cost source however many litres it records. - Only what is under running cost as it stands. The workbook's per-vehicle fixed cost also carries depreciation/ownership and an operator allocation; that is capital recovery and overhead, not running cost, so it stays out - and the vehicles it covers (the excavator, the site and office containers, the generators, the trailers) correctly show no running cost on the Overview.
377 lines
14 KiB
TypeScript
377 lines
14 KiB
TypeScript
import type { Asset, FinancingTerm, InsuranceRate, InvoiceLine, MonthlyIncome, MonthlyRunningCost, Operator } from "./data";
|
|
import { computeProfit, type ProfitOptions } 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 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)[];
|
|
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`/`runningMonths`.
|
|
// `running` is derived from monthly_running_costs; null means we have no running data for that month.
|
|
export function buildIncomeRows(
|
|
assets: Asset[],
|
|
income: MonthlyIncome[],
|
|
running?: MonthlyRunningCost[],
|
|
profitOpts: ProfitOptions = {},
|
|
// 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;
|
|
byAsset.set(a.id, {
|
|
id: a.id, code: a.code, name: a.name,
|
|
isActive: a.is_active !== false,
|
|
months: Array(12).fill(null),
|
|
fixedMonths: Array(12).fill(null),
|
|
runningMonths: 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.fixedMonths[i] = (row.fixedMonths[i] ?? 0) + r.fixed_expenses;
|
|
}
|
|
row.income += r.income;
|
|
row.fixed += r.fixed_expenses;
|
|
}
|
|
// Per Part 1.5: running cost is derived from monthly_running_costs, not stored on income.
|
|
for (const c of running ?? []) {
|
|
const row = byAsset.get(c.asset);
|
|
if (!row) continue;
|
|
row.running += c.amount;
|
|
if (c.month <= lastActualMonth) {
|
|
row.runningMonths[c.month - 1] = c.amount;
|
|
}
|
|
}
|
|
|
|
// Per-month profit is computed ONCE, after income and running have both been
|
|
// accumulated, so it reflects the whole month rather than whichever row happened to be
|
|
// read last. Previously this lived inside the two loops 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,
|
|
},
|
|
profitOpts,
|
|
);
|
|
}
|
|
}
|
|
// 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 }, profitOpts);
|
|
}
|
|
return Array.from(byAsset.values());
|
|
}
|
|
|
|
export type RunningRow = {
|
|
id: string;
|
|
code: string;
|
|
name: string;
|
|
/** Per month, VAT-INCLUSIVE: the workbook figure plus any purchased parts. */
|
|
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: { repairs: number[]; purchases: number[]; insurance: number[]; finance: number[] };
|
|
/** The same four kinds summed for the year, so a card can show WHAT its spend is made
|
|
* of. The parts are the four kinds and nothing else, so they always add up to `ytd`. */
|
|
byKindYtd: { repairs: number; purchases: number; insurance: number; finance: number };
|
|
};
|
|
|
|
export function buildRunningRows(
|
|
assets: Asset[],
|
|
// All four cost kinds per machine per month — repairs, 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: { repairs: Array(12).fill(0), purchases: Array(12).fill(0), insurance: Array(12).fill(0), finance: Array(12).fill(0) },
|
|
byKindYtd: { repairs: 0, 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 four kinds summed for the year. These parts ARE the vehicle's spend — there is
|
|
// no fifth 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. Months with no premium are excluded rather
|
|
// than counted as zero, so this is never quietly smaller than a full year.
|
|
ytd: number;
|
|
coveredMonths: 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[]): 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 known = cost.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: 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;
|
|
}
|