A machine with no repairs and no purchase order still carries an insurance premium and, while it is financed, a monthly instalment. So a blank row was never "this vehicle costs nothing" — it was "nothing recorded", which is a gap rather than a figure. The Overview now totals all four kinds per machine per month. Everything is read from POCKETBASE, the registers the client maintains in the app. The original spreadsheet only bootstrapped the page (monthly_running_costs, Jan-Aug); it is not a source here. September having no workbook data is expected and correct — the figures there now come from the app. Two finance sources existed and OVERLAPPED. The cost_lines instalment rows are the old workbook's subset (5 machines, R115 576,14/mo); financing_terms is the client's current sheet (18 agreements, R428 585,04/mo). Using both double-counted MG02, RV04, TLB06 and TLB07, so financing_terms wins and the stale rows are ignored. TB01 has no financing_terms row, so its old instalment no longer counts — consistent with it being absent from the current sheet. Insurance has one source and no such problem. Insurance is resolved from the RATE PERIODS rather than the derived cost_lines, so a premium that moves mid-year is charged at the price that applied that month. Also: settled agreements contribute nothing (a paid-off machine is not a running cost), purchase lines are filed under the PO date, and the bottom row keeps showing incl above excl. The page carries one line saying this is a re-cut of costs already inside fixed expenses — adding it to them would count the same premium twice. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
368 lines
13 KiB
TypeScript
368 lines
13 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[] };
|
|
};
|
|
|
|
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) },
|
|
});
|
|
}
|
|
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;
|
|
}
|
|
}
|
|
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;
|
|
}
|