mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
- 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.
560 lines
17 KiB
TypeScript
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");
|
|
}
|
|
}
|