Files
edr-platform/apps/edr-freight-api/src/seed/marshalling-demo-trains.seeder.ts

478 lines
18 KiB
TypeScript

import { Injectable, Logger } from '@nestjs/common';
import { WagonStatus } from '@edr/types';
import { DataSource } from 'typeorm';
import { Booking } from '../modules/bookings/entities/booking.entity';
import { Company } from '../modules/companies/entities/company.entity';
import { Locomotive } from '../modules/locomotives/entities/locomotive.entity';
import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity';
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
import { Yard } from '../modules/rule-engine/entities/yard.entity';
import { ImportDjiboutiOperation } from '../modules/train-scheduling/entities/import-djibouti-operation.entity';
import { WagonAllocationContainerItem } from '../modules/train-schedules/entities/wagon-allocation-container-item.entity';
import { WagonBookingAllocation } from '../modules/train-schedules/entities/wagon-booking-allocation.entity';
import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity';
import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity';
import { TrainSetWagon } from '../modules/train-sets/entities/train-set-wagon.entity';
import { TrainSet } from '../modules/train-sets/entities/train-set.entity';
import { WagonType } from '../modules/wagon-types/entities/wagon-type.entity';
import { Wagon } from '../modules/wagons/entities/wagon.entity';
import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity';
import { WarehouseYard } from '../modules/warehouses/entities/warehouse-yard.entity';
import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity';
import { Warehouse } from '../modules/warehouses/entities/warehouse.entity';
type DemoDirection = 'IMPORT' | 'EXPORT';
interface DemoTrain {
trainNumber: string;
direction: DemoDirection;
status: 'SCHEDULED' | 'DISPATCHED' | 'ARRIVED';
bookingPrefix: string;
departureOffsetHours: number;
}
const DEMO_TRAINS: DemoTrain[] = [
{
trainNumber: 'MSH-DEMO-IMP-01',
direction: 'IMPORT',
status: 'SCHEDULED',
bookingPrefix: 'MSH-IMP-01',
departureOffsetHours: 6,
},
{
trainNumber: 'MSH-DEMO-IMP-02',
direction: 'IMPORT',
status: 'DISPATCHED',
bookingPrefix: 'MSH-IMP-02',
departureOffsetHours: -3,
},
{
trainNumber: 'MSH-DEMO-IMP-03',
direction: 'IMPORT',
status: 'ARRIVED',
bookingPrefix: 'MSH-IMP-03',
departureOffsetHours: -14,
},
{
trainNumber: 'MSH-DEMO-EXP-01',
direction: 'EXPORT',
status: 'SCHEDULED',
bookingPrefix: 'MSH-EXP-01',
departureOffsetHours: 8,
},
{
trainNumber: 'MSH-DEMO-EXP-02',
direction: 'EXPORT',
status: 'DISPATCHED',
bookingPrefix: 'MSH-EXP-02',
departureOffsetHours: -2,
},
{
trainNumber: 'MSH-DEMO-EXP-03',
direction: 'EXPORT',
status: 'ARRIVED',
bookingPrefix: 'MSH-EXP-03',
departureOffsetHours: -12,
},
];
@Injectable()
export class MarshallingDemoTrainsSeeder {
private readonly logger = new Logger(MarshallingDemoTrainsSeeder.name);
constructor(private readonly dataSource: DataSource) {}
async run(): Promise<void> {
try {
const yardRepo = this.dataSource.getRepository(Yard);
const serviceTypeRepo = this.dataSource.getRepository(ServiceType);
const cargoTypeRepo = this.dataSource.getRepository(CargoType);
const wagonTypeRepo = this.dataSource.getRepository(WagonType);
const warehouseRepo = this.dataSource.getRepository(Warehouse);
const warehouseYardRepo = this.dataSource.getRepository(WarehouseYard);
const warehouseZoneRepo = this.dataSource.getRepository(WarehouseZone);
const djiboutiYard =
(await yardRepo.findOne({ where: { code: 'NAGAD' } })) ??
(await yardRepo.findOne({ where: { code: 'DJIB_PORT' } })) ??
(await yardRepo.findOne({ where: { country: 'Djibouti' } }));
const ethiopiaYard =
(await yardRepo.findOne({ where: { code: 'INDODE' } })) ??
(await yardRepo.findOne({ where: { code: 'MOJO' } })) ??
(await yardRepo.findOne({ where: { country: 'Ethiopia' } }));
const serviceType =
(await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ??
(await serviceTypeRepo.findOne({ where: { isActive: true } }));
const cargoType = await cargoTypeRepo.findOne({ where: { isActive: true } });
const wagonType =
(await wagonTypeRepo.findOne({ where: { code: 'NW5' } })) ??
(await wagonTypeRepo.findOne({ where: { supportsContainer: true } })) ??
(await wagonTypeRepo.findOne({ where: { isActive: true } }));
const warehouse = await warehouseRepo.findOne({ where: { code: 'INDODE_OPEN' } });
const warehouseYard = warehouse
? await warehouseYardRepo.findOne({ where: { warehouseId: warehouse.id } })
: null;
const warehouseZone = warehouseYard
? await warehouseZoneRepo.findOne({ where: { yardId: warehouseYard.id } })
: null;
// bookings.company_id is NOT NULL — reuse any seeded company for the demo.
const company = await this.dataSource
.getRepository(Company)
.findOne({ where: {}, order: { createdAt: 'ASC' } });
const missing = [
!djiboutiYard ? 'Djibouti yard' : '',
!ethiopiaYard ? 'Ethiopia yard' : '',
!serviceType ? 'service type' : '',
!wagonType ? 'wagon type' : '',
!warehouse ? 'INDODE_OPEN warehouse' : '',
!warehouseYard ? 'warehouse yard' : '',
!warehouseZone ? 'warehouse zone' : '',
!company ? 'company' : '',
].filter(Boolean);
if (missing.length) {
this.logger.warn(`Cannot seed marshalling demo trains, missing: ${missing.join(', ')}`);
return;
}
let seeded = 0;
for (const demo of DEMO_TRAINS) {
const created = await this.seedTrain(demo, {
djiboutiYard: djiboutiYard!,
ethiopiaYard: ethiopiaYard!,
serviceType: serviceType!,
cargoType,
wagonType: wagonType!,
warehouse: warehouse!,
warehouseYard: warehouseYard!,
warehouseZone: warehouseZone!,
company: company!,
});
if (created) seeded += 1;
}
this.logger.log(`Marshalling demo trains ready: ${seeded} new train(s) seeded, 6 total expected`);
} catch (error) {
this.logger.error(
`MarshallingDemoTrainsSeeder failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
private async seedTrain(
demo: DemoTrain,
refs: {
djiboutiYard: Yard;
ethiopiaYard: Yard;
serviceType: ServiceType;
cargoType: CargoType | null;
wagonType: WagonType;
warehouse: Warehouse;
warehouseYard: WarehouseYard;
warehouseZone: WarehouseZone;
company: Company;
},
): Promise<boolean> {
const bookingRepo = this.dataSource.getRepository(Booking);
const trainSetRepo = this.dataSource.getRepository(TrainSet);
const trainSetWagonRepo = this.dataSource.getRepository(TrainSetWagon);
const scheduleRepo = this.dataSource.getRepository(TrainSchedule);
const scheduleBookingRepo = this.dataSource.getRepository(TrainScheduleBooking);
const allocationRepo = this.dataSource.getRepository(WagonBookingAllocation);
const containerItemRepo = this.dataSource.getRepository(WagonAllocationContainerItem);
const existing = await scheduleRepo.findOne({ where: { trainNumber: demo.trainNumber } });
if (existing) {
await this.backfillDispatchQueueInventory(demo, refs);
return false;
}
const now = new Date();
const departure = this.addHours(now, demo.departureOffsetHours);
const arrival = this.addHours(departure, demo.direction === 'IMPORT' ? 12 : 10);
const isDispatched = demo.status === 'DISPATCHED';
const isArrived = demo.status === 'ARRIVED';
const hasDeparted = isDispatched || isArrived;
const originYard = demo.direction === 'IMPORT' ? refs.djiboutiYard : refs.ethiopiaYard;
const destinationYard = demo.direction === 'IMPORT' ? refs.ethiopiaYard : refs.djiboutiYard;
const locomotive = await this.ensureLocomotive(originYard.id);
const bookingWeights = [22.4, 24.8, 18.6, 20.2];
const totalWeight = bookingWeights.reduce((sum, weight) => sum + weight, 0);
const wagonCapacity = Number(refs.wagonType.capacityTons) || 70;
const wagonLength = Number(refs.wagonType.lengthMeters) || 14;
const trainSet = await trainSetRepo.save(
trainSetRepo.create({
locomotiveId: locomotive.id,
totalWeightTons: totalWeight,
totalLengthMeters: wagonLength * bookingWeights.length,
wagonCount: bookingWeights.length,
status: isArrived ? 'COMPLETED' : isDispatched ? 'DISPATCHED' : 'ASSIGNED',
}),
);
const schedule = await scheduleRepo.save(
scheduleRepo.create({
trainSetId: trainSet.id,
originStationId: originYard.id,
destinationStationId: destinationYard.id,
scheduledDepartureDate: departure,
scheduledArrivalDate: arrival,
actualDepartureAt: hasDeparted ? departure : null,
actualArrivalAt: isArrived ? arrival : null,
status: demo.status as TrainSchedule['status'],
trainNumber: demo.trainNumber,
direction: demo.direction,
maxWagons: 53,
bookingWindowStatus: 'CLOSED',
}),
);
for (const [index, weightTons] of bookingWeights.entries()) {
const sequence = index + 1;
const bookingReference = `${demo.bookingPrefix}-${String(sequence).padStart(3, '0')}`;
const containerNumber = `${demo.direction === 'IMPORT' ? 'IMDU' : 'EXPU'}${demo.trainNumber.slice(-2)}${String(sequence).padStart(3, '0')}`;
const booking = await bookingRepo.save(
bookingRepo.create({
reference: bookingReference,
companyId: refs.company.id,
originYardId: originYard.id,
destinationYardId: destinationYard.id,
serviceTypeId: refs.serviceType.id,
status: hasDeparted ? 'IN_TRANSIT' : 'PAID',
paymentStatus: 'PAID',
scheduledDate: departure,
contractType: 'SPOT',
equipmentReturn: 'TERMINAL',
paymentCurrency: 'ETB',
totalAmount: 0,
isGovernment: false,
tradeDirection: demo.direction,
freightType: sequence % 2 === 0 ? 'BULK' : 'CONTAINER',
cargoTypeId: refs.cargoType?.id ?? null,
cargoFreeText: refs.cargoType ? null : `${demo.direction} marshalling demo goods ${sequence}`,
cargoTotalWeightVgm: weightTons * 1000,
trainScheduleId: schedule.id,
schedulingStatus: isArrived ? 'ARRIVED' : isDispatched ? 'DISPATCHED' : 'SCHEDULED',
scheduledAt: now,
}),
);
await this.ensureDispatchQueueInventory({
booking,
demo,
refs,
weightKg: weightTons * 1000,
now,
});
const physicalWagon = await this.ensureWagon({
wagonNumber: `${demo.trainNumber}-W${String(sequence).padStart(2, '0')}`,
wagonTypeId: refs.wagonType.id,
yardId: originYard.id,
trainScheduleId: schedule.id,
dispatched: hasDeparted,
});
const trainSetWagon = await trainSetWagonRepo.save(
trainSetWagonRepo.create({
trainSetId: trainSet.id,
wagonTypeId: refs.wagonType.id,
physicalWagonId: physicalWagon.id,
sequenceNo: sequence,
capacityTons: wagonCapacity,
lengthMeters: wagonLength,
assignedWeightTons: weightTons,
status: hasDeparted ? 'DEPARTED' : 'LOADED',
}),
);
await this.dataSource.getRepository(Wagon).update(physicalWagon.id, {
trainSetWagonId: trainSetWagon.id,
});
const allocation = await allocationRepo.save(
allocationRepo.create({
trainSetWagonId: trainSetWagon.id,
bookingId: booking.id,
allocatedWeightTons: weightTons,
loadType: booking.freightType === 'CONTAINER' ? 'CONTAINER' : 'BULK',
status: hasDeparted ? 'DEPARTED' : 'LOADED',
confirmedAt: now,
}),
);
await containerItemRepo.save(
containerItemRepo.create({
wagonBookingAllocationId: allocation.id,
containerNumber,
positionOnWagon: 1,
sealNumber: `SEAL-${demo.trainNumber.slice(-2)}-${sequence}`,
chassisNumber: `CHS-${demo.trainNumber.slice(-2)}-${sequence}`,
grossWeightTons: weightTons,
}),
);
await scheduleBookingRepo.save(
scheduleBookingRepo.create({
trainScheduleId: schedule.id,
bookingId: booking.id,
}),
);
}
if (demo.direction === 'IMPORT') {
await this.seedImportOperation(schedule.id, demo.trainNumber, now, departure, hasDeparted);
}
return true;
}
private async backfillDispatchQueueInventory(
demo: DemoTrain,
refs: {
warehouse: Warehouse;
warehouseYard: WarehouseYard;
warehouseZone: WarehouseZone;
},
): Promise<void> {
const bookingRepo = this.dataSource.getRepository(Booking);
for (let sequence = 1; sequence <= 4; sequence++) {
const reference = `${demo.bookingPrefix}-${String(sequence).padStart(3, '0')}`;
const booking = await bookingRepo.findOne({ where: { reference } });
if (!booking) continue;
await this.ensureDispatchQueueInventory({
booking,
demo,
refs,
weightKg: Number(booking.cargoTotalWeightVgm) || 0,
now: new Date(),
});
}
}
private async ensureDispatchQueueInventory(input: {
booking: Booking;
demo: DemoTrain;
refs: {
warehouse: Warehouse;
warehouseYard: WarehouseYard;
warehouseZone: WarehouseZone;
};
weightKg: number;
now: Date;
}): Promise<void> {
const repo = this.dataSource.getRepository(WarehouseInventory);
const existing = await repo.findOne({ where: { bookingId: input.booking.id } });
if (existing) return;
const exportDispatch = input.demo.direction === 'EXPORT';
const arrivedAt = this.addHours(input.now, -8);
const inspectedAt = this.addHours(input.now, -6);
const readyAt = this.addHours(input.now, -4);
const loadedAt = this.addHours(input.now, -2);
await repo.save(
repo.create({
warehouseId: input.refs.warehouse.id,
yardId: input.refs.warehouseYard.id,
zoneId: input.refs.warehouseZone.id,
bookingId: input.booking.id,
quantity: 1,
weight: input.weightKg,
status: exportDispatch ? 'LOADED' : 'READY_FOR_PICKUP',
inspectionStatus: 'PASSED',
arrivedAt,
unloadedAt: exportDispatch ? null : arrivedAt,
inspectedAt,
readyForLoadingAt: exportDispatch ? readyAt : null,
loadedAt: exportDispatch ? loadedAt : null,
readyForPickupAt: exportDispatch ? null : readyAt,
notes: `[MSH-DEMO] ${input.demo.trainNumber} dispatch queue test item`,
}),
);
}
private async ensureLocomotive(currentYardId: string): Promise<Locomotive> {
const repo = this.dataSource.getRepository(Locomotive);
const existing = await repo.findOne({ where: { code: 'MSH-DEMO-LOCO' } });
if (existing) return existing;
return repo.save(
repo.create({
code: 'MSH-DEMO-LOCO',
name: 'Marshalling Demo Locomotive',
locomotiveType: 'DIESEL',
maxPullWeightTons: 4200,
maxTrainLengthMeters: 760,
status: 'AVAILABLE',
currentYardId,
}),
);
}
private async ensureWagon(input: {
wagonNumber: string;
wagonTypeId: string;
yardId: string;
trainScheduleId: string;
dispatched: boolean;
}): Promise<Wagon> {
const repo = this.dataSource.getRepository(Wagon);
const existing = await repo.findOne({ where: { wagonNumber: input.wagonNumber } });
if (existing) return existing;
return repo.save(
repo.create({
wagonNumber: input.wagonNumber,
wagonTypeId: input.wagonTypeId,
currentYardId: input.yardId,
currentTrainScheduleId: input.trainScheduleId,
status: WagonStatus.Assigned,
notes: 'Marshalling demo seed wagon',
}),
);
}
private async seedImportOperation(
trainScheduleId: string,
trainNumber: string,
now: Date,
departure: Date,
dispatched: boolean,
): Promise<void> {
const repo = this.dataSource.getRepository(ImportDjiboutiOperation);
await repo.save(
repo.create({
trainScheduleId,
documents: {
DELIVERY_ORDER: this.documentRecord(trainNumber, 'DELIVERY_ORDER', now),
PORT_INVOICE: this.documentRecord(trainNumber, 'PORT_INVOICE', now),
DJIBOUTI_T1: this.documentRecord(trainNumber, 'DJIBOUTI_T1', now),
ETHIOPIA_T1: this.documentRecord(trainNumber, 'ETHIOPIA_T1', now),
RAILWAY_BILL: this.documentRecord(trainNumber, 'RAILWAY_BILL', now),
},
gatepassGrantedAt: now,
readyForLoadingAt: now,
loadedOnTrainAt: now,
departedFromDjiboutiAt: dispatched ? departure : null,
performedBy: 'Marshalling Demo Seeder',
notes: '[MSH-DEMO] Import train ready for marshalling document and dispatch workflow',
}),
);
}
private documentRecord(trainNumber: string, type: string, now: Date) {
return {
reference: `${type}-${trainNumber}`,
uploadedAt: now.toISOString(),
uploadedBy: 'Marshalling Demo Seeder',
notes: 'Seeded document for import marshalling workflow',
};
}
private addHours(date: Date, hours: number): Date {
return new Date(date.getTime() + hours * 60 * 60 * 1000);
}
}