From f55645fea919cb9dcc4acd7b0111c7906e273f29 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 20 Aug 2026 11:37:12 +0000 Subject: [PATCH] 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. --- .../src/scripts/seed-occ-july-2026.ts | 643 ++++++++++++++++++ .../src/scripts/tmp-ops-plan.ts | 84 --- .../src/scripts/tmp-ops-verify.ts | 99 --- apps/edr-freight-api/tmp-mkdb.cjs | 18 - apps/edr-freight-api/tmp-ops-reconcile.cjs | 81 --- 5 files changed, 643 insertions(+), 282 deletions(-) create mode 100644 apps/edr-freight-api/src/scripts/seed-occ-july-2026.ts delete mode 100644 apps/edr-freight-api/src/scripts/tmp-ops-plan.ts delete mode 100644 apps/edr-freight-api/src/scripts/tmp-ops-verify.ts delete mode 100644 apps/edr-freight-api/tmp-mkdb.cjs delete mode 100644 apps/edr-freight-api/tmp-ops-reconcile.cjs diff --git a/apps/edr-freight-api/src/scripts/seed-occ-july-2026.ts b/apps/edr-freight-api/src/scripts/seed-occ-july-2026.ts new file mode 100644 index 000000000..120347132 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/seed-occ-july-2026.ts @@ -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; + cargoTypes: Map; + locomotiveId: string | null; +} + +async function loadIds(ds: DataSource): Promise { + const yards = new Map(); + for (const row of await ds.query>( + `SELECT id, code FROM freight.yards WHERE deleted_at IS NULL`, + )) { + yards.set(row.code, row.id); + } + const cargoTypes = new Map(); + for (const row of await ds.query>( + `SELECT id, code FROM freight.cargo_types WHERE deleted_at IS NULL`, + )) { + cargoTypes.set(row.code, row.id); + } + const [loco] = await ds.query>( + `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 { + 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>( + `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 { + 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>( + `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 { + 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>( + `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 { + 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 { + 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 { + const existing = await ds.query>( + `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>( + `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>( + `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 { + const existing = await ds.query>( + `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>( + `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 { + const existing = await ds.query>( + `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 { + 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 { + 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 { + 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); +}); diff --git a/apps/edr-freight-api/src/scripts/tmp-ops-plan.ts b/apps/edr-freight-api/src/scripts/tmp-ops-plan.ts deleted file mode 100644 index 751b01165..000000000 --- a/apps/edr-freight-api/src/scripts/tmp-ops-plan.ts +++ /dev/null @@ -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 { - 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(); diff --git a/apps/edr-freight-api/src/scripts/tmp-ops-verify.ts b/apps/edr-freight-api/src/scripts/tmp-ops-verify.ts deleted file mode 100644 index ec5fa0605..000000000 --- a/apps/edr-freight-api/src/scripts/tmp-ops-verify.ts +++ /dev/null @@ -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[] = [ - {}, - { 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 { - 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(); diff --git a/apps/edr-freight-api/tmp-mkdb.cjs b/apps/edr-freight-api/tmp-mkdb.cjs deleted file mode 100644 index c292ffee6..000000000 --- a/apps/edr-freight-api/tmp-mkdb.cjs +++ /dev/null @@ -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); }); diff --git a/apps/edr-freight-api/tmp-ops-reconcile.cjs b/apps/edr-freight-api/tmp-ops-reconcile.cjs deleted file mode 100644 index f44f26462..000000000 --- a/apps/edr-freight-api/tmp-ops-reconcile.cjs +++ /dev/null @@ -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); });