`booked_asset` stores an asset record ID, and `npm run seed` deletes and re-creates every asset — so the id a line was booked against stops existing after a seed. The line then points at nothing and silently drops out of monthly_income, which is exactly the reported symptom: booked yesterday, unbooked today, with the line still carrying the correct code. The booking step now re-derives the pointer from the register code the line already carries (`asset_code`, falling back to the machine code) whenever the stored target is empty or no longer exists. It only ever points a line at the asset its own code names, so it never invents a match and never clears a site booking. A line with no matching code stays unbooked exactly as before. This makes the documented run order self-healing: `npm run seed && node scripts/book-invoice.mjs` now restores the links, so a reset cannot silently unbook the register. Verified by running a full seed and re-booking: 29 pointers were invalidated by the seed and all 29 were re-linked, giving 45 posted rows and 3 unbooked lines — the two MPLANT05 containers and one WT02 trailer, which have no matching plant item.
200 lines
No EOL
8.6 KiB
JavaScript
200 lines
No EOL
8.6 KiB
JavaScript
// Post invoice lines into `monthly_income` as ADDITIONAL rows.
|
|
//
|
|
// Run order matters: `npm run seed` wipes monthly_income and re-imports the workbook,
|
|
// so invoice-derived income must be (re)posted afterwards.
|
|
//
|
|
// Idempotent: every row it creates is stamped with `invoice_line`, so a re-run removes
|
|
// the previous invoice-derived rows first instead of adding duplicates. Workbook rows
|
|
// (empty invoice_line) are never touched.
|
|
//
|
|
// Usage: node scripts/book-invoice.mjs [--invoice "PHI 166"] [--dry-run]
|
|
import PocketBase from "pocketbase";
|
|
import { readFileSync, existsSync } from "node:fs";
|
|
import { fileURLToPath } from "node:url";
|
|
import { dirname, resolve } from "node:path";
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const ROOT = resolve(__dirname, "..");
|
|
|
|
const envPath = resolve(ROOT, ".env.local");
|
|
if (!existsSync(envPath)) {
|
|
console.error("✗ .env.local not found. Copy .env.example to .env.local and fill it in.");
|
|
process.exit(1);
|
|
}
|
|
for (const line of readFileSync(envPath, "utf8").split("\n")) {
|
|
const t = line.trim();
|
|
if (!t || t.startsWith("#")) continue;
|
|
const i = t.indexOf("=");
|
|
if (i > 0) process.env[t.slice(0, i).trim()] = t.slice(i + 1).trim();
|
|
}
|
|
|
|
const PB_URL = process.env.NEXT_PUBLIC_PB_URL;
|
|
const EMAIL = process.env.PB_ADMIN;
|
|
const PASS = process.env.PB_PASS;
|
|
const missing = [];
|
|
if (!PB_URL) missing.push("NEXT_PUBLIC_PB_URL");
|
|
if (!EMAIL) missing.push("PB_ADMIN");
|
|
if (!PASS) missing.push("PB_PASS");
|
|
if (missing.length) {
|
|
console.error(`✗ Missing required env: ${missing.join(", ")}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const args = process.argv.slice(2);
|
|
const dryRun = args.includes("--dry-run");
|
|
const onlyIdx = args.indexOf("--invoice");
|
|
const onlyInvoice = onlyIdx >= 0 ? args[onlyIdx + 1] : null;
|
|
|
|
// Shared policy — the app reads the same file, so the booked amount can never differ
|
|
// between this script and the /income/invoices UI. See lib/invoice-booking-policy.json.
|
|
const POLICY = JSON.parse(readFileSync(resolve(ROOT, "lib", "invoice-booking-policy.json"), "utf8"));
|
|
const round2 = (n) => Math.round((n || 0) * 100) / 100;
|
|
const bookedAmount = (excl, vat) => (POLICY.bookInclusiveVat ? round2(excl) + round2(vat) : round2(excl));
|
|
const BOOKED_LABEL = POLICY.bookInclusiveVat ? "incl VAT" : "ex VAT";
|
|
|
|
const pb = new PocketBase(PB_URL);
|
|
pb.autoCancellation(false);
|
|
|
|
console.log(`→ Connecting to ${PB_URL} as ${EMAIL}`);
|
|
await pb.collection("_superusers").authWithPassword(EMAIL, PASS);
|
|
console.log(" ✓ authenticated");
|
|
|
|
const invoices = await pb.collection("invoices").getFullList({ sort: "invoice_date" });
|
|
if (!invoices.length) {
|
|
console.error("✗ No invoices found. Run `npm run seed` first.");
|
|
process.exit(1);
|
|
}
|
|
|
|
const targets = onlyInvoice ? invoices.filter((i) => i.invoice_no === onlyInvoice) : invoices;
|
|
if (!targets.length) {
|
|
console.error(`✗ No invoice matched "${onlyInvoice}". Available: ${invoices.map((i) => i.invoice_no).join(", ")}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const allLines = await pb.collection("invoice_lines").getFullList({ sort: "line_no" });
|
|
const existingIncome = await pb.collection("monthly_income").getFullList();
|
|
|
|
// ---- Repair stale booking pointers ------------------------------------------------
|
|
// `booked_asset` stores an asset RECORD ID, and `npm run seed` deletes and re-creates
|
|
// every asset — so the id a line was booked against stops existing after a seed. The line
|
|
// then points at nothing and silently drops out of monthly_income, which is exactly the
|
|
// "it booked yesterday and is unbooked today" symptom.
|
|
//
|
|
// The invoice line already carries the register code it was matched on (`asset_code`),
|
|
// so the pointer can be re-derived rather than retyped. This ONLY ever points a line at
|
|
// the asset its own code names; it never invents a match and never clears one, so a line
|
|
// with no code stays unbooked exactly as before.
|
|
const allAssets = await pb.collection("assets").getFullList();
|
|
const assetIds = new Set(allAssets.map((a) => a.id));
|
|
const idByCode = new Map(allAssets.filter((a) => a.code).map((a) => [String(a.code).trim().toUpperCase(), a.id]));
|
|
const codeFromMachine = (code) => {
|
|
const m = /^([A-Za-z]+)[- ]?([0-9]{1,3})/.exec(String(code || "").trim());
|
|
return m ? `${m[1].toUpperCase()}${m[2]}` : "";
|
|
};
|
|
|
|
let repaired = 0;
|
|
if (!dryRun) {
|
|
for (const l of allLines) {
|
|
if (l.booked_site) continue; // booked to a site — a different, deliberate choice
|
|
const fresh = (String(l.asset_code || "").trim().toUpperCase() || codeFromMachine(l.machine_code))
|
|
&& idByCode.get(String(l.asset_code || "").trim().toUpperCase() || codeFromMachine(l.machine_code));
|
|
// Only act when the pointer is genuinely gone: empty, or pointing at a deleted asset.
|
|
const needsRepair = !l.booked_asset || !assetIds.has(l.booked_asset);
|
|
if (!needsRepair || !fresh || fresh === l.booked_asset) continue;
|
|
await pb.collection("invoice_lines").update(l.id, { booked_asset: fresh });
|
|
l.booked_asset = fresh;
|
|
repaired++;
|
|
}
|
|
}
|
|
if (repaired) console.log(` · re-linked ${repaired} line(s) whose target asset had been replaced by a seed`);
|
|
|
|
// Remove previously invoice-derived rows for the target invoices only.
|
|
const targetIds = new Set(targets.map((i) => i.id));
|
|
const linesOfTargets = allLines.filter((l) => targetIds.has(l.invoice));
|
|
const lineIds = new Set(linesOfTargets.map((l) => l.id));
|
|
const staleRows = existingIncome.filter((r) => r.invoice_line && lineIds.has(r.invoice_line));
|
|
|
|
console.log(`\n→ Booking ${targets.length} invoice(s), ${linesOfTargets.length} line(s)`);
|
|
if (dryRun) console.log(" (dry run — nothing will be written)");
|
|
|
|
if (staleRows.length) {
|
|
console.log(` · removing ${staleRows.length} previously posted invoice row(s)`);
|
|
if (!dryRun) for (const r of staleRows) await pb.collection("monthly_income").delete(r.id);
|
|
}
|
|
|
|
const errors = [];
|
|
const unbooked = [];
|
|
let posted = 0;
|
|
|
|
for (const inv of targets) {
|
|
console.log(`\n ${inv.invoice_no} — ${inv.debtor} — ${inv.description}`);
|
|
console.log(` period ${inv.period_from} → ${inv.period_to} · booking to ${inv.booked_year}-${String(inv.booked_month).padStart(2, "0")}`);
|
|
|
|
const lines = allLines.filter((l) => l.invoice === inv.id);
|
|
let invoiceComplete = true;
|
|
|
|
for (const l of lines) {
|
|
if (!l.booked_asset) {
|
|
unbooked.push(` ${inv.invoice_no} line ${l.line_no} (${l.machine_code} ${l.description}) — no target asset`);
|
|
invoiceComplete = false;
|
|
console.log(` line ${l.line_no} ${l.machine_code.padEnd(14)} → UNBOOKED (no target asset)`);
|
|
continue;
|
|
}
|
|
const asset = await pb.collection("assets").getOne(l.booked_asset).catch(() => null);
|
|
if (!asset) {
|
|
errors.push(` ${inv.invoice_no} line ${l.line_no}: booked_asset ${l.booked_asset} no longer exists in assets`);
|
|
invoiceComplete = false;
|
|
continue;
|
|
}
|
|
|
|
// Amount posted follows the shared policy (VAT-inclusive per the client's 2026-10-01
|
|
// ruling). Flip lib/invoice-booking-policy.json and re-run to change it.
|
|
const amount = bookedAmount(l.amount_excl, l.vat);
|
|
const arrow = asset.code && l.asset_code && asset.code !== l.asset_code
|
|
? ` (invoice code ${l.asset_code} → booked to ${asset.code})`
|
|
: "";
|
|
console.log(` line ${l.line_no} ${String(l.machine_code).padEnd(14)} → ${String(asset.code || asset.name).padEnd(8)} R${amount.toFixed(2)} ${BOOKED_LABEL}${arrow}`);
|
|
|
|
if (!dryRun) {
|
|
try {
|
|
await pb.collection("monthly_income").create({
|
|
asset: l.booked_asset,
|
|
year: l.booked_year || inv.booked_year,
|
|
month: l.booked_month || inv.booked_month,
|
|
income: amount,
|
|
fixed_expenses: 0,
|
|
is_forecast: false,
|
|
invoice_line: l.id,
|
|
});
|
|
posted++;
|
|
} catch (e) {
|
|
errors.push(` ${inv.invoice_no} line ${l.line_no}: ${e.response?.data?.message || e.message}`);
|
|
}
|
|
} else {
|
|
posted++;
|
|
}
|
|
}
|
|
|
|
if (!dryRun) {
|
|
await pb.collection("invoices").update(inv.id, { is_booked: invoiceComplete });
|
|
}
|
|
}
|
|
|
|
console.log(`\n=== Summary ===`);
|
|
console.log(`Invoices processed: ${targets.length}`);
|
|
console.log(`Rows posted: ${posted}`);
|
|
console.log(`Unbooked lines: ${unbooked.length}`);
|
|
|
|
if (unbooked.length) {
|
|
console.log("\n ⚠ These lines are NOT in monthly_income:");
|
|
for (const u of unbooked) console.log(` -${u}`);
|
|
console.log("\n Assign the target asset in data/source/invoice-phi166.json (asset_code) and re-run");
|
|
console.log(" `npm run seed`, then `node scripts/book-invoice.mjs`.");
|
|
}
|
|
|
|
if (errors.length) {
|
|
console.error(`\n✗ ${errors.length} error(s):`);
|
|
for (const e of errors) console.error(e);
|
|
process.exit(2);
|
|
}
|
|
console.log(dryRun ? "\n✅ Dry run complete — no changes written" : "\n✅ Booking complete"); |