Files
edr-platform/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts
Marshal ddddcfb71f Refactor wagon type handling and container wagon calculations
- Removed maxWagonsPerTrain from WagonType entity and related DTOs.
- Updated containerWagonsForLines function to calculate required wagons based on container lines more accurately.
- Added unit tests for containerWagonsForLines to ensure correct calculations.
- Adjusted related services and scripts to reflect the removal of maxWagonsPerTrain.
- Enhanced booking and contract components to use new status labels for better user experience.
- Implemented validation for unique container numbers in shipment forms.
2026-07-09 03:36:35 +00:00

560 lines
17 KiB
TypeScript

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 {
Company,
CompanyStatus,
CompanyType,
} from "../modules/companies/entities/company.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 { CargoType } from "../modules/rule-engine/entities/cargo-type.entity";
import { ContainerType } from "../modules/rule-engine/entities/container-type.entity";
import { Container } from "../modules/container-management/entities/container.entity";
import { Route } from "../modules/routes/entities/route.entity";
import { RouteMilestone } from "../modules/routes/entities/route-milestone.entity";
import { Wagon } from "../modules/wagons/entities/wagon.entity";
import { WagonStatus } from "@edr/types";
const SEED_FLAG = "SEED_DEMO_BOOKINGS";
const SERVICE_TYPE_CODE = "RAIL_CONTAINER";
const COMPANY_EMAIL = "train-scheduling-demo@edr.local";
const COMPANY_TIN = "1234567890";
const YARDS = [
{
code: "DJIBOUTI",
label: "Djibouti",
country: "Djibouti" as const,
displayOrder: 1,
},
{
code: "ADDIS_ABABA",
label: "Addis Ababa",
country: "Ethiopia" as const,
displayOrder: 2,
},
{
code: "DIRE_DAWA",
label: "Dire Dawa",
country: "Ethiopia" as const,
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",
status: "PAID",
paymentStatus: "PAID",
},
{
reference: "BKG_ONT_02",
containerCode: "20FT",
quantity: 10,
totalWeightTons: 300,
originCode: "DJIBOUTI",
destinationCode: "ADDIS_ABABA",
scheduledDate: "2026-06-20T08:00:00.000Z",
status: "PAID",
paymentStatus: "PAID",
},
{
reference: "BKG_ONT_03",
containerCode: "40FT",
quantity: 15,
totalWeightTons: 450,
originCode: "DJIBOUTI",
destinationCode: "ADDIS_ABABA",
scheduledDate: "2026-06-20T08:00:00.000Z",
status: "PAID",
paymentStatus: "PAID",
},
{
reference: "BKG_ONT_07",
containerCode: "20FT",
quantity: 6,
totalWeightTons: 180,
originCode: "DJIBOUTI",
destinationCode: "ADDIS_ABABA",
scheduledDate: "2026-06-20T08:00:00.000Z",
status: "PAID",
paymentStatus: "PAID",
},
{
reference: "BKG_ONT_08",
containerCode: "40FT",
quantity: 4,
totalWeightTons: 120,
originCode: "DJIBOUTI",
destinationCode: "ADDIS_ABABA",
scheduledDate: "2026-06-20T08:00:00.000Z",
status: "PAID",
paymentStatus: "PAID",
},
{
reference: "BKG_ONT_09",
containerCode: "20FT",
quantity: 5,
totalWeightTons: 110,
originCode: "DJIBOUTI",
destinationCode: "ADDIS_ABABA",
scheduledDate: "2026-06-20T08:00:00.000Z",
status: "PAID",
paymentStatus: "PAID",
},
{
reference: "BKG_ONT_04",
containerCode: "40FT",
quantity: 12,
totalWeightTons: 360,
originCode: "ADDIS_ABABA",
destinationCode: "DIRE_DAWA",
scheduledDate: "2026-06-20T08:00:00.000Z",
status: "PAID",
paymentStatus: "PAID"
},
{
reference: "BKG_ONT_05",
containerCode: "20FT",
quantity: 8,
totalWeightTons: 160,
originCode: "DJIBOUTI",
destinationCode: "ADDIS_ABABA",
scheduledDate: "2026-06-21T08:00:00.000Z",
status: "PAID",
paymentStatus: "PAID",
},
{
reference: "BKG-CONT-006",
containerCode: "40FT",
quantity: 80,
totalWeightTons: 3600,
originCode: "DJIBOUTI",
destinationCode: "ADDIS_ABABA",
scheduledDate: "2026-06-20T08:00:00.000Z",
status: "PAID",
paymentStatus: "PAID",
},
];
const DEMO_BULK_BOOKINGS = [
{
reference: "BKG-BULK-001",
cargoCode: "COFFEE",
totalWeightTons: 1200,
originCode: "DJIBOUTI",
destinationCode: "ADDIS_ABABA",
scheduledDate: "2026-06-20T08:00:00.000Z",
status: "PAID",
paymentStatus: "PAID",
},
{
reference: "BKG-BULK-002",
cargoCode: "FERTILIZER",
totalWeightTons: 800,
originCode: "DJIBOUTI",
destinationCode: "ADDIS_ABABA",
scheduledDate: "2026-06-20T08:00:00.000Z",
status: "PAID",
paymentStatus: "PAID",
},
{
reference: "BKG-BULK-003",
cargoCode: "STEEL",
totalWeightTons: 450,
originCode: "ADDIS_ABABA",
destinationCode: "DIRE_DAWA",
scheduledDate: "2026-06-20T08:00:00.000Z",
status: "PAID",
paymentStatus: "PAID",
},
];
@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,
supportedLoadTypes: ["CONTAINER"],
isActive: true,
equatedLengthM: 14,
tareWeightTons: 20,
supportsContainer: true,
maxContainerGrossT: 70,
},
{
code: "KW2",
name: "Covered Hopper",
capacityTons: 60,
lengthMeters: 12,
supportedLoadTypes: ["BULK"],
isActive: true,
equatedLengthM: 12,
tareWeightTons: 18,
supportsContainer: false,
},
{
code: "PW2",
name: "Powder Wagon",
capacityTons: 55,
lengthMeters: 12,
supportedLoadTypes: ["BULK"],
isActive: true,
equatedLengthM: 12,
tareWeightTons: 17,
supportsContainer: false,
},
{
code: "CW3",
name: "Open Wagon",
capacityTons: 65,
lengthMeters: 13,
supportedLoadTypes: ["BULK"],
isActive: true,
equatedLengthM: 13,
tareWeightTons: 19,
supportsContainer: false,
},
],
{ conflictPaths: { code: true } },
);
await manager.getRepository(Locomotive).upsert(
[
{
code: "LOC-001",
name: "Demo Locomotive 1",
locomotiveType: 'ELECTRIC',
maxPullWeightTons: 3500,
maxTrainLengthMeters: 760,
status: "AVAILABLE",
},
{
code: "LOC-002",
name: "Demo Locomotive 2",
locomotiveType: 'DIESEL',
maxPullWeightTons: 2500,
maxTrainLengthMeters: 760,
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,
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(Company).upsert(
{
name: "Train Scheduling Demo Customer",
type: CompanyType.Customer,
status: CompanyStatus.Active,
tin: COMPANY_TIN,
vatNumber: "1234567890",
fanNumber: "1234567890123456",
country: "Ethiopia",
address: "Demo Address",
phone: "251900000001",
email: COMPANY_EMAIL,
website: null,
contactPersonName: "Train Scheduling",
contactPersonPhone: "251900000001",
generalManagerName: "Demo Manager",
generalManagerEmail: COMPANY_EMAIL,
generalManagerPhone: "251900000001",
},
{ conflictPaths: { tin: true } },
);
const [serviceType, company, yards, containerTypes] = await Promise.all([
manager
.getRepository(ServiceType)
.findOneByOrFail({ code: SERVICE_TYPE_CODE }),
manager
.getRepository(Company)
.findOneByOrFail({ tin: COMPANY_TIN }),
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,
companyId: company.id,
status: demoBooking.status,
scheduledDate: new Date(demoBooking.scheduledDate),
totalAmount: 2,
paymentStatus: demoBooking.paymentStatus,
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: "ETB", 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 });
const wagonsRequired =
Number(demoBooking.quantity) * Number(containerType.wagonsPerUnit ?? 1);
await manager.getRepository(BookingContainer).insert({
id: randomUUID(),
bookingId: booking.id,
containerTypeId: containerType.id,
quantity: demoBooking.quantity,
vgmPerUnitTons,
totalVgmTons: demoBooking.totalWeightTons,
wagonsRequired,
weightLimitRuleId: null,
isOverweight: vgmPerUnitTons > 35,
overweightExcessTons: vgmPerUnitTons > 35 ? vgmPerUnitTons - 35 : null,
});
}
await manager.getRepository(CargoType).upsert(
[
{ code: "COFFEE", cargoTypeName: "Coffee", isActive: true, displayOrder: 1 },
{ code: "FERTILIZER", cargoTypeName: "Fertilizer", isActive: true, displayOrder: 2 },
{ code: "STEEL", cargoTypeName: "Steel", isActive: true, displayOrder: 3 },
],
{ conflictPaths: { code: true } },
);
const cargoTypes = await manager.getRepository(CargoType).find();
const cargoByCode = new Map(cargoTypes.map((c) => [c.code, c]));
for (const demoBulk of DEMO_BULK_BOOKINGS) {
const origin = yardByCode.get(demoBulk.originCode);
const destination = yardByCode.get(demoBulk.destinationCode);
const cargoType = cargoByCode.get(demoBulk.cargoCode);
if (!origin || !destination || !cargoType) {
throw new Error(`demo_bulk_seed_dependency_missing:${demoBulk.reference}`);
}
await manager.getRepository(Booking).upsert(
{
reference: demoBulk.reference,
companyId: company.id,
status: demoBulk.status,
scheduledDate: new Date(demoBulk.scheduledDate),
totalAmount: 0,
paymentStatus: demoBulk.paymentStatus,
contractType: "NEW",
serviceTypeId: serviceType.id,
equipmentReturn: "WITHOUT_RETURN",
originYardId: origin.id,
destinationYardId: destination.id,
tradeDirection: "IMPORT",
freightType: "BULK",
cargoTypeId: cargoType.id,
cargoFreeText: demoBulk.cargoCode,
shippingLineId: null,
cargoTotalWeightVgm: demoBulk.totalWeightTons,
isHazardous: false,
paymentCurrency: "USD", priorityScore: 10,
schedulingStatus: "HOLDING",
versionNumber: 1,
},
{ conflictPaths: { reference: true } },
);
}
const djibouti = yardByCode.get("DJIBOUTI");
const addis = yardByCode.get("ADDIS_ABABA");
if (djibouti && addis) {
let route = await manager.getRepository(Route).findOne({
where: { originYardId: djibouti.id, destinationYardId: addis.id },
});
if (!route) {
route = await manager.getRepository(Route).save(
manager.getRepository(Route).create({
originYardId: djibouti.id,
destinationYardId: addis.id,
status: 'AVAILABLE',
}),
);
await manager.getRepository(RouteMilestone).save([
manager.getRepository(RouteMilestone).create({
routeId: route.id,
yardId: djibouti.id,
sequenceNo: 1,
distanceKm: 0,
}),
manager.getRepository(RouteMilestone).create({
routeId: route.id,
yardId: addis.id,
sequenceNo: 2,
distanceKm: 780,
}),
]);
}
}
const nw5 = await manager.getRepository(WagonType).findOneBy({ code: "NW5" });
if (nw5 && djibouti && addis) {
await manager.getRepository(Wagon).upsert(
Array.from({ length: 20 }, (_, index) => ({
wagonNumber: `WGN-DEMO-${String(index + 1).padStart(3, "0")}`,
wagonTypeId: nw5.id,
trainId: null,
sequenceNumber: null,
tareWeight: 20,
maxPayloadWeight: 70,
status: WagonStatus.Available,
currentYardId: index % 2 === 0 ? djibouti.id : addis.id,
notes: "Demo wagon for train scheduling",
trainSetWagonId: null,
currentTrainScheduleId: null,
})),
{ conflictPaths: { wagonNumber: true } },
);
}
if (djibouti) {
await manager.getRepository(Locomotive).update(
{ code: "LOC-001" },
{ currentYardId: djibouti.id },
);
}
if (addis) {
await manager.getRepository(Locomotive).update(
{ code: "LOC-002" },
{ currentYardId: addis.id },
);
}
const ft20 = containerTypeByCode.get("20FT");
const ft40 = containerTypeByCode.get("40FT");
if (ft20 && ft40) {
await manager.getRepository(Container).upsert(
Array.from({ length: 30 }, (_, index) => {
const is40Ft = index % 2 === 0;
return {
containerNumber: `CONT-DEMO-${String(index + 1).padStart(3, "0")}`,
containerTypeId: is40Ft ? ft40.id : ft20.id,
wagonId: null,
position: null,
tareWeight: is40Ft ? 4.0 : 2.5,
maxGrossWeight: is40Ft ? 32.5 : 24.5,
sealNumber: null,
status: "AVAILABLE",
bookingId: null,
wagonBookingAllocationId: null,
bookingContainerId: null,
};
}),
{ conflictPaths: { containerNumber: true } },
);
}
});
this.logger.log("Seeded demo train scheduling data");
}
}