The container lines (MPLANT04/MPLANT05) have no matching plant item, so they
sat unbooked forever with no way to account for them. They can now be booked
to a SITE instead.
`sites` is a new PocketBase collection derived from the invoices — each
distinct invoice site becomes a site, created as invoices are imported. They
are never hand-typed, and never deleted: a site can carry notes and hold line
bookings, so removing one would orphan that history.
/sites lists every site with its revenue ex VAT, its VAT, the total billed,
how many invoices and machines it carries, and how many lines are still
outstanding. Each row links to a detail page listing the site's invoices and
every line item with its ex-VAT/VAT/total split. Nav: Dashboard is now a
group with Overview and Sites.
Booking a line to a site posts NO income — there is no machine to carry it —
it only marks the line accounted for, so it stops showing as unbooked. Exactly
one of booked_asset / booked_site is ever set, and a line with neither is what
surfaces as unbooked everywhere.
One container per site, as specified: once a machine-less line has taken a
site, that site is withheld from the next one's picker and shown disabled
("already has one") rather than hidden. Free sites sort to the top. The rule is
enforced server-side too, so it does not depend on the UI being the only way in —
re-posting a taken site is refused with 409. Verified: booking The Hills, then
trying again, then taking West Park, and the remaining line sees 8 free and 2
taken. Clearing a booking frees its site.
The site picker only appears on lines that need it — no machine, or one already
booked to a site — so fully-booked rows keep a single control.
Also fixed the Sidebar's group-active check, which derived its prefix from the
first child. For a group whose first child is "/" that yields "/" and matched
every route, so Dashboard would have been highlighted on every page.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
444 lines
18 KiB
JavaScript
444 lines
18 KiB
JavaScript
// Seed PocketBase with data extracted from data/source/*.json
|
|
// Regenerate those files with `node scripts/extract.mjs`
|
|
//
|
|
// Usage: PB_ADMIN=... PB_PASS=... node scripts/seed.mjs
|
|
import PocketBase from "pocketbase";
|
|
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
|
import { fileURLToPath } from "node:url";
|
|
import { dirname, resolve } from "node:path";
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const DATA_DIR = resolve(__dirname, "..", "data", "source");
|
|
|
|
// Load .env.local so `npm run seed` works without exporting vars by hand.
|
|
// Explicit process.env values still win — CI/deploys can override.
|
|
const envPath = resolve(__dirname, "..", ".env.local");
|
|
if (existsSync(envPath)) {
|
|
for (const line of readFileSync(envPath, "utf8").split("\n")) {
|
|
const t = line.trim();
|
|
if (!t || t.startsWith("#") || !t.includes("=")) continue;
|
|
const i = t.indexOf("=");
|
|
const k = t.slice(0, i).trim();
|
|
if (!process.env[k]) process.env[k] = t.slice(i + 1).trim();
|
|
}
|
|
}
|
|
|
|
// Read required env — fail loudly if missing (per Part 5.2)
|
|
const PB = process.env.NEXT_PUBLIC_PB_URL;
|
|
const EMAIL = process.env.PB_ADMIN;
|
|
const PASS = process.env.PB_PASS;
|
|
const errors = [];
|
|
if (!PB) errors.push("NEXT_PUBLIC_PB_URL");
|
|
if (!EMAIL) errors.push("PB_ADMIN");
|
|
if (!PASS) errors.push("PB_PASS");
|
|
if (errors.length) {
|
|
console.error(`✗ Missing required env: ${errors.join(", ")}`);
|
|
console.error(" Copy .env.example to .env.local and fill in.");
|
|
process.exit(1);
|
|
}
|
|
|
|
// Placeholder pin for every machine that has no coordinate of its own, so the
|
|
// Plant Map is never blank. Every asset therefore shares one point until the
|
|
// on-site scan sheet supplies real per-machine coordinates — any explicit
|
|
// `latitude`/`longitude` in assets.json wins over this.
|
|
// When the sheet lands, delete these two lines and the `?? DEFAULT_GEO` fallbacks
|
|
// below; the JSON values will carry every pin from then on.
|
|
const DEFAULT_GEO = {
|
|
latitude: -26.10161543177211,
|
|
longitude: 28.34872485354214,
|
|
};
|
|
|
|
const ASSETS_COLL = "assets";
|
|
const INCOME_COLL = "monthly_income";
|
|
const RUN_COLL = "monthly_running_costs";
|
|
const OVERHEADS_COLL = "overheads";
|
|
const COST_LINES_COLL = "cost_lines";
|
|
const INVOICES_COLL = "invoices";
|
|
const INVOICE_LINES_COLL = "invoice_lines";
|
|
|
|
const assets = JSON.parse(readFileSync(resolve(DATA_DIR, "assets.json"), "utf8"));
|
|
const machines = JSON.parse(readFileSync(resolve(DATA_DIR, "machines.json"), "utf8"));
|
|
const mrc = JSON.parse(readFileSync(resolve(DATA_DIR, "mrc.json"), "utf8"));
|
|
const overheads = JSON.parse(readFileSync(resolve(DATA_DIR, "overheads.json"), "utf8"));
|
|
const costLines = JSON.parse(readFileSync(resolve(DATA_DIR, "cost_lines.json"), "utf8"));
|
|
|
|
// Invoices: one JSON per source PDF, all loaded automatically. Dropping a new
|
|
// invoice-*.json into data/source is the whole procedure for adding another invoice.
|
|
const invoiceFiles = readdirSync(DATA_DIR).filter((f) => /^invoice-.*\.json$/i.test(f)).sort();
|
|
const invoices = invoiceFiles.map((f) => ({
|
|
...JSON.parse(readFileSync(resolve(DATA_DIR, f), "utf8")),
|
|
_file: f,
|
|
}));
|
|
|
|
const pb = new PocketBase(PB);
|
|
pb.autoCancellation(false);
|
|
|
|
console.log(`→ Connecting to ${PB} as ${EMAIL}`);
|
|
await pb.collection("_superusers").authWithPassword(EMAIL, PASS); // Part 5.3: _superusers, not admins
|
|
console.log(" ✓ authenticated");
|
|
|
|
// Wipe existing rows so we can re-seed cleanly
|
|
for (const c of [ASSETS_COLL, INCOME_COLL, RUN_COLL, OVERHEADS_COLL, COST_LINES_COLL, INVOICE_LINES_COLL, INVOICES_COLL]) {
|
|
const items = await pb.collection(c).getFullList();
|
|
for (const r of items) await pb.collection(c).delete(r.id);
|
|
}
|
|
console.log(" ✓ wiped existing rows");
|
|
|
|
// === Build alias map for code mismatches between graph sheet and asset register ===
|
|
// Per Part 1.4 / Part B.3: derived from the workbook, not invented. Unmatched rows
|
|
// must produce a loud failure, not a silent ghost.
|
|
const codeAliases = {
|
|
"428E": "TLB02", // CAT 425F (428E) → TLB02
|
|
"430": "SS01", // CASE420 (430) → first SS01 (handled below)
|
|
};
|
|
function resolveCode(graphCode, graphName) {
|
|
if (!graphCode) return null;
|
|
if (codeAliases[graphCode]) {
|
|
// Special-case 430 — first match wins unless name indicates second
|
|
if (graphCode === "430" && /SS02/i.test(graphName)) return "SS02";
|
|
return codeAliases[graphCode];
|
|
}
|
|
return graphCode;
|
|
}
|
|
|
|
// Name normalisation for fallback matching
|
|
function normalizeName(n) {
|
|
return String(n).toLowerCase().replace(/[^a-z0-9]+/g, "").slice(0, 12);
|
|
}
|
|
|
|
// === Insert assets ===
|
|
console.log(`\n→ Seeding ${assets.length} assets`);
|
|
const codeToId = {};
|
|
const nameToId = {};
|
|
let ok = 0;
|
|
const seedErrors = [];
|
|
for (const a of assets) {
|
|
try {
|
|
const rec = await pb.collection(ASSETS_COLL).create({
|
|
code: a.code,
|
|
name: a.name,
|
|
category: a.category || "Plant",
|
|
type: a.type || "", // Part C.2
|
|
gl_account: a.gl_account || "",
|
|
supplier: a.supplier || "",
|
|
monthly_excl: a.monthly_excl || 0,
|
|
monthly_incl: a.monthly_incl || 0,
|
|
purchase_value: a.purchase_value || 0,
|
|
is_provisional: a.is_provisional || false, // Part C.3
|
|
is_active: a.is_active !== false, // default true; only "AEB Plant Versekering" is false
|
|
// Plant Map — falls back to the shared placeholder pin until the
|
|
// on-site scan sheet supplies a real coordinate for this machine.
|
|
latitude: a.latitude ?? DEFAULT_GEO.latitude,
|
|
longitude: a.longitude ?? DEFAULT_GEO.longitude,
|
|
});
|
|
if (a.code) codeToId[a.code] = rec.id;
|
|
nameToId[normalizeName(a.name)] = rec.id;
|
|
ok++;
|
|
} catch (e) {
|
|
seedErrors.push(`asset "${a.name}": ${e.response?.data?.message || e.message}`);
|
|
}
|
|
}
|
|
console.log(` ✓ ${ok}/${assets.length} assets inserted`);
|
|
|
|
// === Insert cost_lines (Part C.1 — finance/insurance/tracking per machine) ===
|
|
console.log(`\n→ Seeding ${costLines.length} cost_lines`);
|
|
let clRows = 0;
|
|
const clErrors = [];
|
|
for (const cl of costLines) {
|
|
const assetId = codeToId[cl.asset_code];
|
|
if (!assetId) { clErrors.push(`cost_line asset_code=${cl.asset_code}: asset not found`); continue; }
|
|
try {
|
|
await pb.collection(COST_LINES_COLL).create({
|
|
asset: assetId,
|
|
year: cl.year,
|
|
month: cl.month,
|
|
kind: cl.kind,
|
|
amount: cl.amount,
|
|
});
|
|
clRows++;
|
|
} catch (e) {
|
|
clErrors.push(`cost_line ${cl.asset_code} ${cl.kind} m=${cl.month}: ${e.response?.data?.message || e.message}`);
|
|
}
|
|
}
|
|
console.log(` ✓ ${clRows} cost_lines inserted`);
|
|
|
|
// === Insert monthly_income from Machine Graph Data ===
|
|
// Part 1.5: monthly_income no longer carries running_cost — we derive it from monthly_running_costs on read.
|
|
// Part 1.4: alias map + synthetic codes for uncoded items, fail loudly on unmatched rows.
|
|
console.log("\n→ Seeding monthly_income from Machine Graph Data");
|
|
let incomeRows = 0;
|
|
const incomeErrors = [];
|
|
const lastActualMonth = (() => {
|
|
// Last month with any recorded income across the whole workbook
|
|
let m = 0;
|
|
for (const x of machines) for (const d of x.data) if (d.income > 0 && d.month > m) m = d.month;
|
|
return m;
|
|
})();
|
|
for (const m of machines) {
|
|
const resolvedCode = resolveCode(m.code, m.machine);
|
|
let assetId = resolvedCode ? codeToId[resolvedCode] : null;
|
|
if (!assetId && m.machine) assetId = nameToId[normalizeName(m.machine)];
|
|
// Part B.3: NO synthetic code, NO auto-create. Unmatched → loud failure.
|
|
if (!assetId) {
|
|
incomeErrors.push(`machine "${m.machine}" (code=${m.code || "—"}) — no asset matched. Add a code or sequential alias to assets.json and re-seed.`);
|
|
continue;
|
|
}
|
|
for (const d of m.data) {
|
|
const isForecast = d.month > lastActualMonth;
|
|
try {
|
|
await pb.collection(INCOME_COLL).create({
|
|
asset: assetId,
|
|
year: 2026,
|
|
month: d.month,
|
|
income: d.income,
|
|
fixed_expenses: d.fixed_expenses,
|
|
is_forecast: isForecast, // Part 1.2
|
|
// running_cost removed (Part 1.5) — derived from monthly_running_costs at read time
|
|
});
|
|
incomeRows++;
|
|
} catch (e) {
|
|
incomeErrors.push(`income ${m.code || m.machine} m=${d.month}: ${e.response?.data?.message || e.message}`);
|
|
}
|
|
}
|
|
}
|
|
console.log(` ✓ ${incomeRows} income rows inserted`);
|
|
|
|
// === Insert monthly_running_costs ===
|
|
console.log("\n→ Seeding monthly_running_costs");
|
|
let runRows = 0;
|
|
const runErrors = [];
|
|
for (const r of mrc) {
|
|
let assetId = codeToId[r.code];
|
|
if (!assetId) assetId = nameToId[normalizeName(r.name)];
|
|
if (!assetId) { runErrors.push(`running ${r.code || r.name} m=${r.month}: no asset matched`); continue; }
|
|
if (!r.amount) continue;
|
|
try {
|
|
await pb.collection(RUN_COLL).create({
|
|
asset: assetId,
|
|
year: 2026,
|
|
month: r.month,
|
|
amount: r.amount,
|
|
});
|
|
runRows++;
|
|
} catch (e) {
|
|
runErrors.push(`running ${r.code} m=${r.month}: ${e.response?.data?.message || e.message}`);
|
|
}
|
|
}
|
|
console.log(` ✓ ${runRows} running-cost rows inserted`);
|
|
|
|
// === Insert overheads ===
|
|
// Part 1.3: skip "Plant Overheads" — those rows are machines already in assets.
|
|
// Part B.4: skip "Santam versekering" — double-counted in the workbook's own subtotal.
|
|
console.log("\n→ Seeding overheads (excluding Plant Overheads + Santam)");
|
|
let ohRows = 0;
|
|
const ohErrors = [];
|
|
const VAT_WARN = []; // Part B.3: collect VAT anomalies — DO NOT silent-correct, report loudly
|
|
for (const o of overheads) {
|
|
if (o.section === "Plant Overheads") continue; // Part 1.3
|
|
// Note: Santam is kept (Round 4 Part A.2 — round 3 was wrong to drop it).
|
|
|
|
// Part B.3 / Round 4 Part A.3: VAT validation — collect warnings, then FAIL the seed
|
|
// if any row is arithmetically broken (incl < excl or VAT math doesn't add up).
|
|
// Per A.3: do not silently correct. Trip loudly so the client confirms the right number.
|
|
const vatErrors = [];
|
|
if (o.amount_incl > 0 && o.amount_excl > 0) {
|
|
const computedIncl = o.amount_excl + o.vat;
|
|
if (Math.abs(computedIncl - o.amount_incl) > 0.5) {
|
|
vatErrors.push(`VAT math broken: "${o.description}" (${o.section}) — excl ${o.amount_excl} + vat ${o.vat} = ${computedIncl}, but incl is ${o.amount_incl}`);
|
|
}
|
|
if (o.amount_incl < o.amount_excl) {
|
|
vatErrors.push(`incl < excl: "${o.description}" (${o.section}) — excl ${o.amount_excl}, incl ${o.amount_incl}`);
|
|
}
|
|
if (o.amount_excl > 0 && (o.vat / o.amount_excl) > 0.20) {
|
|
vatErrors.push(`VAT > 20%: "${o.description}" (${o.section}) — ${((o.vat / o.amount_excl) * 100).toFixed(1)}%`);
|
|
}
|
|
}
|
|
// A.3: hard fail on VAT errors. Move them to FATAL so seed exits non-zero.
|
|
if (vatErrors.length > 0) {
|
|
ohErrors.push(...vatErrors.map((v) => `FATAL-VAT: ${v}`));
|
|
VAT_WARN.push(...vatErrors);
|
|
}
|
|
|
|
try {
|
|
await pb.collection(OVERHEADS_COLL).create({
|
|
section: o.section,
|
|
description: o.description,
|
|
gl_account: o.gl_account || "",
|
|
supplier: o.supplier || "",
|
|
qty: o.qty || 0,
|
|
unit_price: o.unit_price || 0,
|
|
amount_excl: o.amount_excl || 0,
|
|
vat: o.vat || 0,
|
|
amount_incl: o.amount_incl || 0,
|
|
});
|
|
ohRows++;
|
|
} catch (e) {
|
|
ohErrors.push(`overhead "${o.description}": ${e.response?.data?.message || e.message}`);
|
|
}
|
|
}
|
|
console.log(` ✓ ${ohRows} overhead rows inserted`);
|
|
|
|
if (VAT_WARN.length > 0) {
|
|
console.log(`\n ⚠ ${VAT_WARN.length} VAT anomalies (Part B.3 — flagged, NOT corrected):`);
|
|
for (const w of VAT_WARN) console.log(` - ${w}`);
|
|
}
|
|
|
|
// === Insert invoices + invoice lines ===
|
|
// Every data/source/invoice-*.json is picked up automatically, so dropping a new
|
|
// invoice PDF's transcription in that folder is all it takes to add another one.
|
|
//
|
|
// The line items are transcribed from the source PDF, so the arithmetic is re-checked
|
|
// here rather than trusted. Per the project's standing rule: never silently correct a
|
|
// figure to make it balance — trip loudly and let the client confirm.
|
|
console.log(`\n→ Seeding ${invoices.length} invoice(s)`);
|
|
const invErrors = [];
|
|
const round2 = (n) => Math.round(n * 100) / 100;
|
|
let invoicesSeeded = 0;
|
|
let lineRows = 0;
|
|
|
|
for (const inv of invoices) {
|
|
const where = inv.source_file || inv.invoice_no;
|
|
// Scoped per invoice: a warning on one invoice must never block the next one.
|
|
// (Using the cumulative array here silently skipped every invoice after the first
|
|
// one that had an unbooked line.)
|
|
const errs = [];
|
|
|
|
// Header arithmetic. `subtotal` is the ex-VAT hire, `vat` is the "PLUS VAT" column,
|
|
// and `total_incl` is the VAT-inclusive figure the debtor actually pays.
|
|
const sumHire = round2(inv.lines.reduce((s, l) => s + l.amount_excl, 0));
|
|
const sumVat = round2(inv.lines.reduce((s, l) => s + l.vat, 0));
|
|
if (sumHire !== round2(inv.subtotal)) {
|
|
errs.push(`${where}: hire lines total ${sumHire} but invoice subtotal is ${inv.subtotal}`);
|
|
}
|
|
if (sumVat !== round2(inv.vat)) {
|
|
errs.push(`${where}: VAT lines total ${sumVat} but invoice PLUS VAT is ${inv.vat}`);
|
|
}
|
|
if (round2(inv.subtotal + inv.escalation) !== round2(inv.total_excl)) {
|
|
errs.push(`${where}: subtotal ${inv.subtotal} + escalation ${inv.escalation} != total_excl ${inv.total_excl}`);
|
|
}
|
|
if (round2(inv.total_excl + inv.vat) !== round2(inv.total_incl)) {
|
|
errs.push(`${where}: total_excl ${inv.total_excl} + vat ${inv.vat} != total_incl ${inv.total_incl}`);
|
|
}
|
|
if (inv.vat > 0 && inv.vat_pct && inv.vat / inv.total_excl > inv.vat_pct / 100 + 0.001) {
|
|
errs.push(`${where}: VAT ${inv.vat} on excl ${inv.total_excl} exceeds the stated ${inv.vat_pct}% rate`);
|
|
}
|
|
|
|
// Per-line arithmetic
|
|
for (const l of inv.lines) {
|
|
if (round2(l.amount_excl + l.vat) !== round2(l.total_incl)) {
|
|
errs.push(`${where} line ${l.line_no} (${l.machine_code}) excl ${l.amount_excl} + vat ${l.vat} != incl ${l.total_incl}`);
|
|
}
|
|
if (inv.vat_pct) {
|
|
const expected = round2(l.amount_excl * inv.vat_pct / 100);
|
|
if (Math.abs(expected - l.vat) > 0.02) {
|
|
errs.push(`${where} line ${l.line_no} (${l.machine_code}) VAT is ${l.vat}, but ${inv.vat_pct}% of ${l.amount_excl} is ${expected}`);
|
|
}
|
|
}
|
|
if (l.qty && l.rate && round2(l.qty * l.rate) !== round2(l.amount_excl)) {
|
|
errs.push(`${where} line ${l.line_no} (${l.machine_code}) qty ${l.qty} x rate ${l.rate} = ${round2(l.qty * l.rate)}, but amount is ${l.amount_excl}`);
|
|
}
|
|
}
|
|
|
|
if (errs.length) {
|
|
// One broken invoice must not block the others — report it and move on.
|
|
invErrors.push(...errs);
|
|
console.error(` ✗ ${where}: NOT seeded —`);
|
|
for (const e of errs) console.error(` - ${e}`);
|
|
continue;
|
|
}
|
|
|
|
const periodEnd = new Date(inv.period_to);
|
|
let invoiceRec = null;
|
|
try {
|
|
invoiceRec = await pb.collection(INVOICES_COLL).create({
|
|
invoice_no: inv.invoice_no,
|
|
invoice_date: inv.invoice_date,
|
|
order_no: inv.order_no,
|
|
debtor: inv.debtor,
|
|
debtor_no: inv.debtor_no,
|
|
period_from: inv.period_from,
|
|
period_to: inv.period_to,
|
|
description: inv.description,
|
|
site: inv.site || "",
|
|
section: inv.section,
|
|
category: inv.category,
|
|
currency: inv.currency,
|
|
vat_reg_no: inv.vat_reg_no,
|
|
source_file: inv.source_file,
|
|
subtotal: inv.subtotal,
|
|
escalation: inv.escalation,
|
|
vat: inv.vat,
|
|
vat_pct: inv.vat_pct,
|
|
total_excl: inv.total_excl,
|
|
total_incl: inv.total_incl,
|
|
booked_month: periodEnd.getUTCMonth() + 1,
|
|
booked_year: periodEnd.getUTCFullYear(),
|
|
is_booked: false,
|
|
});
|
|
} catch (e) {
|
|
invErrors.push(`${where}: header failed — ${e.response?.data?.message || e.message}`);
|
|
continue;
|
|
}
|
|
|
|
invoicesSeeded++;
|
|
let okLines = 0;
|
|
for (const l of inv.lines) {
|
|
// An unmatched line is seeded with booked_asset EMPTY rather than guessed, so it
|
|
// shows on the page as "unbooked" instead of silently charging the wrong machine.
|
|
let assetId = l.asset_code ? codeToId[l.asset_code] : null;
|
|
if (!assetId) {
|
|
const why = l.asset_code
|
|
? `no asset matched code "${l.asset_code}"`
|
|
: "no target asset assigned";
|
|
invErrors.push(`UNBOOKED: ${where} line ${l.line_no} "${l.machine_code}" (${l.description}) — ${why}. ${l._unmatched || ""}`);
|
|
}
|
|
try {
|
|
await pb.collection(INVOICE_LINES_COLL).create({
|
|
invoice: invoiceRec.id,
|
|
line_no: l.line_no,
|
|
machine_code: l.machine_code,
|
|
asset_code: l.asset_code || "",
|
|
description: l.description,
|
|
qty: l.qty,
|
|
unit: l.unit,
|
|
rate: l.rate,
|
|
rate_type: l.rate_type,
|
|
amount_excl: l.amount_excl,
|
|
vat: l.vat,
|
|
total_incl: l.total_incl,
|
|
booked_asset: assetId || "",
|
|
booked_month: invoiceRec.booked_month,
|
|
booked_year: invoiceRec.booked_year,
|
|
});
|
|
okLines++;
|
|
lineRows++;
|
|
} catch (e) {
|
|
invErrors.push(`${where} line ${l.line_no} ${l.machine_code}: ${e.response?.data?.message || e.message}`);
|
|
}
|
|
}
|
|
console.log(` ✓ ${inv.invoice_no}${inv.site ? ` (${inv.site})` : ""} — ${okLines}/${inv.lines.length} lines, total R${inv.total_incl.toFixed(2)} incl VAT`);
|
|
}
|
|
|
|
console.log("\n=== Summary ===");
|
|
console.log(`Assets: ${ok}/${assets.length}`);
|
|
console.log(`Cost lines: ${clRows}`);
|
|
console.log(`Income: ${incomeRows}`);
|
|
console.log(`Running: ${runRows}`);
|
|
console.log(`Overheads: ${ohRows}`);
|
|
console.log(`Invoices: ${invoicesSeeded}/${invoices.length} (+ ${lineRows} lines)`);
|
|
|
|
const totalErrors = seedErrors.length + clErrors.length + incomeErrors.length + runErrors.length + ohErrors.length + invErrors.length;
|
|
if (totalErrors > 0) {
|
|
console.error(`\n✗ ${totalErrors} errors — seed FAILED loudly:`);
|
|
for (const e of [...seedErrors, ...clErrors, ...incomeErrors, ...runErrors, ...ohErrors, ...invErrors]) console.error(` - ${e}`);
|
|
if (invErrors.length) {
|
|
console.error("\n Fix the target asset in the matching data/source/invoice-*.json, or assign it");
|
|
console.error(" in /income/invoices (Booked to), then re-run: node scripts/book-invoice.mjs");
|
|
}
|
|
console.error("\n ⚠ monthly_income was wiped and re-imported from the workbook only.");
|
|
console.error(" Invoice-derived income is NOT in it — re-run `node scripts/book-invoice.mjs` after fixing the above.");
|
|
process.exit(2);
|
|
}
|
|
console.log("\n✅ Seed complete");
|
|
console.log(" Invoice lines are seeded but NOT yet in monthly_income.");
|
|
console.log(" Run `node scripts/book-invoice.mjs` to post them.");
|