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>
332 lines
12 KiB
TypeScript
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;
|
|
}
|