Merge branch 'dev' into update-freight-migrations

This commit is contained in:
Yonas Tewabe
2026-07-24 14:04:11 +03:00
committed by GitHub
1114 changed files with 89749 additions and 15412 deletions

View File

@@ -0,0 +1,39 @@
import { AppDataSource } from '../data-source';
import { EdrTruckFleetSeeder } from '../seed/edr-truck-fleet.seeder';
/**
* Seeds the 62-truck EDR fleet used by first-mile / last-mile.
*
* The seeder is idempotent (`ON CONFLICT (plate_number) DO NOTHING`), so a
* re-run will NOT overwrite a truck whose rate was tuned by hand.
*/
async function seedEdrTrucks() {
await AppDataSource.initialize();
try {
await new EdrTruckFleetSeeder(AppDataSource).run();
const summary = await AppDataSource.query(`
SELECT
COUNT(*)::int AS trucks,
COUNT(*) FILTER (WHERE status = 'ACTIVE')::int AS active,
COUNT(*) FILTER (WHERE availability = 'FREE')::int AS free,
COUNT(*) FILTER (WHERE price_per_km > 0)::int AS priced,
COUNT(*) FILTER (WHERE price_per_km IS NULL OR price_per_km <= 0)::int AS unpriced,
MIN(price_per_km)::text AS min_rate,
MAX(price_per_km)::text AS max_rate
FROM freight.vehicles
WHERE vehicle_type = 'TRUCK';
`);
console.table(summary);
console.log('Seeded EDR truck fleet.');
} finally {
await AppDataSource.destroy();
}
}
seedEdrTrucks().catch((error) => {
console.error('Failed to seed EDR truck fleet:', error);
process.exit(1);
});

View File

