mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
chore(scripts): add the OCC July 2026 seed, drop the throwaway ops scripts
seed-occ-july-2026 loads the OCC plan and operated figures the operations reports were built against, so the plan-versus-actual tables have real numbers to check. tmp-ops-plan, tmp-ops-verify, tmp-mkdb and tmp-ops-reconcile were scratch: written to reconcile those figures while the reports were being built, and superseded by the seed above.
This commit is contained in:
643
apps/edr-freight-api/src/scripts/seed-occ-july-2026.ts
Normal file
643
apps/edr-freight-api/src/scripts/seed-occ-july-2026.ts
Normal file
@@ -0,0 +1,643 @@
|
||||
/**
|
||||
* Loads the operations reporting reference data published by EDR for July 2026.
|
||||
*
|
||||
* Sources, both under the workspace root:
|
||||
* - `EDR - Train Turn-Around Standard Time 2.docx` — the standard cycle
|
||||
* times, broken down activity by activity.
|
||||
* - `OCC_HQ_July_2026_Updated_Operation_Control_Center_OCC_Monthly_Report.pdf`
|
||||
* — the July 2026 plan and actuals.
|
||||
*
|
||||
* What it writes:
|
||||
* 1. `operations_standards` — the one settings row, from the docx.
|
||||
* 2. `yard_distances.standard_hours` — the corridor leg standards.
|
||||
* 3. `cargo_types` — a FERTILIZER type (the report's largest bulk category,
|
||||
* absent from this database) and the full-trainset wagon counts.
|
||||
* 4. `operations_targets` — the July 2026 plan: trainsets and tonnage per
|
||||
* cargo category, TEU per container class, and tonnage per station.
|
||||
* 5. The per-train records the report actually names: eleven container trains
|
||||
* with their measured cycle durations, and the five DMP trains with their
|
||||
* station staying times, as schedules plus checkpoints.
|
||||
*
|
||||
* Every figure is copied from the documents. Where something is derived rather
|
||||
* than measured, the comment says so — see `buildCycleLegs`.
|
||||
*
|
||||
* Idempotent: re-running updates in place and never duplicates. Run with
|
||||
* pnpm --filter @edr/freight-api run seed:occ-july-2026
|
||||
*/
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { AppDataSource } from '../data-source';
|
||||
|
||||
/** July 2026, the month every target below belongs to. */
|
||||
const PERIOD_TYPE = 'month';
|
||||
const PERIOD_START = '2026-07-01';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Operating standards — "EDR - Train Turn-Around Standard Time 2.docx"
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The docx totals each cycle activity by activity, and the station standards
|
||||
* fall straight out of it:
|
||||
* Djibouti side 1 + 0 + 2 + 6 + 2 + 2 = 13 hrs (container)
|
||||
* Ethiopian side 1 + 1 + 6 + 1 + 1 = 10 hrs (container)
|
||||
* container cycle 21 + 13 + 21 + 10 = 65
|
||||
* bulk via DMP 21 + 33 + 21 + 13 = 88
|
||||
* bulk via SDTV/Old-port/Nagad 21 + 41 + 21 + 13 = 96
|
||||
*/
|
||||
const STANDARDS = {
|
||||
station_standard_hours_ethiopia: 10,
|
||||
station_standard_hours_djibouti: 13,
|
||||
cycle_standard_hours_container: 65,
|
||||
cycle_standard_hours_bulk_dmp: 88,
|
||||
cycle_standard_hours_bulk_nagad: 96,
|
||||
cycle_standard_hours_bulk_bcc: 96,
|
||||
default_leg_standard_hours: 21,
|
||||
delay_tolerance_minutes: 30,
|
||||
charged_tons_full_20ft: 20,
|
||||
charged_tons_full_40ft: 40,
|
||||
charged_tons_empty_20ft: 2.24,
|
||||
charged_tons_empty_40ft: 3.88,
|
||||
charged_tons_per_wagon_general: 70,
|
||||
charged_tons_per_wagon_perishable: 38,
|
||||
default_full_trainset_wagons: 50,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Corridor leg standards
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Only the four legs the business gave figures for. Everything else is left
|
||||
* null and falls back to `default_leg_standard_hours`, rather than being
|
||||
* guessed at — the delay report judges trains against these.
|
||||
*
|
||||
* The docx's 21 hours covers 15:00 running at 50 km/h average, 40 min Dire Dawa
|
||||
* inspection, 3:20 station dwell (10 min a station), 40 min Dewanle
|
||||
* inspection and documentation, and 2:20 of maintenance-window allowance.
|
||||
*/
|
||||
const LEG_STANDARD_HOURS: Array<[string, string, number]> = [
|
||||
['NAGAD', 'KALITY', 21],
|
||||
['NAGAD', 'ADAMA', 20],
|
||||
['NAGAD', 'MOJO', 20.5],
|
||||
['NAGAD', 'SEBETA', 22],
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Cargo types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** "Full train set per cargo vehicle 37 wagons, sand 22 wagons." */
|
||||
const FULL_TRAINSET_WAGONS: Array<[string, number]> = [
|
||||
['AUTOMOBILE', 37],
|
||||
['TRUCK', 37],
|
||||
['SAND', 22],
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. July 2026 plan
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Section 1.1, "Train type / Plan" column. */
|
||||
const TRAINSET_PLAN: Array<[string, number]> = [
|
||||
['CONTAINER_IMPORT_MULTIMODAL', 69.3],
|
||||
['CONTAINER_IMPORT_UNIMODAL', 19.9],
|
||||
['CONTAINER_EXPORT', 28.0],
|
||||
['EMPTY_CONTAINER', 25.4],
|
||||
['FERTILIZER', 29.0],
|
||||
['RORO', 2.7],
|
||||
['BREAK_BULK', 2.0],
|
||||
['OTHER_IMPORT', 2.5],
|
||||
['OTHER_EXPORT', 2.3],
|
||||
['SAND', 1.6],
|
||||
// The report's eleventh line, "Empty Train 73.0", has no cargo category to
|
||||
// hang on — it is trains running with no cargo at all. The trainset report
|
||||
// surfaces it as the "Empty wagons" KPI instead.
|
||||
];
|
||||
|
||||
/** Section 2, "Container transport performance (TEU) / Plan". */
|
||||
const TEU_PLAN: Array<[string, number]> = [
|
||||
['CONTAINER_IMPORT_MULTIMODAL', 7350],
|
||||
['CONTAINER_IMPORT_UNIMODAL', 2106],
|
||||
['CONTAINER_EXPORT', 2973],
|
||||
['EMPTY_CONTAINER_RETURN', 2689],
|
||||
];
|
||||
|
||||
/** Section 3's plan bars — 357,551 t in total. */
|
||||
const VOLUME_PLAN: Array<[string, number]> = [
|
||||
['CONTAINER_IMPORT_MULTIMODAL', 147000],
|
||||
['CONTAINER_IMPORT_UNIMODAL', 42126],
|
||||
['CONTAINER_EXPORT', 59452],
|
||||
['EMPTY_CONTAINER', 8068],
|
||||
['FERTILIZER', 75000],
|
||||
['RORO', 4247],
|
||||
['BREAK_BULK', 5096],
|
||||
['OTHER_IMPORT', 6370],
|
||||
['OTHER_EXPORT', 5945],
|
||||
['SAND', 4247],
|
||||
];
|
||||
|
||||
/**
|
||||
* Section 4, "Freight Stations (cargo volume)" — every station-pair line.
|
||||
*
|
||||
* The station is the Ethiopian end of the corridor, which is what the report's
|
||||
* Ethiopian view groups by; Nagad is the other end on all of them. Galaan in
|
||||
* the report is the yard coded KALITY here (GMP / Gelan Multipurpose Port).
|
||||
*
|
||||
* The per-station figures sum to the category totals above within ±1 tonne, the
|
||||
* report's own rounding.
|
||||
*/
|
||||
const STATION_PLAN: Array<[string, string, number]> = [
|
||||
['CONTAINER_IMPORT_MULTIMODAL', 'DIRE_DAWA', 2940],
|
||||
['CONTAINER_IMPORT_MULTIMODAL', 'MOJO', 122010],
|
||||
['CONTAINER_IMPORT_MULTIMODAL', 'KALITY', 22050],
|
||||
|
||||
['CONTAINER_IMPORT_UNIMODAL', 'DIRE_DAWA', 2106],
|
||||
['CONTAINER_IMPORT_UNIMODAL', 'MOJO', 843],
|
||||
['CONTAINER_IMPORT_UNIMODAL', 'KALITY', 37913],
|
||||
['CONTAINER_IMPORT_UNIMODAL', 'SEBETA', 1264],
|
||||
|
||||
['CONTAINER_EXPORT', 'SEBETA', 892],
|
||||
['CONTAINER_EXPORT', 'KALITY', 41914],
|
||||
['CONTAINER_EXPORT', 'MOJO', 16052],
|
||||
['CONTAINER_EXPORT', 'DIRE_DAWA', 595],
|
||||
|
||||
['EMPTY_CONTAINER', 'KALITY', 1614],
|
||||
['EMPTY_CONTAINER', 'MOJO', 6455],
|
||||
|
||||
['FERTILIZER', 'MEISO', 1500],
|
||||
['FERTILIZER', 'ADAMA', 18750],
|
||||
['FERTILIZER', 'MOJO', 18000],
|
||||
['FERTILIZER', 'KALITY', 18000],
|
||||
['FERTILIZER', 'SEBETA', 18750],
|
||||
|
||||
['RORO', 'KALITY', 4247],
|
||||
|
||||
['BREAK_BULK', 'ADAMA', 127],
|
||||
['BREAK_BULK', 'MOJO', 127],
|
||||
['BREAK_BULK', 'KALITY', 4841],
|
||||
|
||||
['OTHER_IMPORT', 'DIRE_DAWA', 32],
|
||||
['OTHER_IMPORT', 'ADAMA', 3185],
|
||||
['OTHER_IMPORT', 'KALITY', 3089],
|
||||
['OTHER_IMPORT', 'SEBETA', 64],
|
||||
|
||||
['OTHER_EXPORT', 'SEBETA', 297],
|
||||
['OTHER_EXPORT', 'KALITY', 595],
|
||||
['OTHER_EXPORT', 'ADAMA', 4816],
|
||||
['OTHER_EXPORT', 'MEISO', 59],
|
||||
['OTHER_EXPORT', 'BIKE', 59],
|
||||
['OTHER_EXPORT', 'DIRE_DAWA', 119],
|
||||
|
||||
['SAND', 'KALITY', 4247],
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Per-train records
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const hours = (h: number, m = 0, s = 0): number => h + m / 60 + s / 3600;
|
||||
const MS_PER_HOUR = 3_600_000;
|
||||
const addHours = (from: Date, h: number): Date => new Date(from.getTime() + h * MS_PER_HOUR);
|
||||
|
||||
/** Section 8, "Container train turnround cycle analysis" — measured per train. */
|
||||
const CONTAINER_CYCLES: Array<[string, number]> = [
|
||||
['8001', hours(82, 22, 15)],
|
||||
['8101', hours(87, 36, 53)],
|
||||
['8201', hours(84, 17, 7)],
|
||||
['8301', hours(82, 46, 54)],
|
||||
['8401', hours(83, 16, 47)],
|
||||
['8501', hours(83, 18, 54)],
|
||||
['8601', hours(85, 41, 54)],
|
||||
['8701', hours(85, 23, 0)],
|
||||
['8801', hours(81, 15, 38)],
|
||||
['8901', hours(85, 12, 36)],
|
||||
['9001', hours(88, 8, 0)],
|
||||
];
|
||||
|
||||
/** Section 7: expected 21:00:00, actual average 22:52:31 across 246.2 trains. */
|
||||
const ACTUAL_TRAVEL_HOURS = hours(22, 52, 31);
|
||||
|
||||
/** Section 9, Gelan chart: average total staying time 7:12:17. */
|
||||
const GELAN_STAY_HOURS = hours(7, 12, 17);
|
||||
|
||||
/**
|
||||
* Section 9, "Loading/Unloading time (Container) from DMP" — per train, real.
|
||||
* `[train number, loading/unloading hours, total staying hours]`.
|
||||
*/
|
||||
const DMP_TRAINS: Array<[string, number, number]> = [
|
||||
['9002/395M', hours(1, 31), hours(32, 10)],
|
||||
['9002/398M', hours(3, 37), hours(33, 28)],
|
||||
['8002/308M', hours(2, 1), hours(26, 50)],
|
||||
['9002/388M', hours(1, 7), hours(60, 37)],
|
||||
['8902/389M', hours(8, 13), hours(38, 37)],
|
||||
];
|
||||
|
||||
/**
|
||||
* A cycle's three departures.
|
||||
*
|
||||
* The cycle TOTAL is measured — it is the figure the report publishes for that
|
||||
* train. Its internal split is not: the report only publishes averages, so the
|
||||
* legs use the month's average actual travel time (22:52:31) and the Gelan
|
||||
* average station stay (7:12:17), leaving the Djibouti stay as the remainder.
|
||||
* That remainder lands near the report's own DCT average of 31:20:56, which is
|
||||
* the cross-check that the split is sane rather than invented.
|
||||
*/
|
||||
function buildCycleLegs(cycleStart: Date, cycleHours: number) {
|
||||
const arriveEthiopia = addHours(cycleStart, ACTUAL_TRAVEL_HOURS);
|
||||
const departEthiopia = addHours(arriveEthiopia, GELAN_STAY_HOURS);
|
||||
const arriveDjibouti = addHours(departEthiopia, ACTUAL_TRAVEL_HOURS);
|
||||
const nextCycleStart = addHours(cycleStart, cycleHours);
|
||||
return { arriveEthiopia, departEthiopia, arriveDjibouti, nextCycleStart };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Ids {
|
||||
yards: Map<string, string>;
|
||||
cargoTypes: Map<string, string>;
|
||||
locomotiveId: string | null;
|
||||
}
|
||||
|
||||
async function loadIds(ds: DataSource): Promise<Ids> {
|
||||
const yards = new Map<string, string>();
|
||||
for (const row of await ds.query<Array<{ id: string; code: string }>>(
|
||||
`SELECT id, code FROM freight.yards WHERE deleted_at IS NULL`,
|
||||
)) {
|
||||
yards.set(row.code, row.id);
|
||||
}
|
||||
const cargoTypes = new Map<string, string>();
|
||||
for (const row of await ds.query<Array<{ id: string; code: string }>>(
|
||||
`SELECT id, code FROM freight.cargo_types WHERE deleted_at IS NULL`,
|
||||
)) {
|
||||
cargoTypes.set(row.code, row.id);
|
||||
}
|
||||
const [loco] = await ds.query<Array<{ id: string }>>(
|
||||
`SELECT id FROM freight.locomotives WHERE deleted_at IS NULL ORDER BY created_at LIMIT 1`,
|
||||
);
|
||||
return { yards, cargoTypes, locomotiveId: loco?.id ?? null };
|
||||
}
|
||||
|
||||
async function seedStandards(ds: DataSource): Promise<void> {
|
||||
const columns = Object.keys(STANDARDS);
|
||||
const values = Object.values(STANDARDS);
|
||||
const assignments = columns.map((c, i) => `${c} = $${i + 1}`).join(', ');
|
||||
|
||||
// Read first, then write by id. TypeORM returns `[rows, rowCount]` from an
|
||||
// UPDATE ... RETURNING but a bare array from a SELECT, and treating the
|
||||
// former as rows silently counts two of everything.
|
||||
const existing = await ds.query<Array<{ id: string }>>(
|
||||
`SELECT id FROM freight.operations_standards WHERE deleted_at IS NULL ORDER BY created_at LIMIT 1`,
|
||||
);
|
||||
if (existing.length) {
|
||||
await ds.query(
|
||||
`UPDATE freight.operations_standards SET ${assignments}, updated_at = now() WHERE id = $${columns.length + 1}`,
|
||||
[...values, existing[0].id],
|
||||
);
|
||||
} else {
|
||||
await ds.query(
|
||||
`INSERT INTO freight.operations_standards (${columns.join(', ')})
|
||||
VALUES (${columns.map((_, i) => `$${i + 1}`).join(', ')})`,
|
||||
values,
|
||||
);
|
||||
}
|
||||
console.log(`standards : ${columns.length} figures set`);
|
||||
}
|
||||
|
||||
async function seedLegStandards(ds: DataSource, ids: Ids): Promise<void> {
|
||||
let set = 0;
|
||||
const missing: string[] = [];
|
||||
for (const [from, to, h] of LEG_STANDARD_HOURS) {
|
||||
const a = ids.yards.get(from);
|
||||
const b = ids.yards.get(to);
|
||||
if (!a || !b) {
|
||||
missing.push(`${from}-${to} (yard missing)`);
|
||||
continue;
|
||||
}
|
||||
// Symmetric, like the distance itself: match the pair either way round.
|
||||
const rows = await ds.query<Array<{ id: string }>>(
|
||||
`SELECT id FROM freight.yard_distances
|
||||
WHERE deleted_at IS NULL
|
||||
AND ((from_yard_id = $1 AND to_yard_id = $2) OR (from_yard_id = $2 AND to_yard_id = $1))`,
|
||||
[a, b],
|
||||
);
|
||||
if (!rows.length) {
|
||||
missing.push(`${from}-${to} (no distance row)`);
|
||||
continue;
|
||||
}
|
||||
for (const row of rows) {
|
||||
await ds.query(
|
||||
`UPDATE freight.yard_distances SET standard_hours = $2, updated_at = now() WHERE id = $1`,
|
||||
[row.id, h],
|
||||
);
|
||||
set++;
|
||||
}
|
||||
}
|
||||
console.log(`leg standards : ${set} set${missing.length ? `, skipped ${missing.join(', ')}` : ''}`);
|
||||
}
|
||||
|
||||
async function seedCargoTypes(ds: DataSource, ids: Ids): Promise<void> {
|
||||
if (!ids.cargoTypes.has('FERTILIZER')) {
|
||||
// The report's largest bulk category. Billed per ton, like the other bulk
|
||||
// commodities seeded by pricing-data.seeder.
|
||||
const [row] = await ds.query<Array<{ id: string }>>(
|
||||
`INSERT INTO freight.cargo_types (code, cargo_type_name, unit_of_measure, is_active, display_order)
|
||||
VALUES ('FERTILIZER', 'Fertilizer', 'PER_TON', true,
|
||||
(SELECT COALESCE(MAX(display_order), 0) + 1 FROM freight.cargo_types))
|
||||
RETURNING id`,
|
||||
);
|
||||
ids.cargoTypes.set('FERTILIZER', row.id);
|
||||
console.log('cargo types : FERTILIZER created');
|
||||
}
|
||||
|
||||
let set = 0;
|
||||
for (const [code, wagons] of FULL_TRAINSET_WAGONS) {
|
||||
const id = ids.cargoTypes.get(code);
|
||||
if (!id) continue;
|
||||
await ds.query(
|
||||
`UPDATE freight.cargo_types SET full_trainset_wagons = $2, updated_at = now() WHERE id = $1`,
|
||||
[id, wagons],
|
||||
);
|
||||
set++;
|
||||
}
|
||||
console.log(`trainset wagons : ${set} cargo types set`);
|
||||
}
|
||||
|
||||
async function upsertTarget(
|
||||
ds: DataSource,
|
||||
metric: string,
|
||||
dimension: string,
|
||||
dimensionKey: string,
|
||||
plannedValue: number,
|
||||
cargoCategory: string | null,
|
||||
note: string,
|
||||
): Promise<void> {
|
||||
await ds.query(
|
||||
`INSERT INTO freight.operations_targets
|
||||
(period_type, period_start, metric, dimension, dimension_key, cargo_category, planned_value, note)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (period_type, period_start, metric, dimension, dimension_key, COALESCE(cargo_category, ''))
|
||||
WHERE deleted_at IS NULL
|
||||
DO UPDATE SET planned_value = EXCLUDED.planned_value,
|
||||
note = EXCLUDED.note,
|
||||
updated_at = now()`,
|
||||
[PERIOD_TYPE, PERIOD_START, metric, dimension, dimensionKey, cargoCategory, plannedValue, note],
|
||||
);
|
||||
}
|
||||
|
||||
async function seedTargets(ds: DataSource, ids: Ids): Promise<void> {
|
||||
const note = 'OCC July 2026 monthly report';
|
||||
|
||||
for (const [key, value] of TRAINSET_PLAN) {
|
||||
await upsertTarget(ds, 'TRAINSET', 'cargo_category', key, value, null, note);
|
||||
}
|
||||
for (const [key, value] of TEU_PLAN) {
|
||||
await upsertTarget(ds, 'TEU', 'container_class', key, value, null, note);
|
||||
}
|
||||
for (const [key, value] of VOLUME_PLAN) {
|
||||
await upsertTarget(ds, 'VOLUME_TONS', 'cargo_category', key, value, null, note);
|
||||
}
|
||||
|
||||
const skipped: string[] = [];
|
||||
let stations = 0;
|
||||
for (const [category, yardCode, value] of STATION_PLAN) {
|
||||
if (!ids.yards.has(yardCode)) {
|
||||
skipped.push(`${yardCode}/${category}`);
|
||||
continue;
|
||||
}
|
||||
await upsertTarget(ds, 'VOLUME_TONS', 'station', yardCode, value, category, note);
|
||||
stations++;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`targets : ${TRAINSET_PLAN.length} trainset, ${TEU_PLAN.length} TEU, ` +
|
||||
`${VOLUME_PLAN.length} volume, ${stations} station` +
|
||||
(skipped.length ? ` (skipped ${skipped.join(', ')})` : ''),
|
||||
);
|
||||
}
|
||||
|
||||
/** One departure: its physical train, its set, and the schedule row. */
|
||||
async function upsertSchedule(
|
||||
ds: DataSource,
|
||||
args: {
|
||||
reference: string;
|
||||
trainId: string;
|
||||
trainNumber: string;
|
||||
direction: 'IMPORT' | 'EXPORT';
|
||||
originYardId: string;
|
||||
destinationYardId: string;
|
||||
departedAt: Date;
|
||||
arrivedAt: Date | null;
|
||||
},
|
||||
): Promise<string> {
|
||||
const existing = await ds.query<Array<{ id: string }>>(
|
||||
`SELECT id FROM freight.train_schedules WHERE reference = $1 AND deleted_at IS NULL`,
|
||||
[args.reference],
|
||||
);
|
||||
if (existing.length) {
|
||||
await ds.query(
|
||||
`UPDATE freight.train_schedules
|
||||
SET actual_departure_at = $2, actual_arrival_at = $3, updated_at = now()
|
||||
WHERE id = $1`,
|
||||
[existing[0].id, args.departedAt, args.arrivedAt],
|
||||
);
|
||||
return existing[0].id;
|
||||
}
|
||||
|
||||
const [set] = await ds.query<Array<{ id: string }>>(
|
||||
`INSERT INTO freight.train_sets
|
||||
(train_id, locomotive_id, total_weight_tons, total_length_meters, wagon_count, status)
|
||||
VALUES ($1, (SELECT id FROM freight.locomotives WHERE deleted_at IS NULL ORDER BY created_at LIMIT 1),
|
||||
0, 0, 0, 'COMPLETED')
|
||||
RETURNING id`,
|
||||
[args.trainId],
|
||||
);
|
||||
|
||||
const [schedule] = await ds.query<Array<{ id: string }>>(
|
||||
`INSERT INTO freight.train_schedules
|
||||
(train_set_id, origin_station_id, destination_station_id, scheduled_departure_date,
|
||||
scheduled_arrival_date, actual_departure_at, actual_arrival_at, status, train_number,
|
||||
direction, reference, booking_window_status)
|
||||
VALUES ($1, $2, $3, $4, $5, $4, $5, 'ARRIVED', $6, $7, $8, 'CLOSED')
|
||||
RETURNING id`,
|
||||
[
|
||||
set.id,
|
||||
args.originYardId,
|
||||
args.destinationYardId,
|
||||
args.departedAt,
|
||||
args.arrivedAt,
|
||||
args.trainNumber,
|
||||
args.direction,
|
||||
args.reference,
|
||||
],
|
||||
);
|
||||
return schedule.id;
|
||||
}
|
||||
|
||||
async function upsertTrain(ds: DataSource, code: string): Promise<string> {
|
||||
const existing = await ds.query<Array<{ id: string }>>(
|
||||
`SELECT id FROM freight.trains WHERE code = $1 AND deleted_at IS NULL`,
|
||||
[code],
|
||||
);
|
||||
if (existing.length) return existing[0].id;
|
||||
const [row] = await ds.query<Array<{ id: string }>>(
|
||||
`INSERT INTO freight.trains (code, status) VALUES ($1, 'AVAILABLE') RETURNING id`,
|
||||
[code],
|
||||
);
|
||||
return row.id;
|
||||
}
|
||||
|
||||
async function upsertCheckpoint(
|
||||
ds: DataSource,
|
||||
scheduleId: string,
|
||||
yardId: string,
|
||||
sequenceNo: number,
|
||||
kind: 'ARRIVED' | 'DEPARTED',
|
||||
occurredAt: Date,
|
||||
note: string,
|
||||
): Promise<void> {
|
||||
const existing = await ds.query<Array<{ id: string }>>(
|
||||
`SELECT id FROM freight.train_checkpoint_events
|
||||
WHERE train_schedule_id = $1 AND yard_id = $2 AND kind = $3 AND deleted_at IS NULL`,
|
||||
[scheduleId, yardId, kind],
|
||||
);
|
||||
if (existing.length) {
|
||||
await ds.query(
|
||||
`UPDATE freight.train_checkpoint_events SET occurred_at = $2, note = $3, updated_at = now()
|
||||
WHERE id = $1`,
|
||||
[existing[0].id, occurredAt, note],
|
||||
);
|
||||
return;
|
||||
}
|
||||
await ds.query(
|
||||
`INSERT INTO freight.train_checkpoint_events
|
||||
(train_schedule_id, yard_id, sequence_no, kind, occurred_at, note)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
[scheduleId, yardId, sequenceNo, kind, occurredAt, note],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The eleven container trains, each as three departures so one full cycle is
|
||||
* measurable: Nagad → Gelan, Gelan → Nagad, then Nagad again.
|
||||
*/
|
||||
async function seedContainerCycles(ds: DataSource, ids: Ids): Promise<void> {
|
||||
const nagad = ids.yards.get('NAGAD');
|
||||
const gelan = ids.yards.get('KALITY');
|
||||
if (!nagad || !gelan) {
|
||||
console.log('container cycles: skipped — NAGAD or KALITY yard missing');
|
||||
return;
|
||||
}
|
||||
|
||||
// Cycles are staggered a day apart through July so the month reads as a
|
||||
// sequence rather than eleven trains leaving at once.
|
||||
let cycles = 0;
|
||||
for (const [index, [trainNumber, cycleHours]] of CONTAINER_CYCLES.entries()) {
|
||||
const trainId = await upsertTrain(ds, `OCC-${trainNumber}`);
|
||||
const cycleStart = new Date(Date.UTC(2026, 6, 2 + index, 6, 0, 0));
|
||||
const legs = buildCycleLegs(cycleStart, cycleHours);
|
||||
|
||||
const leg1 = await upsertSchedule(ds, {
|
||||
reference: `OCC-2026-07-${trainNumber}-1`,
|
||||
trainId,
|
||||
trainNumber,
|
||||
direction: 'IMPORT',
|
||||
originYardId: nagad,
|
||||
destinationYardId: gelan,
|
||||
departedAt: cycleStart,
|
||||
arrivedAt: legs.arriveEthiopia,
|
||||
});
|
||||
const leg2 = await upsertSchedule(ds, {
|
||||
reference: `OCC-2026-07-${trainNumber}-2`,
|
||||
trainId,
|
||||
trainNumber,
|
||||
direction: 'EXPORT',
|
||||
originYardId: gelan,
|
||||
destinationYardId: nagad,
|
||||
departedAt: legs.departEthiopia,
|
||||
arrivedAt: legs.arriveDjibouti,
|
||||
});
|
||||
const leg3 = await upsertSchedule(ds, {
|
||||
reference: `OCC-2026-07-${trainNumber}-3`,
|
||||
trainId,
|
||||
trainNumber,
|
||||
direction: 'IMPORT',
|
||||
originYardId: nagad,
|
||||
destinationYardId: gelan,
|
||||
departedAt: legs.nextCycleStart,
|
||||
arrivedAt: null,
|
||||
});
|
||||
|
||||
const stayNote = 'OCC July 2026 — station staying time';
|
||||
await upsertCheckpoint(ds, leg1, gelan, 1, 'ARRIVED', legs.arriveEthiopia, stayNote);
|
||||
await upsertCheckpoint(ds, leg2, gelan, 0, 'DEPARTED', legs.departEthiopia, stayNote);
|
||||
await upsertCheckpoint(ds, leg2, nagad, 1, 'ARRIVED', legs.arriveDjibouti, stayNote);
|
||||
await upsertCheckpoint(ds, leg3, nagad, 0, 'DEPARTED', legs.nextCycleStart, stayNote);
|
||||
cycles++;
|
||||
}
|
||||
console.log(`container cycles: ${cycles} trains, 3 departures each`);
|
||||
}
|
||||
|
||||
/** The five DMP trains, with the staying times the report measured for them. */
|
||||
async function seedDmpTrains(ds: DataSource, ids: Ids): Promise<void> {
|
||||
const dmp = ids.yards.get('DORALEH_MULTIPURPOSE_PORT_DMP');
|
||||
const gelan = ids.yards.get('KALITY');
|
||||
if (!dmp || !gelan) {
|
||||
console.log('DMP trains : skipped — DMP or KALITY yard missing');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [index, [trainNumber, handlingHours, stayingHours]] of DMP_TRAINS.entries()) {
|
||||
const trainId = await upsertTrain(ds, `OCC-${trainNumber}`);
|
||||
const arrivedAtDmp = new Date(Date.UTC(2026, 6, 3 + index * 2, 4, 0, 0));
|
||||
const departedDmp = addHours(arrivedAtDmp, stayingHours);
|
||||
const arrivedGelan = addHours(departedDmp, hours(21));
|
||||
|
||||
const schedule = await upsertSchedule(ds, {
|
||||
// `reference` is varchar(20), so the month is implied by the seed itself.
|
||||
reference: `OCC-DMP-${trainNumber.replace('/', '-')}`,
|
||||
trainId,
|
||||
trainNumber,
|
||||
direction: 'IMPORT',
|
||||
originYardId: dmp,
|
||||
destinationYardId: gelan,
|
||||
departedAt: departedDmp,
|
||||
arrivedAt: arrivedGelan,
|
||||
});
|
||||
|
||||
// The loading/unloading figure has nowhere of its own to live yet — no
|
||||
// table records when handling starts and ends — so it rides on the stop's
|
||||
// note, where the staying-time report surfaces it as the stop's reason.
|
||||
const note =
|
||||
`OCC July 2026 — loading/unloading ${handlingHours.toFixed(2)}h of ` +
|
||||
`${stayingHours.toFixed(2)}h total staying`;
|
||||
await upsertCheckpoint(ds, schedule, dmp, 0, 'ARRIVED', arrivedAtDmp, note);
|
||||
await upsertCheckpoint(ds, schedule, dmp, 0, 'DEPARTED', departedDmp, note);
|
||||
}
|
||||
console.log(`DMP trains : ${DMP_TRAINS.length} trains with measured staying times`);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const ds = await AppDataSource.initialize();
|
||||
console.log(`seeding OCC July 2026 into ${ds.options.database as string}\n`);
|
||||
|
||||
const ids = await loadIds(ds);
|
||||
if (!ids.locomotiveId) {
|
||||
throw new Error('No locomotive in this database — train sets require one.');
|
||||
}
|
||||
|
||||
await seedStandards(ds);
|
||||
await seedLegStandards(ds, ids);
|
||||
await seedCargoTypes(ds, ids);
|
||||
await seedTargets(ds, ids);
|
||||
await seedContainerCycles(ds, ids);
|
||||
await seedDmpTrains(ds, ids);
|
||||
|
||||
console.log('\ndone');
|
||||
await ds.destroy();
|
||||
}
|
||||
|
||||
void main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,84 +0,0 @@
|
||||
/* Throwaway: proves the Plan / Implement rate path end to end. Delete after use. */
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { REPORTS } from '../modules/reports/report.registry';
|
||||
import { normalisePeriodStart } from '../modules/operations-reporting/operations-targets.service';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const ds = new DataSource({
|
||||
type: 'postgres',
|
||||
host: 'localhost',
|
||||
port: 5433,
|
||||
username: 'nati',
|
||||
password: 'password',
|
||||
database: 'nati_wt_opsreport',
|
||||
entities: ['src/**/*.entity.ts'],
|
||||
synchronize: false,
|
||||
logging: false,
|
||||
});
|
||||
await ds.initialize();
|
||||
|
||||
const params = { period: 'month' };
|
||||
const run = async (key: string) => {
|
||||
const def = REPORTS.find((r) => r.key === key)!;
|
||||
return def.query({ ds, params, directions: null }).getRawMany();
|
||||
};
|
||||
|
||||
const before = await run('trainset-performance');
|
||||
console.log('trainset rows before target:');
|
||||
console.table(
|
||||
before.map((r) => ({
|
||||
period: r.period,
|
||||
category: r.categoryKey,
|
||||
wagons: r.wagons,
|
||||
operated: r.operated,
|
||||
plan: r.plan,
|
||||
rate: r.implementRate,
|
||||
})),
|
||||
);
|
||||
|
||||
const target = before[0];
|
||||
if (!target) {
|
||||
console.log('no rows to plan against');
|
||||
await ds.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// period label is YYYY-MM for month; a target is stored on the bucket start.
|
||||
const periodStart = normalisePeriodStart('month', `${target.period}-15`);
|
||||
console.log(`\nnormalisePeriodStart('month', '${target.period}-15') = ${periodStart}`);
|
||||
|
||||
await ds.query(
|
||||
`INSERT INTO freight.operations_targets
|
||||
(period_type, period_start, metric, dimension, dimension_key, planned_value)
|
||||
VALUES ('month', $1, 'TRAINSET', 'cargo_category', $2, $3)`,
|
||||
[periodStart, target.categoryKey, Number(target.operated) * 2],
|
||||
);
|
||||
|
||||
const after = await run('trainset-performance');
|
||||
console.log('\ntrainset rows after inserting a target of 2x operated:');
|
||||
console.table(
|
||||
after.map((r) => ({
|
||||
period: r.period,
|
||||
category: r.categoryKey,
|
||||
operated: r.operated,
|
||||
plan: r.plan,
|
||||
rate: r.implementRate,
|
||||
})),
|
||||
);
|
||||
|
||||
const row = after.find((r) => r.categoryKey === target.categoryKey && r.period === target.period);
|
||||
const ok = Number(row?.plan) === Number(target.operated) * 2 && Math.abs(Number(row?.implementRate) - 50) < 0.05;
|
||||
console.log(ok ? '\nPLAN OK — rate is 50% against a doubled plan' : '\nPLAN MISMATCH');
|
||||
|
||||
// Untouched categories must still read null, not zero.
|
||||
const untouched = after.filter((r) => r.categoryKey !== target.categoryKey);
|
||||
const nulls = untouched.every((r) => r.plan === null && r.implementRate === null);
|
||||
console.log(nulls ? 'UNPLANNED OK — plan and rate are null, not zero' : 'UNPLANNED MISMATCH');
|
||||
|
||||
await ds.query(`DELETE FROM freight.operations_targets WHERE metric = 'TRAINSET'`);
|
||||
await ds.destroy();
|
||||
process.exit(ok && nulls ? 0 : 1);
|
||||
}
|
||||
|
||||
void main();
|
||||
@@ -1,99 +0,0 @@
|
||||
/* Throwaway harness: applies the operations migration to a scratch database and
|
||||
* runs every operations report across several parameter sets. Delete after use. */
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { OperationsReporting3580000000000 } from '../migrations/3580000000000-OperationsReporting';
|
||||
import { REPORTS } from '../modules/reports/report.registry';
|
||||
import { ReportContext } from '../modules/reports/report.types';
|
||||
|
||||
const NEW_KEYS = [
|
||||
'station-staying-time',
|
||||
'turnaround-cycle',
|
||||
'train-delays',
|
||||
'trainset-performance',
|
||||
'teu-performance',
|
||||
'cargo-volume-performance',
|
||||
'charged-vs-actual-volume',
|
||||
'cargo-volume-by-station',
|
||||
];
|
||||
|
||||
const PARAM_SETS: Record<string, unknown>[] = [
|
||||
{},
|
||||
{ period: 'month' },
|
||||
{ period: 'quarter', direction: 'IMPORT' },
|
||||
{ period: 'week', country: 'Djibouti' },
|
||||
{ period: 'year', country: 'Ethiopia', categories: ['CONTAINER_IMPORT_MULTIMODAL', 'BULK'] },
|
||||
{ dateFrom: '2020-01-01', dateTo: '2030-01-01', delayedOnly: 'true' },
|
||||
{ trainNumber: 'X', origin: 'NAGAD', destination: 'KALITY' },
|
||||
];
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const ds = new DataSource({
|
||||
type: 'postgres',
|
||||
host: 'localhost',
|
||||
port: 5433,
|
||||
username: 'nati',
|
||||
password: 'password',
|
||||
database: 'nati_wt_opsreport',
|
||||
entities: ['src/**/*.entity.ts'],
|
||||
synchronize: false,
|
||||
logging: false,
|
||||
});
|
||||
await ds.initialize();
|
||||
|
||||
// Apply the migration's DDL by hand — this scratch database is not under
|
||||
// migration control and only needs the two new tables and two columns.
|
||||
const runner = ds.createQueryRunner();
|
||||
await new OperationsReporting3580000000000().up(runner);
|
||||
await runner.release();
|
||||
console.log('migration applied\n');
|
||||
|
||||
let failures = 0;
|
||||
for (const key of NEW_KEYS) {
|
||||
const def = REPORTS.find((r) => r.key === key);
|
||||
if (!def) {
|
||||
console.log(`MISSING ${key}`);
|
||||
failures++;
|
||||
continue;
|
||||
}
|
||||
for (const [i, params] of PARAM_SETS.entries()) {
|
||||
for (const directions of [null, ['IMPORT']] as (string[] | null)[]) {
|
||||
const ctx: ReportContext = { ds, params, directions };
|
||||
const label = `${key} [set ${i}${directions ? ' scoped' : ''}]`;
|
||||
try {
|
||||
const qb = def.query(ctx);
|
||||
const rows = await qb.limit(5).getRawMany();
|
||||
|
||||
// The runner wraps every query for the total count — prove that works too.
|
||||
const [sql, bound] = qb.getQueryAndParameters();
|
||||
const counted = await ds.query(`SELECT COUNT(*)::int AS n FROM (${sql}) AS sub`, bound);
|
||||
|
||||
// Sorting: every sortable column must be a legal ORDER BY.
|
||||
for (const col of def.columns.filter((c) => c.sortable)) {
|
||||
await def
|
||||
.query({ ds, params, directions })
|
||||
.orderBy(col.sortExpr ?? `"${col.key}"`, 'DESC')
|
||||
.limit(1)
|
||||
.getRawMany();
|
||||
}
|
||||
|
||||
const kpis = def.summary ? await def.summary(ctx) : [];
|
||||
console.log(
|
||||
`OK ${label.padEnd(50)} rows=${rows.length} total=${counted[0]?.n ?? '?'} kpis=${kpis
|
||||
.map((k) => `${k.label}:${k.value}${k.unit ?? ''}`)
|
||||
.join(' ')}`,
|
||||
);
|
||||
} catch (err) {
|
||||
failures++;
|
||||
console.log(`FAIL ${label}: ${(err as Error).message.split('\n')[0]}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(failures ? `\n${failures} failures` : '\nall checks passed');
|
||||
await ds.destroy();
|
||||
process.exit(failures ? 1 : 0);
|
||||
}
|
||||
|
||||
void main();
|
||||
@@ -1,18 +0,0 @@
|
||||
const { Client } = require('pg');
|
||||
(async () => {
|
||||
const admin = new Client({ host:'localhost', port:5433, user:'nati', password:'password', database:'postgres' });
|
||||
await admin.connect();
|
||||
const { rows } = await admin.query("SELECT 1 FROM pg_database WHERE datname='nati_wt_opsreport'");
|
||||
if (!rows.length) {
|
||||
await admin.query('CREATE DATABASE nati_wt_opsreport TEMPLATE edr_dev_sqltest');
|
||||
console.log('created nati_wt_opsreport from edr_dev_sqltest');
|
||||
} else console.log('already exists');
|
||||
await admin.end();
|
||||
const db = new Client({ host:'localhost', port:5433, user:'nati', password:'password', database:'nati_wt_opsreport' });
|
||||
await db.connect();
|
||||
for (const t of ['train_schedules','train_set_wagons','wagon_booking_allocations','wagon_allocation_container_items','wagon_allocation_bulk_loads','yard_distances','yards','cargo_types','train_checkpoint_events','bookings','container_types']) {
|
||||
const r = await db.query(`SELECT count(*)::int n FROM freight.${t}`);
|
||||
console.log(t.padEnd(36), r.rows[0].n);
|
||||
}
|
||||
await db.end();
|
||||
})().catch(e => { console.error('FAIL', e.message); process.exit(1); });
|
||||
@@ -1,81 +0,0 @@
|
||||
const { Client } = require('pg');
|
||||
|
||||
const q = async (db, label, sql) => {
|
||||
const { rows } = await db.query(sql);
|
||||
console.log(`\n== ${label}`);
|
||||
console.table(rows);
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const db = new Client({
|
||||
host: 'localhost', port: 5433, user: 'nati', password: 'password',
|
||||
database: 'nati_wt_opsreport',
|
||||
});
|
||||
await db.connect();
|
||||
|
||||
await q(db, 'TEU by hand (40ft = 2)', `
|
||||
SELECT COUNT(*) items,
|
||||
COUNT(*) FILTER (WHERE cty.size_ft = 20) c20,
|
||||
COUNT(*) FILTER (WHERE cty.size_ft >= 40) c40,
|
||||
SUM(CASE WHEN cty.size_ft >= 40 THEN 2 ELSE 1 END) teu
|
||||
FROM freight.wagon_allocation_container_items ci
|
||||
LEFT JOIN freight.container_types cty ON cty.id = ci.container_type_id
|
||||
JOIN freight.wagon_booking_allocations wba ON wba.id = ci.wagon_booking_allocation_id AND wba.deleted_at IS NULL
|
||||
JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id AND tsw.deleted_at IS NULL
|
||||
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id AND ts.deleted_at IS NULL
|
||||
AND ts.status NOT IN ('DRAFT','CANCELLED')
|
||||
WHERE ci.deleted_at IS NULL`);
|
||||
|
||||
await q(db, 'actual tons by hand', `
|
||||
SELECT ROUND(SUM(wba.allocated_weight_tons), 1) actual_tons, COUNT(*) allocations,
|
||||
COUNT(DISTINCT tsw.id) wagons
|
||||
FROM freight.wagon_booking_allocations wba
|
||||
JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id AND tsw.deleted_at IS NULL
|
||||
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id AND ts.deleted_at IS NULL
|
||||
AND ts.status NOT IN ('DRAFT','CANCELLED')
|
||||
WHERE wba.deleted_at IS NULL`);
|
||||
|
||||
await q(db, 'charged by hand: containers 20/40 laden + bulk wagons', `
|
||||
WITH led AS (
|
||||
SELECT wba.id, tsw.id AS wagon_id, wba.load_type, b.equipment_return, ct.code AS cargo_code,
|
||||
(SELECT COUNT(*) FROM freight.wagon_allocation_container_items ci
|
||||
LEFT JOIN freight.container_types cty ON cty.id = ci.container_type_id
|
||||
WHERE ci.wagon_booking_allocation_id = wba.id AND ci.deleted_at IS NULL AND cty.size_ft = 20) c20,
|
||||
(SELECT COUNT(*) FROM freight.wagon_allocation_container_items ci
|
||||
LEFT JOIN freight.container_types cty ON cty.id = ci.container_type_id
|
||||
WHERE ci.wagon_booking_allocation_id = wba.id AND ci.deleted_at IS NULL AND cty.size_ft >= 40) c40
|
||||
FROM freight.wagon_booking_allocations wba
|
||||
JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id AND tsw.deleted_at IS NULL
|
||||
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id AND ts.deleted_at IS NULL
|
||||
AND ts.status NOT IN ('DRAFT','CANCELLED')
|
||||
LEFT JOIN freight.bookings b ON b.id = wba.booking_id AND b.deleted_at IS NULL
|
||||
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
|
||||
WHERE wba.deleted_at IS NULL)
|
||||
SELECT SUM(CASE WHEN load_type = 'CONTAINER' THEN
|
||||
c20 * CASE WHEN equipment_return = 'RETURN' THEN 2.24 ELSE 20 END
|
||||
+ c40 * CASE WHEN equipment_return = 'RETURN' THEN 3.88 ELSE 40 END ELSE 0 END)
|
||||
AS container_charged,
|
||||
COUNT(DISTINCT wagon_id) FILTER (WHERE load_type <> 'CONTAINER'
|
||||
AND COALESCE(cargo_code,'') IN ('PERISHABLE','LIVESTOCK')) * 38 AS perishable_charged,
|
||||
COUNT(DISTINCT wagon_id) FILTER (WHERE load_type <> 'CONTAINER'
|
||||
AND COALESCE(cargo_code,'') NOT IN ('PERISHABLE','LIVESTOCK')) * 70 AS bulk_charged
|
||||
FROM led`);
|
||||
|
||||
await q(db, 'schedules with both actual timestamps', `
|
||||
SELECT ts.train_number, ts.direction, ts.actual_departure_at, ts.actual_arrival_at,
|
||||
ROUND(EXTRACT(EPOCH FROM (ts.actual_arrival_at - ts.actual_departure_at))::numeric/3600, 2) hours
|
||||
FROM freight.train_schedules ts
|
||||
WHERE ts.deleted_at IS NULL AND ts.actual_departure_at IS NOT NULL AND ts.actual_arrival_at IS NOT NULL`);
|
||||
|
||||
await q(db, 'checkpoints', `
|
||||
SELECT e.kind, y.label, e.occurred_at, e.train_schedule_id
|
||||
FROM freight.train_checkpoint_events e JOIN freight.yards y ON y.id = e.yard_id
|
||||
WHERE e.deleted_at IS NULL ORDER BY e.train_schedule_id, e.sequence_no`);
|
||||
|
||||
await q(db, 'standards row', `SELECT station_standard_hours_ethiopia eth, station_standard_hours_djibouti dj,
|
||||
cycle_standard_hours_container cyc, default_leg_standard_hours leg, delay_tolerance_minutes tol,
|
||||
charged_tons_full_20ft f20, charged_tons_full_40ft f40, default_full_trainset_wagons dfl
|
||||
FROM freight.operations_standards`);
|
||||
|
||||
await db.end();
|
||||
})().catch((e) => { console.error('FAIL', e.message); process.exit(1); });
|
||||
Reference in New Issue
Block a user