import type { Asset, InvoiceLine, MonthlyIncome, MonthlyRunningCost } from "./data"; import { computeProfit, type ProfitOptions } from "./profit"; 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; months: number[]; ytd: number; }; export function buildRunningRows( assets: Asset[], costs: MonthlyRunningCost[] ): 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, }); } for (const c of costs) { const row = byAsset.get(c.asset); if (!row) continue; row.months[c.month - 1] = c.amount; row.ytd += c.amount; } return Array.from(byAsset.values()); }