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>
122 lines
No EOL
4.9 KiB
TypeScript
122 lines
No EOL
4.9 KiB
TypeScript
import type { InsuranceRate } from "./data";
|
|
|
|
// ⚠️ DO NOT import a .json file from this module. `scripts/seed.mjs` imports it
|
|
// directly (Node strips the TypeScript types), and bare Node refuses a JSON import
|
|
// without an import attribute — it dies with ERR_IMPORT_ATTRIBUTE_MISSING and takes
|
|
// `npm run seed` down with it. Webpack tolerates it; Node does not. App-side policy
|
|
// constants live in `lib/insurance-rate-policy.ts` instead, exactly as
|
|
// `lib/invoice-booking.ts` sits beside `scripts/book-invoice.mjs`'s readFileSync.
|
|
|
|
/**
|
|
* Insurance premiums move every month or every three months (client ruling,
|
|
* 2026-10-08), so a premium is stored as a PERIOD, not as twelve monthly rows.
|
|
* This module is the only place that turns rate history into a cost for a month.
|
|
*
|
|
* The one rule everything else follows: **an absent rate is `null`, never `0`.**
|
|
* A machine with no rate in effect for March was not insured for R0 — it is
|
|
* unknown. Collapsing the two would understate the cost of a gap and let it
|
|
* reconcile against a total that was never actually right.
|
|
*/
|
|
|
|
export type RatePeriod = {
|
|
from: number; // inclusive first month, 1-12
|
|
to: number; // inclusive last month; 0 = OPEN (still the current premium)
|
|
rate: number;
|
|
source: string;
|
|
};
|
|
|
|
/** True when `period` is in force during `month`. An open period (to === 0) runs to the end of the year. */
|
|
export function periodCovers(period: RatePeriod, month: number): boolean {
|
|
return period.from <= month && (period.to === 0 || month <= period.to);
|
|
}
|
|
|
|
/** The premium in force for `month`, or null when no period covers it. */
|
|
export function rateInEffect(periods: RatePeriod[], month: number): number | null {
|
|
const hit = periods.filter((p) => periodCovers(p, month));
|
|
if (hit.length !== 1) return null; // 0 = unknown; >1 = overlapping data, also unknown here
|
|
return hit[0].rate;
|
|
}
|
|
|
|
/**
|
|
* The cost for each of the 12 months, resolved against the rate in force THAT
|
|
* month. `null` means no premium was recorded — never R0.
|
|
*/
|
|
export function monthlyCost(periods: RatePeriod[]): (number | null)[] {
|
|
return Array.from({ length: 12 }, (_, i) => rateInEffect(periods, i + 1));
|
|
}
|
|
|
|
/** Months with no recorded premium — the gap a reader must be told about, not average over. */
|
|
export function uncoveredMonths(periods: RatePeriod[]): number[] {
|
|
return monthlyCost(periods)
|
|
.map((v, i) => (v === null ? i + 1 : null))
|
|
.filter((m): m is number => m !== null);
|
|
}
|
|
|
|
/** The premium currently being charged: the open period, else the latest that has closed. */
|
|
export function currentRate(periods: RatePeriod[]): RatePeriod | null {
|
|
const open = periods.find((p) => p.to === 0);
|
|
if (open) return open;
|
|
return periods.reduce<RatePeriod | null>(
|
|
(latest, p) => (!latest || p.from > latest.from ? p : latest),
|
|
null,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Problems that make a machine's rate history untrustworthy. Returned rather than
|
|
* thrown so the page can warn a reader; `scripts/seed.mjs` turns a non-empty list
|
|
* into a hard failure, because a bad rate period silently mis-costs a year.
|
|
*/
|
|
export function validatePeriods(periods: RatePeriod[]): string[] {
|
|
const problems: string[] = [];
|
|
const open = periods.filter((p) => p.to === 0);
|
|
if (open.length > 1) {
|
|
problems.push(`${open.length} open-ended periods — only one premium can be current`);
|
|
}
|
|
|
|
const sorted = [...periods].sort((a, b) => a.from - b.from);
|
|
for (const p of sorted) {
|
|
if (p.to !== 0 && p.to < p.from) {
|
|
problems.push(`period ${p.from}→${p.to} ends before it starts`);
|
|
}
|
|
if (p.from < 1 || p.from > 12) problems.push(`month_from ${p.from} is not a month`);
|
|
if (p.to > 12) problems.push(`month_to ${p.to} is not a month`);
|
|
}
|
|
|
|
// Overlap: two premiums claiming the same month is not a gap, it is a conflict —
|
|
// and `rateInEffect` would return null for it, silently blanking the month.
|
|
for (let i = 0; i < sorted.length; i++) {
|
|
for (let j = i + 1; j < sorted.length; j++) {
|
|
const a = sorted[i];
|
|
const b = sorted[j];
|
|
const aEnd = a.to === 0 ? 12 : a.to;
|
|
const bEnd = b.to === 0 ? 12 : b.to;
|
|
if (a.from <= bEnd && b.from <= aEnd) {
|
|
problems.push(`periods ${a.from}→${aEnd} and ${b.from}→${bEnd} overlap`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// A closed period that does not meet the next period leaves a hole in between.
|
|
for (let i = 0; i < sorted.length - 1; i++) {
|
|
const a = sorted[i];
|
|
const b = sorted[i + 1];
|
|
if (a.to !== 0 && a.to < b.from - 1) {
|
|
const hole = [];
|
|
for (let m = a.to + 1; m < b.from; m++) hole.push(m);
|
|
problems.push(`no premium for month(s) ${hole.join(", ")} between two periods`);
|
|
}
|
|
}
|
|
|
|
return problems;
|
|
}
|
|
|
|
/** Group an asset's raw rows into periods, newest period first for display. */
|
|
export function toPeriods(rates: InsuranceRate[]): RatePeriod[] {
|
|
return rates.map((r) => ({
|
|
from: r.month_from,
|
|
to: r.month_to || 0,
|
|
rate: r.rate,
|
|
source: r.source || "",
|
|
}));
|
|
} |