From 20eb54521b6bf1b861f06b1137035c943e184ff6 Mon Sep 17 00:00:00 2001 From: natib21 Date: Sat, 18 Jul 2026 08:34:43 +0000 Subject: [PATCH] fix --- .../src/modules/wagons/train-runs.const.ts | 41 +++++ .../src/scripts/update-wagon-runs.ts | 174 ++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 apps/edr-freight-api/src/modules/wagons/train-runs.const.ts create mode 100644 apps/edr-freight-api/src/scripts/update-wagon-runs.ts diff --git a/apps/edr-freight-api/src/modules/wagons/train-runs.const.ts b/apps/edr-freight-api/src/modules/wagons/train-runs.const.ts new file mode 100644 index 000000000..85bb5e88a --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/train-runs.const.ts @@ -0,0 +1,41 @@ +/** + * EDR run-number pairs, keyed by the odd EXPORT run (Ethiopia → Djibouti). The + * even IMPORT run (Djibouti → Ethiopia) is fixed by the export run. + * + * Run numbers are always 4 digits (8401, never 84001). Pairs are listed out + * rather than computed from the 8001/+100/+1 pattern, so a run that ever breaks + * the convention stays correct here. + * + * SeedWagonRunNumbers2280000000000 carries its own frozen copy on purpose: a + * migration must keep doing what it did when it was applied, whereas this list + * is live config for the update script. Add or retire runs HERE. + */ +export const TRAIN_RUN_PAIRS: Record = { + '8001': '8002', + '8101': '8102', + '8201': '8202', + '8301': '8302', + '8401': '8402', + '8501': '8502', + '8601': '8602', + '8701': '8702', + '8801': '8802', + '8901': '8902', + '9001': '9002', +}; + +/** Even IMPORT run -> its odd EXPORT run. Derived so the two cannot drift. */ +export const EXPORT_BY_IMPORT: Record = Object.fromEntries( + Object.entries(TRAIN_RUN_PAIRS).map(([exportRun, importRun]) => [importRun, exportRun]), +); + +/** + * Normalise any run number to its EXPORT run. Accepts either half of a pair, so + * a sheet listing "8002" and one listing "8001" both resolve to the same train. + * Returns null when the number belongs to no known run. + */ +export const toExportRun = (run: string): string | null => { + const value = run.trim(); + if (TRAIN_RUN_PAIRS[value]) return value; + return EXPORT_BY_IMPORT[value] ?? null; +}; diff --git a/apps/edr-freight-api/src/scripts/update-wagon-runs.ts b/apps/edr-freight-api/src/scripts/update-wagon-runs.ts new file mode 100644 index 000000000..d24f6245e --- /dev/null +++ b/apps/edr-freight-api/src/scripts/update-wagon-runs.ts @@ -0,0 +1,174 @@ +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); +});