- 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
251 lines
10 KiB
JavaScript
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/");
|