mega-plant/lib/rows.ts
David aac440cfef Fold purchase orders into the Running Costs Overview, with incl/excl totals
A purchase order buys parts and repairs against a plant item, so its money is
running cost and belongs in the same per-machine, per-month series the Overview
already reads. Without it September showed nothing at all.

Merged at READ time, never copied into monthly_running_costs: that collection is
deleted and rebuilt by every seed, so a purchase written there would vanish on the
next one and have to be re-uploaded. purchase_lines is never deleted, so the money
lives in one place and the Overview adds it up.

- Month comes from the PO date (when it was ordered), not "Required By":
  MEGPPO50532 is dated 07/10 but required 29/08 and those must not be confused.
- Only lines allocated to a machine are counted. An unallocated line is real money
  not yet on a plant item; it stays on /purchases, reported, rather than being
  hidden or added to the fleet total.
- A PO with no readable date is excluded and named, never guessed into January.
- Bottom total row shows incl ABOVE excl per month and for the YTD. Workbook
  figures carry no VAT column, so RUNNING_VAT_PCT (15, confirmed by every PO line)
  is the single constant to revisit if they are ever restated ex-VAT.

September now reads R 5 842 incl / R 5 080 excl from the two September POs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-09 12:10:13 +02:00

366 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, type RunningSlice } from "./purchase-booking";
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[];
};
export function buildRunningRows(
assets: Asset[],
costs: MonthlyRunningCost[],
// Purchases merged in on top of the workbook, per machine and month, VAT-INCLUSIVE
// (see lib/purchase-booking.ts). Optional so the shape stays usable where only the
// workbook is loaded.
purchases?: Map<string, (RunningSlice | null)[]>,
): 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, derived at the confirmed rate. Kept alongside so the Overview can
// show incl above excl rather than making the reader back the VAT out.
monthsExcl: Array(12).fill(0),
ytdExcl: 0,
purchasesIncl: Array(12).fill(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;
row.monthsExcl[c.month - 1] = exclFromIncl(c.amount);
row.ytdExcl += exclFromIncl(c.amount);
}
if (purchases) {
for (const [assetId, months] of purchases) {
const row = byAsset.get(assetId);
if (!row) continue;
months.forEach((slice, i) => {
if (slice == null) return;
// Use the excl the PO line actually carries rather than backing it out of the
// incl — the document states both, so there is no reason to re-derive one.
row.months[i] += slice.incl;
row.ytd += slice.incl;
row.purchasesIncl[i] += slice.incl;
row.monthsExcl[i] += slice.excl;
row.ytdExcl += slice.excl;
});
}
}
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;
}