@@ -1,5 +1,9 @@
import { AppDataSource } from '../data-source';
import { SeedEdRWagonFleet1750400000000 } from '../migrations/1750400000000-SeedEdRWagonFleet';
import { SeedEdrWagonFleetErNumbering2260000000000 } from '../migrations/2260000000000-SeedEdrWagonFleetErNumbering';
import { AddWagonTrainNumbers2270000000000 } from '../migrations/2270000000000-AddWagonTrainNumbers';
import { SeedWagonRunNumbers2280000000000 } from '../migrations/2280000000000-SeedWagonRunNumbers';
import { WagonNumberPartialUnique2280000000000 } from '../migrations/2280000000000-WagonNumberPartialUnique';
import { SeedWagonYardDoraleh2290000000000 } from '../migrations/2290000000000-SeedWagonYardDoraleh';
async function seedEdRWagons() {
await AppDataSource.initialize();
@@ -10,28 +14,76 @@ async function seedEdRWagons() {
await queryRunner.connect();
await queryRunner.startTransaction();
await new SeedEdRWagonFleet1750400000000().up(queryRunner);
// Fleet first (recreates every wagon with NULL yard + NULL runs), then the
// columns are ensured to exist, then the run roster and the yard are applied
// on top. Same order the migrations run in, so the script and a fresh
// migrate agree.
await new SeedEdrWagonFleetErNumbering2260000000000().up(queryRunner);
await new AddWagonTrainNumbers2270000000000().up(queryRunner);
// Not a wagon seed, but it owns wagon_number uniqueness — included so this
// script leaves the same schema a real `migration:run` would, rather than a
// database missing the partial unique index.
await new WagonNumberPartialUnique2280000000000().up(queryRunner);
await new SeedWagonRunNumbers2280000000000().up(queryRunner);
await new SeedWagonYardDoraleh2290000000000().up(queryRunner);
const [summary] = await queryRunner.query(`
const summary = await queryRunner.query(`
SELECT
COUNT(*)::int AS total,
COUNT(*) FILTER (WHERE wt.code = 'PW2')::int AS pw2,
COUNT(*) FILTER (WHERE wt.code = 'CW4')::int AS cw4,
COUNT(*) FILTER (WHERE wt.code = 'CW3')::int AS cw3,
COUNT(*) FILTER (WHERE wt.code = 'KW2')::int AS kw2,
COUNT(*) FILTER (WHERE wt.code = 'KW3')::int AS kw3,
COUNT(*) FILTER (WHERE wt.code = 'NW5')::int AS nw5,
COUNT(*) FILTER (WHERE wt.supported_load_types @> ARRAY['CONTAINER'])::int AS container_ready,
COUNT(*) FILTER (WHERE wt.supported_load_types @> ARRAY['BULK'])::int AS bulk_ready,
COUNT(*) FILTER (WHERE w.status = 'IMPORT_READY')::int AS import_ready
wt.code,
wt.name,
COUNT(*)::int AS wagons,
MIN(w.wagon_number) AS first_wagon,
MAX(w.wagon_number) AS last_wagon,
COUNT(*) FILTER (WHERE w.status = 'AVAILABLE')::int AS available,
COUNT(*) FILTER (WHERE w.current_yard_id IS NULL)::int AS no_yard,
COUNT(*) FILTER (WHERE w.export_train_number IS NOT NULL)::int AS on_a_run
FROM freight.wagons w
JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id
WHERE w.wagon_number BETWEEN 'ER0001' AND 'ER0940';
WHERE w.wagon_number BETWEEN 'ER0001' AND 'ER1100'
GROUP BY wt.code, wt.name
ORDER BY MIN(w.wagon_number);
`);
const [totals] = await queryRunner.query(`
SELECT
COUNT(*)::int AS total,
COUNT(*) FILTER (WHERE export_train_number IS NOT NULL)::int AS on_a_run
FROM freight.wagons;
`);
const yards = await queryRunner.query(`
SELECT
COALESCE(y.label, '(no yard)') AS yard,
COUNT(*)::int AS wagons
FROM freight.wagons w
LEFT JOIN freight.yards y ON y.id = w.current_yard_id
GROUP BY y.label
ORDER BY 2 DESC;
`);
const runs = await queryRunner.query(`
SELECT
export_train_number AS export_run,
import_train_number AS import_run,
COUNT(*)::int AS wagons
FROM freight.wagons
WHERE export_train_number IS NOT NULL
GROUP BY export_train_number, import_train_number
ORDER BY export_train_number;
`);
await queryRunner.commitTransaction();
console.log('Seeded EDR wagon fleet:', summary);
console.log('\nFleet by wagon type:');
console.table(summary);
console.log('Run roster (export/import pairs):');
console.table(runs);
console.log('Fleet by yard:');
console.table(yards);
console.log(
`Seeded EDR wagon fleet — ${totals.total} wagons total (expected 1100), ` +
`${totals.on_a_run} on a run (expected 533).`,
);
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;

View File

@@ -209,7 +209,6 @@ async function ensureReferences(manager: any) {
code: '40FT',
label: '40FT',
sizeFt: 40,
wagonsPerUnit: 1,
isReefer: false,
isOpenTop: false,
isActive: true,

View File

@@ -118,7 +118,6 @@ async function main() {
code: '40FT',
label: '40FT',
sizeFt: 40,
wagonsPerUnit: 1,
isReefer: false,
isOpenTop: false,
isActive: true,

View File

@@ -12,6 +12,7 @@ import { Booking } from '../modules/bookings/entities/booking.entity';
import { BookingContainer } from '../modules/bookings/entities/booking-container.entity';
import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity';
import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity';
import { wagonsPerUnitForSize } from '../modules/rule-engine/container-type.util';
import { ContainerType } from '../modules/rule-engine/entities/container-type.entity';
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
import { Yard } from '../modules/rule-engine/entities/yard.entity';
@@ -115,7 +116,7 @@ async function main() {
reeferQuantity: 0,
vgmPerUnitTons: Number((weightKg / containerQuantity / 1000).toFixed(3)),
totalVgmTons: Number((weightKg / 1000).toFixed(3)),
wagonsRequired: Math.max(1, containerQuantity * Number(containerType!.wagonsPerUnit ?? 1)),
wagonsRequired: Math.max(1, containerQuantity * wagonsPerUnitForSize(containerType!.sizeFt)),
isOverweight: false,
}),
);

View 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);
});