mega-plant/lib/rows.ts
David f85b9378a2 Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics
Splits the Running Costs group into six purpose-built pages, each with its own
collection and its own source data, plus a coordinate parser for purchase orders.

Insurance (R503 330/yr, 20 vehicles)
  A premium can move every month or three, so it is stored as a RATE PERIOD in
  insurance_rates and each month's cost is resolved against the rate in force
  that month. cost_lines insurance rows are derived from those periods. An absent
  rate is null, never 0 — nobody has entered the date, which is not the same as
  being insured for free.

Financing (20 agreements, R428 585/mo)
  Instalment from the sheet; start date, paid-off date and the settled toggle are
  human-entered and preserved across re-seeds. Months-left and outstanding are
  null until a date is entered, never R0 on a live debt. The two paid-off
  agreements (WB02, DT01) show as settled and are excluded from the run-rate.

Purchases + Oil Stock
  Uploads PO PDFs the way /income/invoices does. The parser reads columns by
  x-position, because plain text extraction reorders them, and refuses any PO
  whose lines do not sum to the sub-total. A cost code either names a machine
  ("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never
  guessed. Oil POs move litres into oil_stock_movements instead of onto a machine;
  stock is the sum of movements so the first usage is a subtraction from history.

Operators (19) and Mechanics (2)
  Both are people with an employee number, separately reconcilable by the seed so
  someone added in the app survives. Mechanics get a badge QR; a mechanic with no
  employee number cannot be printed, because the code is what the QR encodes.

Cross-cutting
  - The seed no longer deletes operators, mechanics or oil items: people are added
    in the app, and those rows exist in no file.
  - Re-pointing allocations that PocketBase clears because `assets` is rebuilt
    with new ids each seed (operators, purchase lines).
  - One QR encoder for screen and PDF, so a printed code cannot drift from the one
    on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them.
  - Every figure is null-safe: unknown is never rendered as zero.

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

332 lines
12 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";
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;
months: number[];
ytd: number;
};
export function buildRunningRows(
assets: Asset[],
costs: MonthlyRunningCost[]
): 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,
});
}
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());
}
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;
}