mega-plant/lib/purchase-booking.ts
David aac440cfef Fold purchase orders into the Running Costs Overview, with incl/excl totals
A purchase order buys parts and repairs against a plant item, so its money is
running cost and belongs in the same per-machine, per-month series the Overview
already reads. Without it September showed nothing at all.

Merged at READ time, never copied into monthly_running_costs: that collection is
deleted and rebuilt by every seed, so a purchase written there would vanish on the
next one and have to be re-uploaded. purchase_lines is never deleted, so the money
lives in one place and the Overview adds it up.

- Month comes from the PO date (when it was ordered), not "Required By":
  MEGPPO50532 is dated 07/10 but required 29/08 and those must not be confused.
- Only lines allocated to a machine are counted. An unallocated line is real money
  not yet on a plant item; it stays on /purchases, reported, rather than being
  hidden or added to the fleet total.
- A PO with no readable date is excluded and named, never guessed into January.
- Bottom total row shows incl ABOVE excl per month and for the YTD. Workbook
  figures carry no VAT column, so RUNNING_VAT_PCT (15, confirmed by every PO line)
  is the single constant to revisit if they are ever restated ex-VAT.

September now reads R 5 842 incl / R 5 080 excl from the two September POs.

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

87 lines
No EOL
3.2 KiB
TypeScript

import type { Purchase, PurchaseLine } from "./data";
/**
* Purchases are running cost. A PO buys parts and repairs against a plant item, so
* its money belongs in the same per-machine, per-month series as the workbook's
* "Plant Monthly Running Cost" — and the Overview is where somebody reads the total
* for a month.
*
* They are MERGED AT READ TIME rather than copied into `monthly_running_costs`, for
* one decisive reason: that collection is deleted and rebuilt by every seed, so any
* purchase written into it would vanish on the next one and have to be re-imported.
* `purchase_lines` is never deleted, so the money lives in one place and the Overview
* simply adds it up.
*/
/**
* VAT rate used to split a workbook running-cost figure, which records ONE amount
* with no VAT column (client confirmed 2026-10-09 that it is the amount paid, i.e.
* VAT-INCLUSIVE). Purchase lines state their own VAT% — all 14 read 15.00 — so the
* same rate is applied to the workbook side to produce a comparable excl figure.
*/
export const RUNNING_VAT_PCT = 15;
/** Split a VAT-inclusive amount into its net value. */
export function exclFromIncl(incl: number, vatPct: number = RUNNING_VAT_PCT): number {
return incl / (1 + vatPct / 100);
}
/** One machine's spend in one month, on both bases. */
export type RunningSlice = { incl: number; excl: number };
/** The month a purchase order lands in: the PO date, i.e. the month it was ordered. */
function poMonth(p: Purchase): number | null {
const m = /^(\d{4})-(\d{2})/.exec(p.po_date || "");
if (!m) return null;
const month = Number(m[2]);
return month >= 1 && month <= 12 ? month : null;
}
/**
* Per-machine, per-month spend from uploaded purchase orders.
*
* Only lines ALLOCATED to a machine are counted. An unallocated line is real money
* that is not yet on a plant item — adding it to no machine would hide it, and adding
* it to the fleet total would overstate a specific machine. It stays on /purchases,
* reported as unallocated, until it is pointed somewhere.
*
* A purchase with no readable PO date is skipped and surfaced in `undated`, rather
* than being quietly filed under January.
*/
export function purchaseRunningCosts(
purchases: Purchase[],
lines: PurchaseLine[],
year = 2026,
): {
byAsset: Map<string, (RunningSlice | null)[]>;
undated: string[];
} {
const poById = new Map(purchases.map((p) => [p.id, p]));
const byAsset = new Map<string, (RunningSlice | null)[]>();
const undated: string[] = [];
for (const line of lines) {
if (!line.asset) continue;
const po = poById.get(line.purchase);
if (!po) continue;
if (po.year && po.year !== year) continue;
const month = poMonth(po);
if (month === null) {
const label = `${po.po_no} #${line.line_no}`;
if (!undated.includes(label)) undated.push(label);
continue;
}
const incl = line.total_incl ?? line.amount_excl ?? 0;
const excl = line.amount_excl ?? exclFromIncl(incl);
const months = byAsset.get(line.asset) || Array(12).fill(null);
const at = months[month - 1];
months[month - 1] = at
? { incl: at.incl + incl, excl: at.excl + excl }
: { incl, excl };
byAsset.set(line.asset, months);
}
return { byAsset, undated };
}