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:
Nathnael
2026-08-20 11:37:12 +00:00
parent 1b08666527
commit f55645fea9
5 changed files with 643 additions and 282 deletions

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

View File

@@ -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();

View File

@@ -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();

View File

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

View File

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