mega-plant/scripts/extract.mjs
Mega Plant b70fc8c5c7 Round 2 (Parts B-D): asset register restructure, type field, sequential codes, flat sortable table, cost_lines, removed synthetic code, fail loudly
- B.1: removed running_cost from PB monthly_income schema
- B.2: 40 of 40 machines now have income (was 32/40)
- B.3: removed syntheticCode() + auto-create branch; deleted 2 ghost Z* assets; seed fails loudly on unmatched
- C.1: collapsed 13 cost-component rows into 5 machines + new cost_lines collection; Finance/Insurance/Tracking categories removed from assets
- C.2: type field on assets derived from code prefix
- C.3: sequential codes SC01-07, OC01-02, PC01-02; fixed SITE CONTANER spelling
- C.4: flat sortable plant register table with type filter pills
- C.5: wired filter input on plant register
- D: verified term 2 (insurance) reconciles for all 5 financed machines
2026-09-12 10:24:26 +02:00

251 lines
10 KiB
JavaScript

// Regenerate JSON extraction files from the source workbook.
// Reads: ./data/source/original.xlsx
// Writes: ./data/source/{assets,machines,mrc,overheads}.json
//
// Usage: node scripts/extract.mjs
import XLSX from "xlsx";
import { writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const SRC = resolve(__dirname, "..", "data", "source", "original.xlsx");
console.log(`→ Reading ${SRC}`);
const wb = XLSX.readFile(SRC, { cellFormula: false });
function find(ws, range) {
const out = [];
const r = XLSX.utils.decode_range(range);
for (let R = r.s.r; R <= r.e.r; R++) {
const row = [];
for (let C = r.s.c; C <= r.e.c; C++) {
const addr = XLSX.utils.encode_cell({ r: R, c: C });
row.push(ws[addr] ? ws[addr].v : null);
}
out.push(row);
}
return out;
}
// === ASSETS from Sheet1 ===
console.log("\n→ Extracting assets from Sheet1");
const sheet1 = wb.Sheets["Sheet1"];
const assets = [];
// Plant Overheads (rows 6-42)
for (let r = 5; r <= 41; r++) {
const name = sheet1[XLSX.utils.encode_cell({ r, c: 1 })]?.v;
if (!name) continue;
const code = sheet1[XLSX.utils.encode_cell({ r, c: 5 })]?.v ?? "";
const excl = sheet1[XLSX.utils.encode_cell({ r, c: 6 })]?.v;
const incl = sheet1[XLSX.utils.encode_cell({ r, c: 8 })]?.v;
const pv = sheet1[XLSX.utils.encode_cell({ r, c: 9 })]?.v;
const rate = sheet1[XLSX.utils.encode_cell({ r, c: 11 })]?.v;
assets.push({
name: String(name).trim(),
code: String(code).trim(),
category: "Plant",
monthly_excl: typeof excl === "number" ? excl : 0,
monthly_incl: typeof incl === "number" ? incl : 0,
purchase_value: typeof pv === "number" ? pv : 0,
site_rate: typeof rate === "number" ? rate : 0,
});
}
// Vehicle Costs (rows 46-50)
for (let r = 45; r <= 49; r++) {
const name = sheet1[XLSX.utils.encode_cell({ r, c: 1 })]?.v;
if (!name) continue;
const code = sheet1[XLSX.utils.encode_cell({ r, c: 5 })]?.v ?? "";
const excl = sheet1[XLSX.utils.encode_cell({ r, c: 6 })]?.v;
const incl = sheet1[XLSX.utils.encode_cell({ r, c: 8 })]?.v;
const rate = sheet1[XLSX.utils.encode_cell({ r, c: 11 })]?.v;
assets.push({
name: String(name).trim(),
code: String(code).trim(),
category: "Vehicle",
monthly_excl: typeof excl === "number" ? excl : 0,
monthly_incl: typeof incl === "number" ? incl : 0,
purchase_value: 0,
site_rate: typeof rate === "number" ? rate : 0,
});
}
// Bastion finance/insurance/tracking (rows 54-66)
for (let r = 53; r <= 65; r++) {
const name = sheet1[XLSX.utils.encode_cell({ r, c: 1 })]?.v;
if (!name) continue;
const code = sheet1[XLSX.utils.encode_cell({ r, c: 5 })]?.v ?? "";
const gl = sheet1[XLSX.utils.encode_cell({ r, c: 2 })]?.v;
const sup = sheet1[XLSX.utils.encode_cell({ r, c: 3 })]?.v;
const excl = sheet1[XLSX.utils.encode_cell({ r, c: 6 })]?.v;
const incl = sheet1[XLSX.utils.encode_cell({ r, c: 8 })]?.v;
assets.push({
name: String(name).trim(),
code: String(code).trim(),
category: (gl ? String(gl).trim() : "Finance"),
gl_account: gl ? String(gl).trim() : "",
supplier: sup ? String(sup).trim() : "",
monthly_excl: typeof excl === "number" ? excl : 0,
monthly_incl: typeof incl === "number" ? incl : 0,
purchase_value: 0,
site_rate: 0,
});
}
writeFileSync(resolve(__dirname, "..", "data", "source", "assets.json"), JSON.stringify(assets, null, 2));
console.log(` ✓ ${assets.length} assets`);
// === MACHINES from Machine Graph Data ===
console.log("\n→ Extracting machines from Machine Graph Data");
const wsMGD = wb.Sheets["Machine Graph Data"];
const monthNames = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
// Part C.3: assign sequential codes for uncoded duplicates in the workbook.
// Maps "TYPE_<n>" to a counter so "SITE CONTANER #1" → SC01, etc.
// We also fix the spelling (CONTANER → CONTAINER) so it matches assets.json.
// Each entry: { prefix, test(name) → bool, normalised(name) → string }
const UNCODED_TYPES = [
{ prefix: "SC", test: (n) => /^SITE CONTANER/i.test(n) || /^Site Container/i.test(n) },
{ prefix: "OC", test: (n) => /^Office Container/i.test(n) },
{ prefix: "PC", test: (n) => /^Porta camp/i.test(n) },
{ prefix: "RV02", test: (n) => /^ELB 1TON RV/i.test(n) },
{ prefix: "GN100", test: (n) => /^10 KVA GENERATOR/i.test(n) },
{ prefix: "GN075", test: (n) => /^7,5 KVA GENERATOR/i.test(n) },
{ prefix: "GN300", test: (n) => /^30KVA GENERATOR/i.test(n) },
{ prefix: "TR01", test: (n) => /^TRAILOR PLANT/i.test(n) },
{ prefix: "TR02", test: (n) => /^DIESEL TRAILOR/i.test(n) },
{ prefix: "SO01", test: (n) => /^Solar office/i.test(n) },
];
const uncodedCounters = {};
function codeForUncoded(rawName) {
// Normalise the workbook spelling to match assets.json
const name = rawName.replace(/^SITE CONTANER/, "Site Container").trim();
const match = UNCODED_TYPES.find((t) => t.test(name));
if (!match) return null;
// Fixed code (single instance) vs sequential
if (/^\D+\d+$/.test(match.prefix)) return match.prefix;
uncodedCounters[match.prefix] = (uncodedCounters[match.prefix] || 0) + 1;
return `${match.prefix}${String(uncodedCounters[match.prefix]).padStart(2, "0")}`;
}
const machineRows = [];
let currentMachine = null;
let currentData = [];
const range = XLSX.utils.decode_range(wsMGD["!ref"]);
for (let r = range.s.r; r <= range.e.r; r++) {
const a = wsMGD[XLSX.utils.encode_cell({ r, c: 0 })]?.v;
const b = wsMGD[XLSX.utils.encode_cell({ r, c: 1 })]?.v;
if (a == null && b == null) {
if (currentMachine && currentData.length) {
machineRows.push({ machine: currentMachine, data: currentData });
currentMachine = null;
currentData = [];
}
continue;
}
if (typeof a === "string" && a === "Month" && typeof b === "string" && b === "Income") continue;
if (typeof a === "string" && monthNames.includes(a)) {
if (!currentMachine) continue;
const income = wsMGD[XLSX.utils.encode_cell({ r, c: 1 })]?.v;
const fixed = wsMGD[XLSX.utils.encode_cell({ r, c: 2 })]?.v;
const running = wsMGD[XLSX.utils.encode_cell({ r, c: 4 })]?.v;
currentData.push({
month: monthNames.indexOf(a) + 1,
income: typeof income === "number" ? income : 0,
fixed_expenses: typeof fixed === "number" ? fixed : 0,
running_cost: typeof running === "number" ? running : 0,
});
continue;
}
if (typeof a === "string" && !monthNames.includes(a)) {
if (currentMachine && currentData.length) machineRows.push({ machine: currentMachine, data: currentData });
currentMachine = a;
currentData = [];
}
}
if (currentMachine && currentData.length) machineRows.push({ machine: currentMachine, data: currentData });
// Strip code from "NAME (CODE)" pattern OR assign sequential code for uncoded types
const machines = machineRows.map((m) => {
const match = m.machine.match(/\(([^)]+)\)\s*$/);
if (match) {
return {
machine: m.machine.replace(/\s*\([^)]+\)\s*$/, "").trim(),
code: match[1].trim(),
data: m.data,
};
}
// Try sequential-code assignment (Part C.3)
const syntheticCode = codeForUncoded(m.machine);
return {
machine: m.machine.replace(/^SITE CONTANER/, "Site Container").trim(),
code: syntheticCode || "",
data: m.data,
};
});
writeFileSync(resolve(__dirname, "..", "data", "source", "machines.json"), JSON.stringify(machines, null, 2));
console.log(` ✓ ${machines.length} machines (sequential codes: ${Object.keys(uncodedCounters).length} prefixes)`);
// === MRC from Monthly Running Cost sheet ===
console.log("\n→ Extracting monthly running costs");
const wsMRC = wb.Sheets["Monthly Running Cost"];
const monthCols = { 1: 3, 2: 5, 3: 7, 4: 9, 5: 11, 6: 13, 7: 15, 8: 17 };
const mrc = [];
const mrcRange = XLSX.utils.decode_range(wsMRC["!ref"]);
for (let r = 3; r <= 46; r++) {
const name = wsMRC[XLSX.utils.encode_cell({ r, c: 1 })]?.v;
const code = wsMRC[XLSX.utils.encode_cell({ r, c: 2 })]?.v;
if (!code) continue;
for (const [month, col] of Object.entries(monthCols)) {
const v = wsMRC[XLSX.utils.encode_cell({ r, c: col })]?.v;
if (typeof v === "number" && v > 0) {
mrc.push({
name: name ? String(name).trim() : String(code).trim(),
code: String(code).trim(),
month: Number(month),
amount: v,
});
}
}
}
writeFileSync(resolve(__dirname, "..", "data", "source", "mrc.json"), JSON.stringify(mrc, null, 2));
console.log(` ✓ ${mrc.length} running-cost rows`);
// === OVERHEADS from Cost sheet ===
console.log("\n→ Extracting overheads from Cost sheet");
const wsCost = wb.Sheets["Cost"];
const sections = [
{ name: "Plant Overheads", rows: [5, 40] },
{ name: "Vehicle Costs", rows: [45, 48] },
{ name: "Bastion Finance", rows: [53, 65] },
{ name: "Personnel", rows: [70, 74] },
{ name: "Subscriptions", rows: [80, 82] },
{ name: "Rent & Utilities", rows: [87, 88] },
];
const overheads = [];
for (const { name, rows } of sections) {
for (let r = rows[0]; r <= rows[1]; r++) {
const desc = wsCost[XLSX.utils.encode_cell({ r, c: 1 })]?.v;
if (!desc) continue;
const gl = wsCost[XLSX.utils.encode_cell({ r, c: 2 })]?.v;
const sup = wsCost[XLSX.utils.encode_cell({ r, c: 3 })]?.v;
const qty = wsCost[XLSX.utils.encode_cell({ r, c: 4 })]?.v;
const pu = wsCost[XLSX.utils.encode_cell({ r, c: 5 })]?.v;
const amt = wsCost[XLSX.utils.encode_cell({ r, c: 6 })]?.v;
const vat = wsCost[XLSX.utils.encode_cell({ r, c: 7 })]?.v;
const tot = wsCost[XLSX.utils.encode_cell({ r, c: 8 })]?.v;
overheads.push({
section: name,
description: String(desc).trim(),
gl_account: gl ? String(gl).trim() : "",
supplier: sup ? String(sup).trim() : "",
qty: typeof qty === "number" ? qty : 0,
unit_price: typeof pu === "number" ? pu : 0,
amount_excl: typeof amt === "number" ? amt : 0,
vat: typeof vat === "number" ? vat : 0,
amount_incl: typeof tot === "number" ? tot : 0,
});
}
}
writeFileSync(resolve(__dirname, "..", "data", "source", "overheads.json"), JSON.stringify(overheads, null, 2));
console.log(` ✓ ${overheads.length} overhead rows`);
console.log("\n✅ Extraction complete → ./data/source/");