import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { AppDataSource } from '../data-source'; import { TRAIN_RUN_PAIRS, toExportRun } from '../modules/wagons/train-runs.const'; /** * Update wagon run numbers from a roster file — the tool for making the DB match * the operator's sheet. * * pnpm seed:wagon-runs [--apply] * * CSV: two columns, header optional. Either half of a run pair is accepted, so * "8001" and "8002" both mean the same train. * * wagon_number,run * ER0744,8001 * ER0458,8102 * * FULL REPLACEMENT: wagons absent from the file have their runs cleared, so the * DB ends up matching the file exactly rather than accumulating stale rows. * * Dry run by default — it validates and prints what would change. Nothing is * written without `--apply`. Validation is fatal on: an unknown run, a wagon not * in the database, or the same wagon claimed by two runs (a wagon holds one run, * so a double-booking has no correct answer and must be fixed in the sheet). */ interface Row { line: number; wagonNumber: string; exportRun: string; } function parseCsv(path: string) { const text = readFileSync(path, 'utf8'); const rows: Row[] = []; const unknownRuns: string[] = []; text.split(/\r?\n/).forEach((raw, i) => { const line = i + 1; const trimmed = raw.trim(); if (!trimmed || trimmed.startsWith('#')) return; const [rawWagon = '', rawRun = ''] = trimmed.split(',').map((c) => c.trim()); // Skip a header row without needing it to be declared. if (/wagon/i.test(rawWagon) && /run|train/i.test(rawRun)) return; if (!rawWagon || !rawRun) { throw new Error(`line ${line}: expected "wagon_number,run", got "${trimmed}"`); } const exportRun = toExportRun(rawRun); if (!exportRun) { unknownRuns.push(`line ${line}: "${rawRun}" (wagon ${rawWagon})`); return; } rows.push({ line, wagonNumber: rawWagon.toUpperCase(), exportRun }); }); return { rows, unknownRuns }; } async function updateWagonRuns() { const [fileArg, ...flags] = process.argv.slice(2); const apply = flags.includes('--apply'); if (!fileArg) { console.error('usage: pnpm seed:wagon-runs [--apply]'); process.exit(2); } const path = resolve(process.cwd(), fileArg); const { rows, unknownRuns } = parseCsv(path); // A wagon in two runs cannot be represented — surface every instance rather // than silently keeping whichever line happened to come first. const seen = new Map(); const doubleBooked: string[] = []; for (const row of rows) { const prior = seen.get(row.wagonNumber); if (prior && prior.exportRun !== row.exportRun) { doubleBooked.push( `${row.wagonNumber}: run ${prior.exportRun} (line ${prior.line}) vs ${row.exportRun} (line ${row.line})`, ); continue; } if (!prior) seen.set(row.wagonNumber, row); } await AppDataSource.initialize(); try { const wagonNumbers = [...seen.keys()]; const existing: Array<{ wagon_number: string }> = wagonNumbers.length ? await AppDataSource.query( `SELECT wagon_number FROM freight.wagons WHERE deleted_at IS NULL AND wagon_number = ANY($1::text[]);`, [wagonNumbers], ) : []; const known = new Set(existing.map((r) => r.wagon_number)); const missing = wagonNumbers.filter((w) => !known.has(w)); const problems = [ ...unknownRuns.map((u) => `unknown run ${u}`), ...doubleBooked.map((d) => `double-booked ${d}`), ...missing.map((m) => `not in database ${m}`), ]; const perRun = new Map(); for (const row of seen.values()) { if (known.has(row.wagonNumber)) { perRun.set(row.exportRun, (perRun.get(row.exportRun) ?? 0) + 1); } } console.log(`\nFile: ${path}`); console.log(`Rows read: ${rows.length + unknownRuns.length} | assignable: ${known.size}`); console.table( Object.keys(TRAIN_RUN_PAIRS).map((exportRun) => ({ export_run: exportRun, import_run: TRAIN_RUN_PAIRS[exportRun], wagons: perRun.get(exportRun) ?? 0, })), ); if (problems.length) { console.error(`\n${problems.length} problem(s) — nothing was written:`); problems.forEach((p) => console.error(` ${p}`)); console.error('\nFix these in the source sheet, then re-run.'); process.exit(1); } if (!apply) { console.log('\nDry run — no changes written. Re-run with --apply to write.'); return; } await AppDataSource.transaction(async (manager) => { // Full replacement: clear first so a wagon dropped from the sheet does not // keep a run it no longer has. await manager.query(` UPDATE freight.wagons SET export_train_number = NULL, import_train_number = NULL WHERE export_train_number IS NOT NULL; `); for (const exportRun of new Set([...seen.values()].map((r) => r.exportRun))) { const wagons = [...seen.values()] .filter((r) => r.exportRun === exportRun) .map((r) => r.wagonNumber); await manager.query( `UPDATE freight.wagons SET export_train_number = $1, import_train_number = $2, updated_at = now() WHERE wagon_number = ANY($3::text[]);`, [exportRun, TRAIN_RUN_PAIRS[exportRun], wagons], ); } }); const [totals] = await AppDataSource.query(` SELECT COUNT(*) FILTER (WHERE export_train_number IS NOT NULL)::int AS on_a_run FROM freight.wagons WHERE deleted_at IS NULL; `); console.log(`\nApplied. ${totals.on_a_run} wagons now on a run.`); } finally { await AppDataSource.destroy(); } } updateWagonRuns().catch((error) => { console.error('Failed to update wagon runs:', error instanceof Error ? error.message : error); process.exit(1); });