mega-plant/lib/insurance.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

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 || "",
}));
}