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(); 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, ): RunningRow[] { const byAsset = new Map(); 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(); 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(); 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; }