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

358 lines
10 KiB
TypeScript

// Shared types and pure data transforms. Safe for client components.
import type { RecordModel } from "pocketbase";
export type Asset = {
id: string;
code: string;
name: string;
category: string;
type: string;
gl_account: string;
supplier: string;
monthly_excl: number;
monthly_incl: number;
purchase_value: number;
is_provisional: boolean;
is_active: boolean;
// TRUE when this machine appears on the client's operator sheet, i.e. it belongs on
// /running/operators. Containers, generators and truck trailers are FALSE — they are
// in the register but nobody is ever allocated to them. Set by the seed from
// data/source/operators.json, not edited by hand.
on_operator_sheet?: boolean;
// Which yard this machine is parked at. Empty until assigned in
// /plant → Edit → Site. cascadeDelete is OFF, so removing a site never
// removes the machines that referenced it.
site: string;
// Plant Map. Null until the on-site scan sheet supplies them. 0/0 counts as
// "not located" — see `hasCoords` in components/PlantMap.
latitude: number | null;
longitude: number | null;
} & RecordModel;
export type CostLine = {
id: string;
asset: string;
year: number;
month: number;
kind: "instalment" | "insurance" | "tracking" | "operator" | "running";
amount: number;
} & RecordModel;
/**
* A finance agreement for one plant vehicle: a monthly instalment, a term, and
* whether it has been settled.
*
* Deliberately NOT part of `insurance_rates` and NOT a `cost_lines` row. Insurance
* and financing are independent commitments that happen to share a rate sheet; the
* date and settled fields here are typed in by a human and must survive a re-seed.
*
* A year or month of `0` means UNKNOWN — never "January". See `lib/financing.ts`.
*/
export type FinancingTerm = {
id: string;
asset: string;
installment: number;
start_year: number;
start_month: number;
end_year: number;
end_month: number;
is_settled: boolean;
plant: string;
source: string;
} & RecordModel;
/**
* An operator, and which plant vehicle they are allocated to.
*
* TERM 3 of the fixed-expense formula — the wage that varies per machine per month,
* and the last unknown in the workbook cost model (see AGENTS.md).
*
* An empty `asset` means UNASSIGNED, which is a normal state on this register: two
* operators had no machine on the sheet. It never means "deleted".
*/
export type Operator = {
id: string;
name: string;
employee_code: string;
rate: number;
asset: string;
source: string;
} & RecordModel;
/**
* A mechanic on the payroll, with the employee number their badge QR encodes.
*
* Not a plant item and not an operator: an operator is allocated TO a machine, a
* mechanic is not. The employee code is the identity — it must be unique, because it
* is what a scanner reads.
*/
export type Mechanic = {
id: string;
name: string;
employee_code: string;
role: string;
photo: string;
source: string;
} & RecordModel;
/**
* A tracked consumable oil, held in bulk and drawn down from a drum.
*
* `store_cost_code` is the code the oil PO prints against the line — "MPLANT, ENGOIL".
* It is what tells an uploaded PO line apart as OIL rather than spend against a
* machine, and the two namespaces cannot collide because a store is never a machine.
*/
export type OilItem = {
id: string;
code: string;
name: string;
detail: string;
supplier: string;
material_code: string;
store_cost_code: string;
drum_litres: number;
colour: string;
qr_payload: string;
source: string;
} & RecordModel;
/**
* One movement in oil stock. "received" when a purchase order delivers oil, "used"
* when a drum is drawn down. Stock is the sum of both, so usage stays auditable.
*/
export type OilMovement = {
id: string;
item: string;
purchase: string;
kind: string;
litres: number;
occurred_on: string;
note: string;
} & RecordModel;
/** A purchase order header: what was ordered, from whom, for how much. */
export type Purchase = {
id: string;
po_no: string;
supplier: string;
supplier_code: string;
po_date: string;
required_by: string;
req_no: string;
currency: string;
subtotal: number;
vat: number;
total: number;
source_pdf: string;
source_file: string;
} & RecordModel;
/**
* One line of a purchase order, allocated to a plant vehicle by its COST CODE.
*
* `cost_code` is kept exactly as printed (hyphenation and all) so the document can
* still be checked line by line; `asset` is where it landed. An EMPTY `asset` means the
* code matched no machine in the register — money that exists but is not yet on a plant
* item. It is never a zero and never a guess.
*/
export type PurchaseLine = {
id: string;
purchase: string;
line_no: number;
material_code: string;
cost_code: string;
asset: string;
description: string;
qty: number;
rate: number;
disc_pct: number;
vat_pct: number;
amount_excl: number;
vat: number;
total_incl: number;
} & RecordModel;
export type MonthlyIncome = {
id: string;
asset: string;
year: number;
month: number;
income: number;
fixed_expenses: number;
running_cost: number;
} & RecordModel;
export type MonthlyRunningCost = {
id: string;
asset: string;
year: number;
month: number;
amount: number;
} & RecordModel;
/**
* An insurance premium for one plant vehicle over a PERIOD of months.
*
* This is deliberately a different grain from `cost_lines`: a premium changes
* every month or three (client ruling, 2026-10-08), so it is recorded once per
* change and the per-month cost is derived from it. `month_to === 0` means the
* period is OPEN — this is the premium currently being charged. See `lib/insurance.ts`.
*/
export type InsuranceRate = {
id: string;
asset: string;
year: number;
month_from: number;
month_to: number;
rate: number;
plant: string;
source: string;
} & RecordModel;
export type Overhead = {
id: string;
section: string;
description: string;
gl_account: string;
supplier: string;
qty: number;
unit_price: number;
amount_excl: number;
vat: number;
amount_incl: number;
} & RecordModel;
// Invoice header. `booked_month`/`booked_year` record the period the invoice was
// posted into monthly_income; `is_booked` flips once every line has a target asset.
export type Invoice = {
id: string;
invoice_no: string;
invoice_date: string;
order_no: string;
debtor: string;
debtor_no: string;
period_from: string;
period_to: string;
description: string;
site: string;
section: string;
category: string;
currency: string;
vat_reg_no: string;
source_file: string;
subtotal: number;
escalation: number;
vat: number;
vat_pct: number;
total_excl: number;
total_incl: number;
source_pdf: string;
booked_month: number;
booked_year: number;
is_booked: boolean;
} & RecordModel;
// Invoice line. `machine_code` is what the invoice prints; `asset_code` is the
// register match; `booked_asset` is the machine the income is actually booked to.
// These can differ — an invoice line may legitimately be charged to another machine.
export type InvoiceLine = {
id: string;
invoice: string;
line_no: number;
machine_code: string;
asset_code: string;
description: string;
qty: number;
unit: string;
rate: number;
rate_type: string;
amount_excl: number;
vat: number;
total_incl: number;
booked_asset: string;
booked_site: string;
booked_month: number;
booked_year: number;
} & RecordModel;
// Returns the last month that has any actual income — used to label forecast ranges.
export function lastActualMonth(rows: MonthlyIncome[]): number {
let m = 0;
for (const r of rows) if (r.income > 0 && r.month > m) m = r.month;
return m;
}
// An invoice line joined to the asset it is booked against. Kept as a plain derived
// type so the page never has to deal with PocketBase expand payloads.
export type BookedLine = InvoiceLine & {
bookedCode: string;
bookedName: string;
isBooked: boolean;
};
export function joinBookedLines(lines: InvoiceLine[], assets: Asset[]): BookedLine[] {
const byId = new Map(assets.map((a) => [a.id, a]));
return lines.map((l) => {
const a = l.booked_asset ? byId.get(l.booked_asset) : undefined;
return {
...l,
bookedCode: a?.code || "",
bookedName: a?.name || "",
isBooked: Boolean(a),
};
});
}
// One machine can legitimately be hired to several sites in the same month — that is
// multi-site use, not a duplicate. These are flagged so the reader can see the pattern,
// NOT as a data error, so nothing here is styled as a problem.
//
// Returns a map of line id → the other (invoice, site) pairs this same machine code was
// billed on over an overlapping period.
export function findDuplicateMachineCodes(
invoices: Invoice[],
lines: BookedLine[],
): Map<string, string[]> {
const byId = new Map(invoices.map((i) => [i.id, i]));
const groups = new Map<string, BookedLine[]>();
for (const l of lines) {
if (!l.machine_code) continue;
if (!groups.has(l.machine_code)) groups.set(l.machine_code, []);
groups.get(l.machine_code)!.push(l);
}
const out = new Map<string, string[]>();
for (const [, group] of groups) {
if (group.length < 2) continue;
for (const line of group) {
const inv = byId.get(line.invoice);
if (!inv) continue;
const others = new Set<string>();
for (const other of group) {
if (other.id === line.id) continue;
const oInv = byId.get(other.invoice);
if (!oInv || oInv.id === inv.id) continue;
const overlaps = inv.period_from <= oInv.period_to && oInv.period_from <= inv.period_to;
if (overlaps) others.add(oInv.invoice_no);
}
if (others.size) out.set(line.id, [...others]);
}
}
return out;
}
export type Site = {
id: string;
name: string;
code: string;
notes: string;
latitude: number;
longitude: number;
container_asset: string;
container_code: string;
is_active: boolean;
} & RecordModel;
export const MONTH_NAMES = [
"Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec",
];