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>
87 lines
No EOL
3.2 KiB
TypeScript
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 };
|
|
} |