feat(freight): basics of train scheduling

This commit is contained in:
Michael Abebe
2026-06-04 16:50:38 +03:00
parent ec0d789502
commit 8b3aaad5fd
41 changed files with 3147 additions and 12 deletions

View File

@@ -0,0 +1,265 @@
import { Injectable, Logger } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { DataSource } from 'typeorm';
import { BookingContainer } from '../modules/bookings/entities/booking-container.entity';
import { Booking } from '../modules/bookings/entities/booking.entity';
import { Customer } from '../modules/customers/entities/customer.entity';
import { Locomotive } from '../modules/locomotives/entities/locomotive.entity';
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
import { Yard } from '../modules/rule-engine/entities/yard.entity';
import { WagonType } from '../modules/wagon-types/entities/wagon-type.entity';
import { ContainerType } from '../modules/rule-engine/entities/container-type.entity';
const SEED_FLAG = 'SEED_DEMO_BOOKINGS';
const SERVICE_TYPE_CODE = 'RAIL_CONTAINER';
const CUSTOMER_EMAIL = 'train-scheduling-demo@edr.local';
const YARDS = [
{ code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti', displayOrder: 1 },
{ code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia', displayOrder: 2 },
{ code: 'DIRE_DAWA', label: 'Dire Dawa', country: 'Ethiopia', displayOrder: 3 },
];
const CONTAINER_TYPES = [
{ code: '20FT', label: '20FT', sizeFt: 20 },
{ code: '40FT', label: '40FT', sizeFt: 40 },
];
const DEMO_BOOKINGS = [
{
reference: 'BKG-CONT-001',
containerCode: '40FT',
quantity: 20,
totalWeightTons: 500,
originCode: 'DJIBOUTI',
destinationCode: 'ADDIS_ABABA',
scheduledDate: '2026-06-20T08:00:00.000Z',
},
{
reference: 'BKG-CONT-002',
containerCode: '20FT',
quantity: 10,
totalWeightTons: 300,
originCode: 'DJIBOUTI',
destinationCode: 'ADDIS_ABABA',
scheduledDate: '2026-06-20T08:00:00.000Z',
},
{
reference: 'BKG-CONT-003',
containerCode: '40FT',
quantity: 15,
totalWeightTons: 450,
originCode: 'DJIBOUTI',
destinationCode: 'ADDIS_ABABA',
scheduledDate: '2026-06-20T08:00:00.000Z',
},
{
reference: 'BKG-CONT-004',
containerCode: '40FT',
quantity: 12,
totalWeightTons: 360,
originCode: 'ADDIS_ABABA',
destinationCode: 'DIRE_DAWA',
scheduledDate: '2026-06-20T08:00:00.000Z',
},
{
reference: 'BKG-CONT-005',
containerCode: '20FT',
quantity: 8,
totalWeightTons: 160,
originCode: 'DJIBOUTI',
destinationCode: 'ADDIS_ABABA',
scheduledDate: '2026-06-21T08:00:00.000Z',
},
{
reference: 'BKG-CONT-006',
containerCode: '40FT',
quantity: 80,
totalWeightTons: 3600,
originCode: 'DJIBOUTI',
destinationCode: 'ADDIS_ABABA',
scheduledDate: '2026-06-20T08:00:00.000Z',
},
];
@Injectable()
export class DemoBookingsSeeder {
private readonly logger = new Logger(DemoBookingsSeeder.name);
constructor(private readonly dataSource: DataSource) {}
async run() {
const shouldSeed = process.env[SEED_FLAG]?.trim().toLowerCase() === 'true';
if (!shouldSeed) {
this.logger.log(`Skipping demo booking seed because ${SEED_FLAG} is not enabled`);
return;
}
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(WagonType).upsert(
{
code: 'NW5',
name: 'Flat Wagon',
capacityTons: 70,
lengthMeters: 14,
maxWagonsPerTrain: 53,
supportedLoadTypes: ['CONTAINER'],
isActive: true,
},
{ conflictPaths: { code: true } },
);
await manager.getRepository(Locomotive).upsert(
[
{
code: 'LOC-001',
name: 'Demo Locomotive 1',
maxPullWeightTons: 3500,
status: 'AVAILABLE',
},
{
code: 'LOC-002',
name: 'Demo Locomotive 2',
maxPullWeightTons: 2500,
status: 'AVAILABLE',
},
],
{ conflictPaths: { code: true } },
);
await manager.getRepository(Yard).upsert(
YARDS.map((yard) => ({ ...yard, isActive: true })),
{ conflictPaths: { code: true } },
);
await manager.getRepository(ServiceType).upsert(
{
code: SERVICE_TYPE_CODE,
serviceName: 'Rail Container Service',
description: 'Temporary service type for train scheduling demos',
canBeBookedAlone: true,
includesFirstMile: false,
includesLastMile: false,
includesCustoms: false,
priorityBonusPoints: 0,
isActive: true,
displayOrder: 1,
},
{ conflictPaths: { code: true } },
);
await manager.getRepository(ContainerType).upsert(
CONTAINER_TYPES.map((containerType, index) => ({
...containerType,
wagonsPerUnit: 1,
isReefer: false,
isOpenTop: false,
isActive: true,
displayOrder: index + 1,
})),
{ conflictPaths: { code: true } },
);
await manager.getRepository(Customer).upsert(
{
userId: '00000000-0000-0000-0000-000000000111',
firstName: 'Train',
lastName: 'Scheduling',
email: CUSTOMER_EMAIL,
phone: '251900000001',
companyName: 'Train Scheduling Demo Customer',
companyEmail: CUSTOMER_EMAIL,
companyPhone: '251900000001',
companyLocation: 'Addis Ababa',
companyAddress: 'Demo Address',
customerType: 'DEMO',
status: 'ACTIVE',
contactPersonName: 'Train Scheduling',
contactPersonPhone: '251900000001',
tinNumber: '1234567890',
vatNumber: '1234567890',
fanNumber: '1234567890123456',
generalManagerName: 'Demo Manager',
generalManagerEmail: CUSTOMER_EMAIL,
generalManagerPhone: '251900000001',
},
{ conflictPaths: { email: true } },
);
const [serviceType, customer, yards, containerTypes] = await Promise.all([
manager.getRepository(ServiceType).findOneByOrFail({ code: SERVICE_TYPE_CODE }),
manager.getRepository(Customer).findOneByOrFail({ email: CUSTOMER_EMAIL }),
manager.getRepository(Yard).find(),
manager.getRepository(ContainerType).find(),
]);
const yardByCode = new Map(yards.map((yard) => [yard.code, yard]));
const containerTypeByCode = new Map(
containerTypes.map((containerType) => [containerType.code, containerType]),
);
for (const demoBooking of DEMO_BOOKINGS) {
const origin = yardByCode.get(demoBooking.originCode);
const destination = yardByCode.get(demoBooking.destinationCode);
const containerType = containerTypeByCode.get(demoBooking.containerCode);
if (!origin || !destination || !containerType) {
throw new Error(`demo_booking_seed_dependency_missing:${demoBooking.reference}`);
}
const vgmPerUnitTons = demoBooking.totalWeightTons / demoBooking.quantity;
await manager.getRepository(Booking).upsert(
{
reference: demoBooking.reference,
customerId: customer.id,
status: 'APPROVED',
scheduledDate: new Date(demoBooking.scheduledDate),
totalAmount: 0,
paymentStatus: 'PENDING',
contractType: 'NEW',
serviceTypeId: serviceType.id,
equipmentReturn: 'WITHOUT_RETURN',
originYardId: origin.id,
destinationYardId: destination.id,
tradeDirection: 'IMPORT',
freightType: 'CONTAINER',
cargoTypeId: null,
cargoFreeText: null,
shippingLineId: null,
cargoTotalWeightVgm: demoBooking.totalWeightTons,
isHazardous: false,
paymentCurrency: 'USD',
allowConsolidation: false,
priorityScore: 0,
versionNumber: 1,
},
{ conflictPaths: { reference: true } },
);
const booking = await manager.getRepository(Booking).findOneByOrFail({
reference: demoBooking.reference,
});
await manager.getRepository(BookingContainer).delete({ bookingId: booking.id });
await manager.getRepository(BookingContainer).insert({
id: randomUUID(),
bookingId: booking.id,
containerTypeId: containerType.id,
quantity: demoBooking.quantity,
vgmPerUnitTons,
totalVgmTons: demoBooking.totalWeightTons,
wagonsRequired: Math.ceil(demoBooking.totalWeightTons / 70),
weightLimitRuleId: null,
isOverweight: demoBooking.totalWeightTons > 70,
overweightExcessTons:
demoBooking.totalWeightTons > 70 ? demoBooking.totalWeightTons - 70 : null,
});
}
});
this.logger.log('Seeded demo train scheduling data');
}
}