mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix
This commit is contained in:
41
apps/edr-freight-api/src/modules/wagons/train-runs.const.ts
Normal file
41
apps/edr-freight-api/src/modules/wagons/train-runs.const.ts
Normal file
@@ -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<string, string> = {
|
||||
'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<string, string> = 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;
|
||||
};
|
||||
174
apps/edr-freight-api/src/scripts/update-wagon-runs.ts
Normal file
174
apps/edr-freight-api/src/scripts/update-wagon-runs.ts
Normal file
@@ -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 <file.csv> [--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 <file.csv> [--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<string, Row>();
|
||||
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<string, number>();
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user