resolve conflict

This commit is contained in:
hagiye
2026-06-05 10:59:52 +03:00
19 changed files with 2325 additions and 654 deletions

View File

@@ -37,6 +37,7 @@ import {
import { EdrOrgSeeder } from "./seed/edr-org.seeder"; import { EdrOrgSeeder } from "./seed/edr-org.seeder";
import { DemoUsersSeeder } from "./seed/demo-users.seeder"; import { DemoUsersSeeder } from "./seed/demo-users.seeder";
import { DemoBookingsSeeder } from "./seed/demo-bookings.seeder"; import { DemoBookingsSeeder } from "./seed/demo-bookings.seeder";
import { PricingDataSeeder } from "./seed/pricing-data.seeder";
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder"; import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
//New Trains, Wagons, Container and Cargo management modules //New Trains, Wagons, Container and Cargo management modules
import { TrainsModule } from "./modules/trains/trains.module"; import { TrainsModule } from "./modules/trains/trains.module";
@@ -94,7 +95,7 @@ import { CargoesModule } from './modules/cargoes/cargoes.module';
ContainersModule, ContainersModule,
CargoesModule, CargoesModule,
], ],
providers: [EdrOrgSeeder, DemoUsersSeeder, DemoBookingsSeeder, FileUploadSettingsSeeder], providers: [EdrOrgSeeder, DemoUsersSeeder, DemoBookingsSeeder, PricingDataSeeder, FileUploadSettingsSeeder],
}) })
export class AppModule implements OnApplicationBootstrap { export class AppModule implements OnApplicationBootstrap {
constructor( constructor(
@@ -102,6 +103,7 @@ export class AppModule implements OnApplicationBootstrap {
private readonly edrOrgSeeder: EdrOrgSeeder, private readonly edrOrgSeeder: EdrOrgSeeder,
private readonly demoUsersSeeder: DemoUsersSeeder, private readonly demoUsersSeeder: DemoUsersSeeder,
private readonly demoBookingsSeeder: DemoBookingsSeeder, private readonly demoBookingsSeeder: DemoBookingsSeeder,
private readonly pricingDataSeeder: PricingDataSeeder,
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder, private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
) { } ) { }
@@ -110,6 +112,7 @@ export class AppModule implements OnApplicationBootstrap {
await this.edrOrgSeeder.run(); await this.edrOrgSeeder.run();
await this.demoUsersSeeder.run(); await this.demoUsersSeeder.run();
await this.demoBookingsSeeder.run(); await this.demoBookingsSeeder.run();
await this.pricingDataSeeder.run();
await this.fileUploadSettingsSeeder.run(); await this.fileUploadSettingsSeeder.run();
} }
} }

View File

@@ -147,6 +147,18 @@ export class BookingsController {
return this.bookingsService.remove(id); return this.bookingsService.remove(id);
} }
@Post(':id/documents')
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Upload documents for a booking (DRAFT only)' })
async uploadDocuments(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
) {
const booking = await this.bookingsService.uploadDocuments(id, files ?? []);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/generate-price') @Post(':id/generate-price')
@ApiOperation({ summary: 'Generate price preview (DRAFT only)' }) @ApiOperation({ summary: 'Generate price preview (DRAFT only)' })
@ApiOkResponse({ type: GeneratePriceResponseDto }) @ApiOkResponse({ type: GeneratePriceResponseDto })

View File

@@ -452,6 +452,21 @@ export class BookingsService {
return this.findById(booking.id); return this.findById(booking.id);
} }
/** Upload documents for a DRAFT booking. */
async uploadDocuments(
id: string,
files: Express.Multer.File[],
): Promise<Booking> {
const booking = await this.findById(id);
if (booking.status !== 'DRAFT') {
throw new BadRequestException(
'Documents can only be uploaded for DRAFT bookings',
);
}
await this.filesService.uploadMany(id, 'bookings', files);
return this.findById(id);
}
async remove(id: string): Promise<void> { async remove(id: string): Promise<void> {
const booking = await this.findById(id); const booking = await this.findById(id);
if (booking.status !== 'DRAFT') { if (booking.status !== 'DRAFT') {

View File

@@ -3,25 +3,28 @@ import {
ConflictException, ConflictException,
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from "@nestjs/common";
import { InjectDataSource } from '@nestjs/typeorm'; import { InjectDataSource } from "@nestjs/typeorm";
import { DataSource, EntityManager, In } from 'typeorm'; import { DataSource, EntityManager, In } from "typeorm";
import { Booking } from '../bookings/entities/booking.entity'; import { Booking } from "../bookings/entities/booking.entity";
import { Locomotive, type LocomotiveStatus } from '../locomotives/entities/locomotive.entity'; import {
import { LocomotivesRepository } from '../locomotives/locomotives.repository'; Locomotive,
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; type LocomotiveStatus,
import { TrainSet } from '../train-sets/entities/train-set.entity'; } from "../locomotives/entities/locomotive.entity";
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; import { LocomotivesRepository } from "../locomotives/locomotives.repository";
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { TrainSetWagon } from "../train-sets/entities/train-set-wagon.entity";
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity'; import { TrainSet } from "../train-sets/entities/train-set.entity";
import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { TrainScheduleBooking } from "../train-schedules/entities/train-schedule-booking.entity";
import { WagonTypesRepository } from '../wagon-types/wagon-types.repository'; import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
import { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto'; import { WagonBookingAllocation } from "../train-schedules/entities/wagon-booking-allocation.entity";
import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto'; import { WagonType } from "../wagon-types/entities/wagon-type.entity";
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto'; import { WagonTypesRepository } from "../wagon-types/wagon-types.repository";
import { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.dto";
import { GetEligibleContainerBookingsDto } from "./dto/get-eligible-container-bookings.dto";
import { PreviewContainerTrainScheduleDto } from "./dto/preview-container-train-schedule.dto";
const DEFAULT_WAGON_TYPE_CODE = 'NW5'; const DEFAULT_WAGON_TYPE_CODE = "NW5";
const MAX_TRAIN_WEIGHT_TONS = 3500; const MAX_TRAIN_WEIGHT_TONS = 3500;
const MAX_TRAIN_LENGTH_METERS = 760; const MAX_TRAIN_LENGTH_METERS = 760;
@@ -74,11 +77,12 @@ export class TrainSchedulingService {
private readonly dataSource: DataSource, private readonly dataSource: DataSource,
private readonly locomotivesRepository: LocomotivesRepository, private readonly locomotivesRepository: LocomotivesRepository,
private readonly wagonTypesRepository: WagonTypesRepository, private readonly wagonTypesRepository: WagonTypesRepository,
) {} ) { }
async getEligibleContainerBookings(query: GetEligibleContainerBookingsDto) { async getEligibleContainerBookings(query: GetEligibleContainerBookingsDto) {
const bookingRepository = this.dataSource.getRepository(Booking); const bookingRepository = this.dataSource.getRepository(Booking);
const queryBuilder = bookingRepository const queryBuilder = bookingRepository
<<<<<<< HEAD
.createQueryBuilder('booking') .createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company') .leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.originYard', 'originYard') .leftJoinAndSelect('booking.originYard', 'originYard')
@@ -88,17 +92,35 @@ export class TrainSchedulingService {
.leftJoin(TrainScheduleBooking, 'scheduleBooking', 'scheduleBooking.booking_id = booking.id') .leftJoin(TrainScheduleBooking, 'scheduleBooking', 'scheduleBooking.booking_id = booking.id')
.where('booking.freightType = :freightType', { freightType: 'CONTAINER' }) .where('booking.freightType = :freightType', { freightType: 'CONTAINER' })
.andWhere('scheduleBooking.id IS NULL'); .andWhere('scheduleBooking.id IS NULL');
=======
.createQueryBuilder("booking")
.leftJoinAndSelect("booking.customer", "customer")
.leftJoinAndSelect("booking.originYard", "originYard")
.leftJoinAndSelect("booking.destinationYard", "destinationYard")
.leftJoinAndSelect("booking.bookingContainers", "bookingContainer")
.leftJoinAndSelect("bookingContainer.containerType", "containerType")
.leftJoin(
TrainScheduleBooking,
"scheduleBooking",
"scheduleBooking.booking_id = booking.id",
)
.where("booking.freightType = :freightType", { freightType: "CONTAINER" })
.andWhere("scheduleBooking.id IS NULL");
>>>>>>> bd6ad54eea229305a214d06081e0ffd27fe163b8
if (query.originStationId) { if (query.originStationId) {
queryBuilder.andWhere('booking.originYardId = :originStationId', { queryBuilder.andWhere("booking.originYardId = :originStationId", {
originStationId: query.originStationId, originStationId: query.originStationId,
}); });
} }
if (query.destinationStationId) { if (query.destinationStationId) {
queryBuilder.andWhere('booking.destinationYardId = :destinationStationId', { queryBuilder.andWhere(
destinationStationId: query.destinationStationId, "booking.destinationYardId = :destinationStationId",
}); {
destinationStationId: query.destinationStationId,
},
);
} }
if (query.scheduleDate) { if (query.scheduleDate) {
@@ -109,25 +131,52 @@ export class TrainSchedulingService {
} }
if (query.status) { if (query.status) {
queryBuilder.andWhere('booking.status = :status', { status: query.status }); queryBuilder.andWhere("booking.status = :status", {
status: query.status,
});
} }
const bookings = await queryBuilder const bookings = await queryBuilder
.orderBy('booking.scheduled_date', 'ASC') .orderBy("booking.scheduled_date", "ASC")
.addOrderBy('booking.created_at', 'ASC') .addOrderBy("booking.created_at", "ASC")
.getMany(); .getMany();
const items: EligibleBookingItem[] = bookings.map((booking) => ({ const items: EligibleBookingItem[] = bookings.map((booking) => ({
id: booking.id, id: booking.id,
reference: booking.reference, reference: booking.reference,
<<<<<<< HEAD
customer: booking.company?.name ?? booking.company?.email ?? 'Unknown customer', customer: booking.company?.name ?? booking.company?.email ?? 'Unknown customer',
containerType: booking.bookingContainers containerType: booking.bookingContainers
?.map((container) => container.containerType?.label ?? container.containerType?.code ?? 'Container') ?.map((container) => container.containerType?.label ?? container.containerType?.code ?? 'Container')
.join(', ') ?? 'Container', .join(', ') ?? 'Container',
quantity: booking.bookingContainers?.reduce((sum, container) => sum + Number(container.quantity ?? 0), 0) ?? 0, quantity: booking.bookingContainers?.reduce((sum, container) => sum + Number(container.quantity ?? 0), 0) ?? 0,
=======
customer:
booking.company?.name ?? booking.company?.email ?? "Unknown customer",
containerType:
booking.bookingContainers
?.map(
(container) =>
container.containerType?.label ??
container.containerType?.code ??
"Container",
)
.join(", ") ?? "Container",
quantity:
booking.bookingContainers?.reduce(
(sum, container) => sum + Number(container.quantity ?? 0),
0,
) ?? 0,
>>>>>>> bd6ad54eea229305a214d06081e0ffd27fe163b8
weightTons: this.roundTons(booking.cargoTotalWeightVgm), weightTons: this.roundTons(booking.cargoTotalWeightVgm),
origin: booking.originYard?.label ?? booking.originYard?.code ?? 'Unknown origin', origin:
destination: booking.destinationYard?.label ?? booking.destinationYard?.code ?? 'Unknown destination', booking.originYard?.label ??
booking.originYard?.code ??
"Unknown origin",
destination:
booking.destinationYard?.label ??
booking.destinationYard?.code ??
"Unknown destination",
preferredDepartureDate: booking.scheduledDate.toISOString(), preferredDepartureDate: booking.scheduledDate.toISOString(),
status: booking.status, status: booking.status,
})); }));
@@ -155,7 +204,7 @@ export class TrainSchedulingService {
if (!validation.valid) { if (!validation.valid) {
throw new BadRequestException({ throw new BadRequestException({
message: 'train_schedule_invalid', message: "train_schedule_invalid",
violations: validation.violations, violations: validation.violations,
}); });
} }
@@ -165,92 +214,115 @@ export class TrainSchedulingService {
validation.summary.totalWeightTons, validation.summary.totalWeightTons,
); );
const createdSchedule = await this.dataSource.transaction(async (manager) => { const createdSchedule = await this.dataSource.transaction(
const locomotiveRepository = manager.getRepository(Locomotive); async (manager) => {
const lockedLocomotive = await locomotiveRepository.findOne({ const locomotiveRepository = manager.getRepository(Locomotive);
where: { id: locomotive.id }, const lockedLocomotive = await locomotiveRepository.findOne({
lock: { mode: 'pessimistic_write' }, where: { id: locomotive.id },
}); lock: { mode: "pessimistic_write" },
});
if (!lockedLocomotive) { if (!lockedLocomotive) {
throw new NotFoundException(`Locomotive ${locomotive.id} not found`); throw new NotFoundException(`Locomotive ${locomotive.id} not found`);
}
if (lockedLocomotive.status !== 'AVAILABLE') {
throw new ConflictException(`Locomotive ${lockedLocomotive.code} is not available`);
}
if (Number(lockedLocomotive.maxPullWeightTons) < validation.summary.totalWeightTons) {
throw new BadRequestException(
`Locomotive ${lockedLocomotive.code} cannot pull ${validation.summary.totalWeightTons}T`,
);
}
const existingScheduleCount = await manager.getRepository(TrainScheduleBooking).count({
where: { bookingId: In(validation.bookings.map((booking) => booking.id)) },
});
if (existingScheduleCount > 0) {
throw new BadRequestException('One or more bookings are already scheduled');
}
const trainSet = await this.buildTrainSet(
manager,
lockedLocomotive,
validation.wagonType,
validation.summary.totalWeightTons,
validation.summary.totalLengthMeters,
validation.wagonPlan,
);
const schedule = manager.getRepository(TrainSchedule).create({
trainSetId: trainSet.id,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
scheduledDepartureDate: new Date(dto.scheduleDate),
status: 'SCHEDULED',
});
const savedSchedule = await manager.getRepository(TrainSchedule).save(schedule);
const scheduleBookings = validation.bookings.map((booking) =>
manager.getRepository(TrainScheduleBooking).create({
trainScheduleId: savedSchedule.id,
bookingId: booking.id,
}),
);
await manager.getRepository(TrainScheduleBooking).save(scheduleBookings);
const savedWagons = await manager.getRepository(TrainSetWagon).find({
where: { trainSetId: trainSet.id },
order: { sequenceNo: 'ASC' },
});
const wagonBySequence = new Map(savedWagons.map((wagon) => [wagon.sequenceNo, wagon]));
const allocationRows = validation.wagonPlan.flatMap((wagonPlan) => {
const wagon = wagonBySequence.get(wagonPlan.sequenceNo);
if (!wagon) {
throw new BadRequestException(`Missing wagon sequence ${wagonPlan.sequenceNo}`);
} }
return wagonPlan.allocations.map((allocation) => if (lockedLocomotive.status !== "AVAILABLE") {
manager.getRepository(WagonBookingAllocation).create({ throw new ConflictException(
trainSetWagonId: wagon.id, `Locomotive ${lockedLocomotive.code} is not available`,
bookingId: allocation.bookingId, );
allocatedWeightTons: allocation.allocatedWeightTons, }
if (
Number(lockedLocomotive.maxPullWeightTons) <
validation.summary.totalWeightTons
) {
throw new BadRequestException(
`Locomotive ${lockedLocomotive.code} cannot pull ${validation.summary.totalWeightTons}T`,
);
}
const existingScheduleCount = await manager
.getRepository(TrainScheduleBooking)
.count({
where: {
bookingId: In(validation.bookings.map((booking) => booking.id)),
},
});
if (existingScheduleCount > 0) {
throw new BadRequestException(
"One or more bookings are already scheduled",
);
}
const trainSet = await this.buildTrainSet(
manager,
lockedLocomotive,
validation.wagonType,
validation.summary.totalWeightTons,
validation.summary.totalLengthMeters,
validation.wagonPlan,
);
const schedule = manager.getRepository(TrainSchedule).create({
trainSetId: trainSet.id,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
scheduledDepartureDate: new Date(dto.scheduleDate),
status: "SCHEDULED",
});
const savedSchedule = await manager
.getRepository(TrainSchedule)
.save(schedule);
const scheduleBookings = validation.bookings.map((booking) =>
manager.getRepository(TrainScheduleBooking).create({
trainScheduleId: savedSchedule.id,
bookingId: booking.id,
}), }),
); );
}); await manager
.getRepository(TrainScheduleBooking)
.save(scheduleBookings);
await manager.getRepository(WagonBookingAllocation).save(allocationRows); const savedWagons = await manager.getRepository(TrainSetWagon).find({
where: { trainSetId: trainSet.id },
order: { sequenceNo: "ASC" },
});
await locomotiveRepository.update(lockedLocomotive.id, { const wagonBySequence = new Map(
status: 'ASSIGNED', savedWagons.map((wagon) => [wagon.sequenceNo, wagon]),
}); );
const allocationRows = validation.wagonPlan.flatMap((wagonPlan) => {
const wagon = wagonBySequence.get(wagonPlan.sequenceNo);
return savedSchedule.id; if (!wagon) {
}); throw new BadRequestException(
`Missing wagon sequence ${wagonPlan.sequenceNo}`,
);
}
return wagonPlan.allocations.map((allocation) =>
manager.getRepository(WagonBookingAllocation).create({
trainSetWagonId: wagon.id,
bookingId: allocation.bookingId,
allocatedWeightTons: allocation.allocatedWeightTons,
}),
);
});
await manager
.getRepository(WagonBookingAllocation)
.save(allocationRows);
await locomotiveRepository.update(lockedLocomotive.id, {
status: "ASSIGNED",
});
return savedSchedule.id;
},
);
return this.getContainerTrainScheduleById(createdSchedule); return this.getContainerTrainScheduleById(createdSchedule);
} }
@@ -261,7 +333,7 @@ export class TrainSchedulingService {
const bookingIds = [...new Set(dto.bookingIds)]; const bookingIds = [...new Set(dto.bookingIds)];
if (!bookingIds.length) { if (!bookingIds.length) {
throw new BadRequestException('At least one booking is required'); throw new BadRequestException("At least one booking is required");
} }
const [wagonType] = await this.wagonTypesRepository.findAll({ const [wagonType] = await this.wagonTypesRepository.findAll({
@@ -269,7 +341,9 @@ export class TrainSchedulingService {
}); });
if (!wagonType) { if (!wagonType) {
throw new NotFoundException(`Wagon type ${DEFAULT_WAGON_TYPE_CODE} not found`); throw new NotFoundException(
`Wagon type ${DEFAULT_WAGON_TYPE_CODE} not found`,
);
} }
const bookings = await this.loadBookingsForScheduling(bookingIds); const bookings = await this.loadBookingsForScheduling(bookingIds);
@@ -278,21 +352,29 @@ export class TrainSchedulingService {
if (bookings.length !== bookingIds.length) { if (bookings.length !== bookingIds.length) {
const foundIds = new Set(bookings.map((booking) => booking.id)); const foundIds = new Set(bookings.map((booking) => booking.id));
const missing = bookingIds.filter((id) => !foundIds.has(id)); const missing = bookingIds.filter((id) => !foundIds.has(id));
violations.push(`Bookings not found: ${missing.join(', ')}`); violations.push(`Bookings not found: ${missing.join(", ")}`);
} }
const scheduledLinks = await this.dataSource.getRepository(TrainScheduleBooking).find({ const scheduledLinks = await this.dataSource
where: { bookingId: In(bookingIds) }, .getRepository(TrainScheduleBooking)
select: { bookingId: true }, .find({
}); where: { bookingId: In(bookingIds) },
select: { bookingId: true },
});
if (scheduledLinks.length > 0) { if (scheduledLinks.length > 0) {
violations.push('One or more selected bookings are already assigned to a train schedule'); violations.push(
"One or more selected bookings are already assigned to a train schedule",
);
} }
const nonContainerBookings = bookings.filter((booking) => booking.freightType !== 'CONTAINER'); const nonContainerBookings = bookings.filter(
(booking) => booking.freightType !== "CONTAINER",
);
if (nonContainerBookings.length > 0) { if (nonContainerBookings.length > 0) {
violations.push('Only CONTAINER bookings are supported for train scheduling'); violations.push(
"Only CONTAINER bookings are supported for train scheduling",
);
} }
const scheduleDateKey = this.toUtcDateKey(dto.scheduleDate); const scheduleDateKey = this.toUtcDateKey(dto.scheduleDate);
@@ -302,44 +384,62 @@ export class TrainSchedulingService {
booking.destinationYardId !== dto.destinationStationId, booking.destinationYardId !== dto.destinationStationId,
); );
if (routeMismatch) { if (routeMismatch) {
violations.push('Selected bookings must share the same origin and destination as the schedule'); violations.push(
"Selected bookings must share the same origin and destination as the schedule",
);
} }
const dateMismatch = bookings.some( const dateMismatch = bookings.some(
(booking) => this.toUtcDateKey(booking.scheduledDate) !== scheduleDateKey, (booking) => this.toUtcDateKey(booking.scheduledDate) !== scheduleDateKey,
); );
if (dateMismatch) { if (dateMismatch) {
violations.push('Selected bookings must share the same schedule date'); violations.push("Selected bookings must share the same schedule date");
} }
const uniqueOriginCount = new Set(bookings.map((booking) => booking.originYardId)).size; const uniqueOriginCount = new Set(
bookings.map((booking) => booking.originYardId),
).size;
if (uniqueOriginCount > 1) { if (uniqueOriginCount > 1) {
violations.push('Selected bookings must share the same origin station'); violations.push("Selected bookings must share the same origin station");
} }
const uniqueDestinationCount = new Set(bookings.map((booking) => booking.destinationYardId)).size; const uniqueDestinationCount = new Set(
bookings.map((booking) => booking.destinationYardId),
).size;
if (uniqueDestinationCount > 1) { if (uniqueDestinationCount > 1) {
violations.push('Selected bookings must share the same destination station'); violations.push(
"Selected bookings must share the same destination station",
);
} }
const uniqueDateCount = new Set( const uniqueDateCount = new Set(
bookings.map((booking) => this.toUtcDateKey(booking.scheduledDate)), bookings.map((booking) => this.toUtcDateKey(booking.scheduledDate)),
).size; ).size;
if (uniqueDateCount > 1) { if (uniqueDateCount > 1) {
violations.push('Selected bookings must share the same preferred departure date'); violations.push(
"Selected bookings must share the same preferred departure date",
);
} }
const totalWeightTons = this.roundTons( const totalWeightTons = this.roundTons(
bookings.reduce((sum, booking) => sum + Number(booking.cargoTotalWeightVgm ?? 0), 0), bookings.reduce(
(sum, booking) => sum + Number(booking.cargoTotalWeightVgm ?? 0),
0,
),
); );
const wagonPlan = this.allocateBookingsToWagons(bookings, this.calculateNW5WagonPlan(totalWeightTons, wagonType)); const wagonPlan = this.allocateBookingsToWagons(
bookings,
this.calculateNW5WagonPlan(totalWeightTons, wagonType),
);
const totalLengthMeters = this.roundTons( const totalLengthMeters = this.roundTons(
wagonPlan.reduce((sum, wagon) => sum + wagon.lengthMeters, 0), wagonPlan.reduce((sum, wagon) => sum + wagon.lengthMeters, 0),
); );
if (totalWeightTons > MAX_TRAIN_WEIGHT_TONS) { if (totalWeightTons > MAX_TRAIN_WEIGHT_TONS) {
violations.push(`Total booking weight ${totalWeightTons}T exceeds max train weight ${MAX_TRAIN_WEIGHT_TONS}T`); violations.push(
`Total booking weight ${totalWeightTons}T exceeds max train weight ${MAX_TRAIN_WEIGHT_TONS}T`,
);
} }
if (totalLengthMeters > MAX_TRAIN_LENGTH_METERS) { if (totalLengthMeters > MAX_TRAIN_LENGTH_METERS) {
@@ -357,21 +457,25 @@ export class TrainSchedulingService {
); );
} }
const availableLocomotiveCount = await this.dataSource.getRepository(Locomotive).count({ const availableLocomotiveCount = await this.dataSource
where: { status: 'AVAILABLE' as LocomotiveStatus }, .getRepository(Locomotive)
}); .count({
where: { status: "AVAILABLE" as LocomotiveStatus },
});
if (availableLocomotiveCount === 0) { if (availableLocomotiveCount === 0) {
violations.push('No available locomotive exists for scheduling'); violations.push("No available locomotive exists for scheduling");
} else { } else {
const capableLocomotives = await this.dataSource.getRepository(Locomotive).find({ const capableLocomotives = await this.dataSource
where: { status: 'AVAILABLE' }, .getRepository(Locomotive)
}); .find({
where: { status: "AVAILABLE" },
});
const canPull = capableLocomotives.some( const canPull = capableLocomotives.some(
(locomotive) => Number(locomotive.maxPullWeightTons) >= totalWeightTons, (locomotive) => Number(locomotive.maxPullWeightTons) >= totalWeightTons,
); );
if (!canPull) { if (!canPull) {
violations.push('No available locomotive can pull the total weight'); violations.push("No available locomotive can pull the total weight");
} }
} }
@@ -391,14 +495,21 @@ export class TrainSchedulingService {
}; };
} }
calculateNW5WagonPlan(totalBookingWeightTons: number, wagonType: WagonType): WagonPlanRecord[] { calculateNW5WagonPlan(
totalBookingWeightTons: number,
wagonType: WagonType,
): WagonPlanRecord[] {
const wagonCapacityTons = Number(wagonType.capacityTons); const wagonCapacityTons = Number(wagonType.capacityTons);
const wagonsNeeded = Math.ceil(totalBookingWeightTons / wagonCapacityTons); const wagonsNeeded = Math.ceil(totalBookingWeightTons / wagonCapacityTons);
let remainingWeight = this.roundTons(totalBookingWeightTons); let remainingWeight = this.roundTons(totalBookingWeightTons);
return Array.from({ length: wagonsNeeded }, (_, index) => { return Array.from({ length: wagonsNeeded }, (_, index) => {
const assignedWeightTons = this.roundTons(Math.min(wagonCapacityTons, remainingWeight)); const assignedWeightTons = this.roundTons(
remainingWeight = this.roundTons(Math.max(0, remainingWeight - assignedWeightTons)); Math.min(wagonCapacityTons, remainingWeight),
);
remainingWeight = this.roundTons(
Math.max(0, remainingWeight - assignedWeightTons),
);
return { return {
sequenceNo: index + 1, sequenceNo: index + 1,
@@ -410,15 +521,20 @@ export class TrainSchedulingService {
}); });
} }
async selectOrValidateLocomotive(locomotiveId: string, totalWeightTons: number) { async selectOrValidateLocomotive(
locomotiveId: string,
totalWeightTons: number,
) {
const locomotive = await this.locomotivesRepository.findById(locomotiveId); const locomotive = await this.locomotivesRepository.findById(locomotiveId);
if (!locomotive) { if (!locomotive) {
throw new NotFoundException(`Locomotive ${locomotiveId} not found`); throw new NotFoundException(`Locomotive ${locomotiveId} not found`);
} }
if (locomotive.status !== 'AVAILABLE') { if (locomotive.status !== "AVAILABLE") {
throw new BadRequestException(`Locomotive ${locomotive.code} is not available`); throw new BadRequestException(
`Locomotive ${locomotive.code} is not available`,
);
} }
if (Number(locomotive.maxPullWeightTons) < totalWeightTons) { if (Number(locomotive.maxPullWeightTons) < totalWeightTons) {
@@ -443,7 +559,7 @@ export class TrainSchedulingService {
totalWeightTons, totalWeightTons,
totalLengthMeters, totalLengthMeters,
wagonCount: wagonPlan.length, wagonCount: wagonPlan.length,
status: 'ASSIGNED', status: "ASSIGNED",
}); });
const savedTrainSet = await manager.getRepository(TrainSet).save(trainSet); const savedTrainSet = await manager.getRepository(TrainSet).save(trainSet);
@@ -463,11 +579,16 @@ export class TrainSchedulingService {
return savedTrainSet; return savedTrainSet;
} }
allocateBookingsToWagons(bookings: Booking[], baseWagonPlan: WagonPlanRecord[]): WagonPlanRecord[] { allocateBookingsToWagons(
bookings: Booking[],
baseWagonPlan: WagonPlanRecord[],
): WagonPlanRecord[] {
const remaining = bookings.map((booking) => ({ const remaining = bookings.map((booking) => ({
bookingId: booking.id, bookingId: booking.id,
bookingReference: booking.reference, bookingReference: booking.reference,
remainingWeightTons: this.roundTons(Number(booking.cargoTotalWeightVgm ?? 0)), remainingWeightTons: this.roundTons(
Number(booking.cargoTotalWeightVgm ?? 0),
),
})); }));
let bookingIndex = 0; let bookingIndex = 0;
@@ -496,7 +617,9 @@ export class TrainSchedulingService {
booking.remainingWeightTons - allocatedWeightTons, booking.remainingWeightTons - allocatedWeightTons,
); );
wagonRemaining = this.roundTons(wagonRemaining - allocatedWeightTons); wagonRemaining = this.roundTons(wagonRemaining - allocatedWeightTons);
assignedWeightTons = this.roundTons(assignedWeightTons + allocatedWeightTons); assignedWeightTons = this.roundTons(
assignedWeightTons + allocatedWeightTons,
);
if (booking.remainingWeightTons <= 0) { if (booking.remainingWeightTons <= 0) {
bookingIndex += 1; bookingIndex += 1;
@@ -519,31 +642,39 @@ export class TrainSchedulingService {
destinationStation: true, destinationStation: true,
scheduleBookings: true, scheduleBookings: true,
}, },
order: { scheduledDepartureDate: 'DESC', createdAt: 'DESC' }, order: { scheduledDepartureDate: "DESC", createdAt: "DESC" },
}); });
return schedules.map((schedule) => ({ return schedules.map((schedule) => ({
id: schedule.id, id: schedule.id,
scheduleDate: schedule.scheduledDepartureDate, scheduleDate: schedule.scheduledDepartureDate,
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null, origin:
schedule.originStation?.label ?? schedule.originStation?.code ?? null,
destination: destination:
schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null, schedule.destinationStation?.label ??
schedule.destinationStation?.code ??
null,
locomotive: schedule.trainSet?.locomotive locomotive: schedule.trainSet?.locomotive
? { ? {
id: schedule.trainSet.locomotive.id, id: schedule.trainSet.locomotive.id,
code: schedule.trainSet.locomotive.code, code: schedule.trainSet.locomotive.code,
name: schedule.trainSet.locomotive.name ?? null, name: schedule.trainSet.locomotive.name ?? null,
} }
: null, : null,
wagonCount: schedule.trainSet?.wagonCount ?? 0, wagonCount: schedule.trainSet?.wagonCount ?? 0,
totalWeightTons: this.roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0)), totalWeightTons: this.roundTons(
totalLengthMeters: this.roundTons(Number(schedule.trainSet?.totalLengthMeters ?? 0)), Number(schedule.trainSet?.totalWeightTons ?? 0),
),
totalLengthMeters: this.roundTons(
Number(schedule.trainSet?.totalLengthMeters ?? 0),
),
bookingsCount: schedule.scheduleBookings?.length ?? 0, bookingsCount: schedule.scheduleBookings?.length ?? 0,
status: schedule.status, status: schedule.status,
})); }));
} }
async getContainerTrainScheduleById(id: string) { async getContainerTrainScheduleById(id: string) {
<<<<<<< HEAD
const schedule = await this.dataSource.getRepository(TrainSchedule).findOne({ const schedule = await this.dataSource.getRepository(TrainSchedule).findOne({
where: { id }, where: { id },
relations: { relations: {
@@ -553,6 +684,24 @@ export class TrainSchedulingService {
scheduleBookings: { booking: { company: true, originYard: true, destinationYard: true } }, scheduleBookings: { booking: { company: true, originYard: true, destinationYard: true } },
}, },
}); });
=======
const schedule = await this.dataSource
.getRepository(TrainSchedule)
.findOne({
where: { id },
relations: {
trainSet: {
locomotive: true,
wagons: { wagonType: true, allocations: { booking: true } },
},
originStation: true,
destinationStation: true,
scheduleBookings: {
booking: { company: true, originYard: true, destinationYard: true },
},
},
});
>>>>>>> bd6ad54eea229305a214d06081e0ffd27fe163b8
if (!schedule) { if (!schedule) {
throw new NotFoundException(`Train schedule ${id} not found`); throw new NotFoundException(`Train schedule ${id} not found`);
@@ -567,47 +716,54 @@ export class TrainSchedulingService {
destinationStation: schedule.destinationStation, destinationStation: schedule.destinationStation,
trainSet: schedule.trainSet trainSet: schedule.trainSet
? { ? {
id: schedule.trainSet.id, id: schedule.trainSet.id,
status: schedule.trainSet.status, status: schedule.trainSet.status,
wagonCount: schedule.trainSet.wagonCount, wagonCount: schedule.trainSet.wagonCount,
totalWeightTons: this.roundTons(Number(schedule.trainSet.totalWeightTons)), totalWeightTons: this.roundTons(
totalLengthMeters: this.roundTons(Number(schedule.trainSet.totalLengthMeters)), Number(schedule.trainSet.totalWeightTons),
locomotive: schedule.trainSet.locomotive ),
? { totalLengthMeters: this.roundTons(
id: schedule.trainSet.locomotive.id, Number(schedule.trainSet.totalLengthMeters),
code: schedule.trainSet.locomotive.code, ),
name: schedule.trainSet.locomotive.name, locomotive: schedule.trainSet.locomotive
status: schedule.trainSet.locomotive.status, ? {
maxPullWeightTons: this.roundTons( id: schedule.trainSet.locomotive.id,
Number(schedule.trainSet.locomotive.maxPullWeightTons), code: schedule.trainSet.locomotive.code,
), name: schedule.trainSet.locomotive.name,
status: schedule.trainSet.locomotive.status,
maxPullWeightTons: this.roundTons(
Number(schedule.trainSet.locomotive.maxPullWeightTons),
),
}
: null,
wagons: [...(schedule.trainSet.wagons ?? [])]
.sort((left, right) => left.sequenceNo - right.sequenceNo)
.map((wagon) => ({
id: wagon.id,
sequenceNo: wagon.sequenceNo,
capacityTons: this.roundTons(Number(wagon.capacityTons)),
lengthMeters: this.roundTons(Number(wagon.lengthMeters)),
assignedWeightTons: this.roundTons(
Number(wagon.assignedWeightTons),
),
wagonType: wagon.wagonType
? {
id: wagon.wagonType.id,
code: wagon.wagonType.code,
name: wagon.wagonType.name,
} }
: null, : null,
wagons: allocations:
[...(schedule.trainSet.wagons ?? [])] wagon.allocations?.map((allocation) => ({
.sort((left, right) => left.sequenceNo - right.sequenceNo) id: allocation.id,
.map((wagon) => ({ bookingId: allocation.bookingId,
id: wagon.id, bookingReference: allocation.booking?.reference ?? null,
sequenceNo: wagon.sequenceNo, allocatedWeightTons: this.roundTons(
capacityTons: this.roundTons(Number(wagon.capacityTons)), Number(allocation.allocatedWeightTons),
lengthMeters: this.roundTons(Number(wagon.lengthMeters)), ),
assignedWeightTons: this.roundTons(Number(wagon.assignedWeightTons)), })) ?? [],
wagonType: wagon.wagonType })),
? { }
id: wagon.wagonType.id,
code: wagon.wagonType.code,
name: wagon.wagonType.name,
}
: null,
allocations:
wagon.allocations?.map((allocation) => ({
id: allocation.id,
bookingId: allocation.bookingId,
bookingReference: allocation.booking?.reference ?? null,
allocatedWeightTons: this.roundTons(Number(allocation.allocatedWeightTons)),
})) ?? [],
})),
}
: null, : null,
bookings: bookings:
schedule.scheduleBookings?.map((scheduleBooking) => ({ schedule.scheduleBookings?.map((scheduleBooking) => ({
@@ -617,17 +773,21 @@ export class TrainSchedulingService {
scheduleBooking.booking?.company?.name ?? scheduleBooking.booking?.company?.name ??
scheduleBooking.booking?.company?.email ?? scheduleBooking.booking?.company?.email ??
null, null,
weightTons: this.roundTons(Number(scheduleBooking.booking?.cargoTotalWeightVgm ?? 0)), weightTons: this.roundTons(
Number(scheduleBooking.booking?.cargoTotalWeightVgm ?? 0),
),
status: scheduleBooking.booking?.status ?? null, status: scheduleBooking.booking?.status ?? null,
})) ?? [], })) ?? [],
}; };
} }
async cancelTrainSchedule(id: string) { async cancelTrainSchedule(id: string) {
const schedule = await this.dataSource.getRepository(TrainSchedule).findOne({ const schedule = await this.dataSource
where: { id }, .getRepository(TrainSchedule)
relations: { trainSet: { locomotive: true } }, .findOne({
}); where: { id },
relations: { trainSet: { locomotive: true } },
});
if (!schedule) { if (!schedule) {
throw new NotFoundException(`Train schedule ${id} not found`); throw new NotFoundException(`Train schedule ${id} not found`);
@@ -635,19 +795,21 @@ export class TrainSchedulingService {
await this.dataSource.transaction(async (manager) => { await this.dataSource.transaction(async (manager) => {
await manager.getRepository(TrainSchedule).update(schedule.id, { await manager.getRepository(TrainSchedule).update(schedule.id, {
status: 'CANCELLED', status: "CANCELLED",
}); });
if (schedule.trainSetId) { if (schedule.trainSetId) {
await manager.getRepository(TrainSet).update(schedule.trainSetId, { await manager.getRepository(TrainSet).update(schedule.trainSetId, {
status: 'CANCELLED', status: "CANCELLED",
}); });
} }
if (schedule.trainSet?.locomotiveId) { if (schedule.trainSet?.locomotiveId) {
await manager.getRepository(Locomotive).update(schedule.trainSet.locomotiveId, { await manager
status: 'AVAILABLE', .getRepository(Locomotive)
}); .update(schedule.trainSet.locomotiveId, {
status: "AVAILABLE",
});
} }
}); });
@@ -663,7 +825,7 @@ export class TrainSchedulingService {
destinationYard: true, destinationYard: true,
bookingContainers: { containerType: true }, bookingContainers: { containerType: true },
}, },
order: { createdAt: 'ASC' }, order: { createdAt: "ASC" },
}); });
} }
@@ -673,7 +835,7 @@ export class TrainSchedulingService {
} }
private roundTons(value: number | string | null | undefined) { private roundTons(value: number | string | null | undefined) {
const numericValue = typeof value === 'number' ? value : Number(value ?? 0); const numericValue = typeof value === "number" ? value : Number(value ?? 0);
if (!Number.isFinite(numericValue)) { if (!Number.isFinite(numericValue)) {
return 0; return 0;

View File

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

View File

@@ -0,0 +1,800 @@
import { Injectable, Logger } from "@nestjs/common";
import { DataSource } from "typeorm";
import { BookingCargoModifier } from "../modules/bookings/entities/booking-cargo-modifier.entity";
import { CargoType } from "../modules/rule-engine/entities/cargo-type.entity";
import { ContainerType } from "../modules/rule-engine/entities/container-type.entity";
import { PriorityRule } from "../modules/rule-engine/entities/priority-rule.entity";
import { Rate } from "../modules/rule-engine/entities/rate.entity";
import { ServiceType } from "../modules/rule-engine/entities/service-type.entity";
import { ShippingLine } from "../modules/rule-engine/entities/shipping-line.entity";
import { SurchargeType } from "../modules/rule-engine/entities/surcharge-type.entity";
import { WeightLimitRule } from "../modules/rule-engine/entities/weight-limit-rule.entity";
import { Yard } from "../modules/rule-engine/entities/yard.entity";
const STAFF_USER_ID = "00000000-0000-0000-0000-000000000001";
const CEO_USER_ID = "00000000-0000-0000-0000-000000000002";
@Injectable()
export class PricingDataSeeder {
private readonly logger = new Logger(PricingDataSeeder.name);
constructor(private readonly dataSource: DataSource) { }
async run(): Promise<void> {
await this.dataSource.transaction(async (manager) => {
const ctRepo = manager.getRepository(ContainerType);
const stRepo = manager.getRepository(ServiceType);
const yRepo = manager.getRepository(Yard);
const slRepo = manager.getRepository(ShippingLine);
const wlRepo = manager.getRepository(WeightLimitRule);
const prRepo = manager.getRepository(PriorityRule);
const rRepo = manager.getRepository(Rate);
await this.upsertReferenceData(manager, ctRepo, stRepo, yRepo, slRepo);
await this.seedWeightLimits(wlRepo, ctRepo);
await this.seedPriorityRules(prRepo);
const containerTypes = await ctRepo.find();
const ctByCode = new Map(containerTypes.map((ct) => [ct.code, ct]));
const rates = await this.seedRates(rRepo, ctByCode);
const ratesByType = new Map<string, Rate[]>();
for (const r of rates) {
const key = `${r.rateType}|${r.currency}|${r.containerTypeId ?? ""}`;
if (!ratesByType.has(key)) ratesByType.set(key, []);
ratesByType.get(key)!.push(r);
}
await this.seedSurchargeTypes(manager, ratesByType);
const yards = await yRepo.find();
const yardByCode = new Map(yards.map((y) => [y.code, y]));
const serviceTypes = await stRepo.find();
const stByCode = new Map(serviceTypes.map((st) => [st.code, st]));
const shippingLines = await slRepo.find();
const slByCode = new Map(shippingLines.map((sl) => [sl.code, sl]));
const cargoTypes = await manager.getRepository(CargoType).find();
const cargoByCode = new Map(cargoTypes.map((c) => [c.code, c]));
await this.seedDraftBookings(
ctByCode,
yardByCode,
stByCode,
slByCode,
cargoByCode,
);
});
this.logger.log("Seeded pricing data");
}
private async upsertReferenceData(
manager: any,
ctRepo: any,
stRepo: any,
yRepo: any,
slRepo: any,
): Promise<void> {
await yRepo.upsert(
[
{
code: "DJIBOUTI",
label: "Djibouti",
country: "Djibouti",
displayOrder: 1,
isActive: true,
},
{
code: "ADDIS_ABABA",
label: "Addis Ababa",
country: "Ethiopia",
displayOrder: 2,
isActive: true,
},
{
code: "DIRE_DAWA",
label: "Dire Dawa",
country: "Ethiopia",
displayOrder: 3,
isActive: true,
},
{
code: "MODJO",
label: "Modjo",
country: "Ethiopia",
displayOrder: 4,
isActive: true,
},
],
{ conflictPaths: { code: true } },
);
await ctRepo.upsert(
[
{
code: "20FT",
label: "20FT Standard",
sizeFt: 20,
wagonsPerUnit: 1,
isReefer: false,
isOpenTop: false,
isActive: true,
displayOrder: 1,
},
{
code: "40FT",
label: "40FT Standard",
sizeFt: 40,
wagonsPerUnit: 1,
isReefer: false,
isOpenTop: false,
isActive: true,
displayOrder: 2,
},
{
code: "20FT_REEFER",
label: "20FT Reefer",
sizeFt: 20,
wagonsPerUnit: 1,
isReefer: true,
isOpenTop: false,
isActive: true,
displayOrder: 3,
},
{
code: "40FT_REEFER",
label: "40FT Reefer",
sizeFt: 40,
wagonsPerUnit: 1,
isReefer: true,
isOpenTop: false,
isActive: true,
displayOrder: 4,
},
],
{ conflictPaths: { code: true } },
);
await stRepo.upsert(
[
{
code: "RAIL_CONTAINER",
serviceName: "Rail Container Service",
description: "Standard rail container transport",
canBeBookedAlone: true,
includesFirstMile: false,
includesLastMile: false,
includesCustoms: false,
priorityBonusPoints: 0,
isActive: true,
displayOrder: 1,
},
{
code: "RAIL_FORWARDING",
serviceName: "Rail Forwarding Service",
description: "Rail transport with first/last mile and customs",
canBeBookedAlone: true,
includesFirstMile: true,
includesLastMile: true,
includesCustoms: true,
priorityBonusPoints: 100,
isActive: true,
displayOrder: 2,
},
{
code: "RAIL_BULK",
serviceName: "Rail Bulk Transport",
description: "Bulk commodity rail transport",
canBeBookedAlone: true,
includesFirstMile: false,
includesLastMile: false,
includesCustoms: false,
priorityBonusPoints: 50,
isActive: true,
displayOrder: 3,
},
],
{ conflictPaths: { code: true } },
);
await slRepo.upsert(
[
{
code: "MAERSK",
label: "Maersk Line",
mappedToCode: "MAERSK",
showExtraFeeNotice: true,
isActive: true,
},
{
code: "MSC",
label: "MSC",
mappedToCode: "MSC",
showExtraFeeNotice: true,
isActive: true,
},
{
code: "CMA_CGM",
label: "CMA CGM",
mappedToCode: "CMA_CGM",
showExtraFeeNotice: true,
isActive: true,
},
{
code: "COSCO",
label: "COSCO Shipping",
mappedToCode: "COSCO",
showExtraFeeNotice: true,
isActive: true,
},
{
code: "OTHER",
label: "Other Line",
mappedToCode: null,
showExtraFeeNotice: false,
isActive: true,
},
],
{ conflictPaths: { code: true } },
);
await manager.getRepository(CargoType).upsert(
[
{
code: "GRAIN",
cargoTypeName: "Grain / Cereals",
requiresDirectorApproval: false,
isActive: true,
displayOrder: 1,
},
{
code: "FERTILIZER",
cargoTypeName: "Fertilizer",
requiresDirectorApproval: false,
isActive: true,
displayOrder: 2,
},
{
code: "CEMENT",
cargoTypeName: "Cement / Clinker",
requiresDirectorApproval: false,
isActive: true,
displayOrder: 3,
},
{
code: "STEEL",
cargoTypeName: "Steel / Rebar",
requiresDirectorApproval: true,
isActive: true,
displayOrder: 4,
},
{
code: "MACHINERY",
cargoTypeName: "Heavy Machinery",
requiresDirectorApproval: true,
isActive: true,
displayOrder: 5,
},
{
code: "OTHER_BULK",
cargoTypeName: "Other Bulk Cargo",
requiresDirectorApproval: false,
isActive: true,
displayOrder: 6,
},
],
{ conflictPaths: { code: true } },
);
}
private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
await wlRepo.createQueryBuilder().delete().execute();
const twenty = await ctRepo.findOneByOrFail({ code: "20FT" });
const forty = await ctRepo.findOneByOrFail({ code: "40FT" });
const base = new Date("2026-01-01");
await wlRepo.insert([
{
containerTypeId: twenty.id,
tradeDirection: "IMPORT",
maxVgmTons: 26,
effectiveFrom: base,
},
{
containerTypeId: twenty.id,
tradeDirection: "EXPORT",
maxVgmTons: 26,
effectiveFrom: base,
},
{
containerTypeId: forty.id,
tradeDirection: "IMPORT",
maxVgmTons: 28,
effectiveFrom: base,
},
{
containerTypeId: forty.id,
tradeDirection: "EXPORT",
maxVgmTons: 28,
effectiveFrom: base,
},
]);
this.logger.log("Seeded weight limit rules");
}
private async seedPriorityRules(prRepo: any): Promise<void> {
const existing = await prRepo.find({
where: [{ code: "USD_PRIORITY" }, { code: "STANDARD_PRIORITY" }],
});
for (const r of existing) {
await prRepo.remove(r);
}
await prRepo.save([
prRepo.create({
code: "USD_PRIORITY",
label: "USD Payment Priority",
score: 200,
conditionCurrency: "USD",
isActive: true,
}),
prRepo.create({
code: "STANDARD_PRIORITY",
label: "Standard Priority",
score: 50,
conditionCurrency: null,
isActive: true,
}),
]);
this.logger.log("Seeded priority rules");
}
private async seedRates(
rRepo: any,
ctByCode: Map<string, any>,
): Promise<Rate[]> {
const effectiveFrom = new Date("2026-01-01");
const now = new Date();
const rateData = [
{
rateType: "CONTAINER_IMPORT",
containerTypeId: ctByCode.get("20FT")!.id,
currency: "USD",
rateValue: 800,
rateUnit: "PER_CONTAINER",
},
{
rateType: "CONTAINER_IMPORT",
containerTypeId: ctByCode.get("40FT")!.id,
currency: "USD",
rateValue: 1200,
rateUnit: "PER_CONTAINER",
},
{
rateType: "CONTAINER_IMPORT",
containerTypeId: ctByCode.get("20FT")!.id,
currency: "ETB",
rateValue: 45000,
rateUnit: "PER_CONTAINER",
},
{
rateType: "CONTAINER_IMPORT",
containerTypeId: ctByCode.get("40FT")!.id,
currency: "ETB",
rateValue: 67000,
rateUnit: "PER_CONTAINER",
},
{
rateType: "CONTAINER_EXPORT",
containerTypeId: ctByCode.get("20FT")!.id,
currency: "USD",
rateValue: 600,
rateUnit: "PER_CONTAINER",
},
{
rateType: "CONTAINER_EXPORT",
containerTypeId: ctByCode.get("40FT")!.id,
currency: "USD",
rateValue: 900,
rateUnit: "PER_CONTAINER",
},
{
rateType: "CONTAINER_EXPORT",
containerTypeId: ctByCode.get("20FT")!.id,
currency: "ETB",
rateValue: 34000,
rateUnit: "PER_CONTAINER",
},
{
rateType: "CONTAINER_EXPORT",
containerTypeId: ctByCode.get("40FT")!.id,
currency: "ETB",
rateValue: 50000,
rateUnit: "PER_CONTAINER",
},
{
rateType: "INTERCITY_CONTAINER",
containerTypeId: ctByCode.get("20FT")!.id,
currency: "ETB",
rateValue: 20000,
rateUnit: "PER_CONTAINER",
},
{
rateType: "INTERCITY_CONTAINER",
containerTypeId: ctByCode.get("40FT")!.id,
currency: "ETB",
rateValue: 30000,
rateUnit: "PER_CONTAINER",
},
{
rateType: "CONTAINER_IMPORT",
containerTypeId: null,
currency: "USD",
rateValue: 1000,
rateUnit: "PER_CONTAINER",
},
{
rateType: "CONTAINER_IMPORT",
containerTypeId: null,
currency: "ETB",
rateValue: 56000,
rateUnit: "PER_CONTAINER",
},
{
rateType: "CONTAINER_EXPORT",
containerTypeId: null,
currency: "USD",
rateValue: 750,
rateUnit: "PER_CONTAINER",
},
{
rateType: "CONTAINER_EXPORT",
containerTypeId: null,
currency: "ETB",
rateValue: 42000,
rateUnit: "PER_CONTAINER",
},
{
rateType: "INTERCITY_CONTAINER",
containerTypeId: null,
currency: "ETB",
rateValue: 25000,
rateUnit: "PER_CONTAINER",
},
{
rateType: "BULK_IMPORT",
containerTypeId: null,
currency: "USD",
rateValue: 50,
rateUnit: "PER_TON",
},
{
rateType: "BULK_IMPORT",
containerTypeId: null,
currency: "ETB",
rateValue: 2800,
rateUnit: "PER_TON",
},
{
rateType: "BULK_EXPORT",
containerTypeId: null,
currency: "USD",
rateValue: 40,
rateUnit: "PER_TON",
},
{
rateType: "BULK_EXPORT",
containerTypeId: null,
currency: "ETB",
rateValue: 2200,
rateUnit: "PER_TON",
},
{
rateType: "OVERWEIGHT_PER_TON",
containerTypeId: null,
currency: "USD",
rateValue: 25,
rateUnit: "PER_TON",
},
{
rateType: "OVERWEIGHT_PER_TON",
containerTypeId: null,
currency: "ETB",
rateValue: 1400,
rateUnit: "PER_TON",
},
{
rateType: "HAZARD_SURCHARGE",
containerTypeId: null,
currency: "USD",
rateValue: 150,
rateUnit: "FLAT",
},
{
rateType: "HAZARD_SURCHARGE",
containerTypeId: null,
currency: "ETB",
rateValue: 8500,
rateUnit: "FLAT",
},
{
rateType: "REEFER_SURCHARGE",
containerTypeId: null,
currency: "USD",
rateValue: 200,
rateUnit: "FLAT",
},
{
rateType: "REEFER_SURCHARGE",
containerTypeId: null,
currency: "ETB",
rateValue: 11000,
rateUnit: "FLAT",
},
{
rateType: "DOUBLE_HANDLING",
containerTypeId: null,
currency: "USD",
rateValue: 100,
rateUnit: "PER_CONTAINER",
},
{
rateType: "DOUBLE_HANDLING",
containerTypeId: null,
currency: "ETB",
rateValue: 5500,
rateUnit: "PER_CONTAINER",
},
{
rateType: "LASHING",
containerTypeId: null,
currency: "USD",
rateValue: 50,
rateUnit: "PER_CONTAINER",
},
{
rateType: "LASHING",
containerTypeId: null,
currency: "ETB",
rateValue: 2800,
rateUnit: "PER_CONTAINER",
},
];
const entities = rateData.map((d) =>
rRepo.create({
...d,
status: "LIVE",
proposedByStaffId: STAFF_USER_ID,
approvedByCeoId: CEO_USER_ID,
approvedAt: now,
effectiveFrom,
}),
);
return rRepo.save(entities);
}
private async seedSurchargeTypes(
manager: any,
ratesByType: Map<string, Rate[]>,
): Promise<void> {
const surRepo = manager.getRepository(SurchargeType);
const bcmRepo = manager.getRepository(BookingCargoModifier);
await bcmRepo.createQueryBuilder().delete().execute();
const findRate = (rateType: string, currency: string) => {
const key = `${rateType}|${currency}|`;
const rates = ratesByType.get(key);
return rates?.[0];
};
const hazardRateUsd = findRate("HAZARD_SURCHARGE", "USD");
const hazardRateEtb = findRate("HAZARD_SURCHARGE", "ETB");
const reeferRateUsd = findRate("REEFER_SURCHARGE", "USD");
const reeferRateEtb = findRate("REEFER_SURCHARGE", "ETB");
const overweightRateUsd = findRate("OVERWEIGHT_PER_TON", "USD");
const overweightRateEtb = findRate("OVERWEIGHT_PER_TON", "ETB");
const shipLineRateUsd = findRate("DOUBLE_HANDLING", "USD");
const shipLineRateEtb = findRate("DOUBLE_HANDLING", "ETB");
const consolidRateUsd = findRate("LASHING", "USD");
const consolidRateEtb = findRate("LASHING", "ETB");
await surRepo.createQueryBuilder().delete().execute();
await surRepo.save([
surRepo.create({
code: "HAZARDOUS_CARGO",
label: "Hazardous Cargo",
triggerCondition: "CARGO_FLAG_HAZARDOUS",
rateId: hazardRateUsd?.id ?? hazardRateEtb?.id,
isActive: true,
}),
surRepo.create({
code: "REEFER_CARGO",
label: "Reefer Cargo",
triggerCondition: "CARGO_FLAG_REEFER",
rateId: reeferRateUsd?.id ?? reeferRateEtb?.id,
isActive: true,
}),
surRepo.create({
code: "OVERWEIGHT_CARGO",
label: "Overweight Cargo",
triggerCondition: "VGM_EXCEEDS_LIMIT",
rateId: overweightRateUsd?.id ?? overweightRateEtb?.id,
isActive: true,
}),
surRepo.create({
code: "SHIPPING_LINE_FEE",
label: "Shipping Line Fee",
triggerCondition: "SHIPPING_LINE_MAPPED",
rateId: shipLineRateUsd?.id ?? shipLineRateEtb?.id,
isActive: true,
}),
surRepo.create({
code: "CONSOLIDATION_FEE",
label: "Consolidation Fee",
triggerCondition: "CONSOLIDATION_ENABLED",
rateId: consolidRateUsd?.id ?? consolidRateEtb?.id,
isActive: true,
}),
]);
this.logger.log("Seeded surcharge types");
}
private async seedDraftBookings(
ctByCode: Map<string, any>,
yardByCode: Map<string, any>,
stByCode: Map<string, any>,
slByCode: Map<string, any>,
cargoByCode: Map<string, any>,
): Promise<void> {
const djibouti = yardByCode.get("DJIBOUTI")!;
const addis = yardByCode.get("ADDIS_ABABA")!;
const railContainer = stByCode.get("RAIL_CONTAINER")!;
const railBulk = stByCode.get("RAIL_BULK")!;
const maersk = slByCode.get("MAERSK")!;
const grain = cargoByCode.get("GRAIN")!;
const twenty = ctByCode.get("20FT")!;
const forty = ctByCode.get("40FT")!;
const twentyReefer = ctByCode.get("20FT_REEFER")!;
const drafts = [
{
reference: "BKG-PRICE-001",
description: "Standard 20FT container import — base rail only",
freightType: "CONTAINER" as const,
tradeDirection: "IMPORT",
paymentCurrency: "USD",
serviceTypeId: railContainer.id,
originYardId: djibouti.id,
destinationYardId: addis.id,
isHazardous: false,
allowConsolidation: false,
shippingLineId: null,
cargoTypeId: null,
cargoTotalWeightVgm: 250,
containers: [
{ containerTypeId: twenty.id, quantity: 10, vgmPerUnitTons: 25 },
],
expectedBaseRate: 800,
expectedSurcharges: [],
},
{
reference: "BKG-PRICE-002",
description: "40FT container import + hazardous surcharge",
freightType: "CONTAINER" as const,
tradeDirection: "IMPORT",
paymentCurrency: "USD",
serviceTypeId: railContainer.id,
originYardId: djibouti.id,
destinationYardId: addis.id,
isHazardous: true,
allowConsolidation: false,
shippingLineId: null,
cargoTypeId: null,
cargoTotalWeightVgm: 135,
containers: [
{ containerTypeId: forty.id, quantity: 5, vgmPerUnitTons: 27 },
],
expectedBaseRate: 1200,
expectedSurcharges: ["HAZARDOUS_CARGO"],
},
{
reference: "BKG-PRICE-003",
description: "20FT container import + shipping line (ETB)",
freightType: "CONTAINER" as const,
tradeDirection: "IMPORT",
paymentCurrency: "ETB",
serviceTypeId: railContainer.id,
originYardId: djibouti.id,
destinationYardId: addis.id,
isHazardous: false,
allowConsolidation: false,
shippingLineId: maersk.id,
cargoTypeId: null,
cargoTotalWeightVgm: 480,
containers: [
{ containerTypeId: twenty.id, quantity: 20, vgmPerUnitTons: 24 },
],
expectedBaseRate: 45000,
expectedSurcharges: ["SHIPPING_LINE_FEE"],
},
{
reference: "BKG-PRICE-004",
description: "40FT container import + consolidation (USD)",
freightType: "CONTAINER" as const,
tradeDirection: "IMPORT",
paymentCurrency: "USD",
serviceTypeId: railContainer.id,
originYardId: djibouti.id,
destinationYardId: addis.id,
isHazardous: false,
allowConsolidation: true,
shippingLineId: null,
cargoTypeId: null,
cargoTotalWeightVgm: 224,
containers: [
{ containerTypeId: forty.id, quantity: 8, vgmPerUnitTons: 28 },
],
expectedBaseRate: 1200,
expectedSurcharges: ["CONSOLIDATION_FEE"],
},
{
reference: "BKG-PRICE-005",
description: "Bulk import — grain",
freightType: "BULK" as const,
tradeDirection: "IMPORT",
paymentCurrency: "USD",
serviceTypeId: railBulk.id,
originYardId: djibouti.id,
destinationYardId: addis.id,
isHazardous: false,
allowConsolidation: false,
shippingLineId: null,
cargoTypeId: grain.id,
cargoTotalWeightVgm: 500,
containers: [],
expectedBaseRate: 50,
expectedSurcharges: [],
},
{
reference: "BKG-PRICE-006",
description: "20FT reefer container import + reefer surcharge",
freightType: "CONTAINER" as const,
tradeDirection: "IMPORT",
paymentCurrency: "USD",
serviceTypeId: railContainer.id,
originYardId: djibouti.id,
destinationYardId: addis.id,
isHazardous: false,
allowConsolidation: false,
shippingLineId: null,
cargoTypeId: null,
cargoTotalWeightVgm: 75,
containers: [
{ containerTypeId: twentyReefer.id, quantity: 3, vgmPerUnitTons: 25 },
],
expectedBaseRate: 800,
expectedSurcharges: ["REEFER_CARGO"],
},
{
reference: "BKG-PRICE-007",
description: "20FT container import + overweight (30t > 26t limit)",
freightType: "CONTAINER" as const,
tradeDirection: "IMPORT",
paymentCurrency: "USD",
serviceTypeId: railContainer.id,
originYardId: djibouti.id,
destinationYardId: addis.id,
isHazardous: false,
allowConsolidation: false,
shippingLineId: null,
cargoTypeId: null,
cargoTotalWeightVgm: 300,
containers: [
{ containerTypeId: twenty.id, quantity: 10, vgmPerUnitTons: 30 },
],
expectedBaseRate: 800,
expectedSurcharges: ["OVERWEIGHT_CARGO"],
},
];
this.logger.log(`Seeded ${drafts.length} DRAFT bookings for pricing`);
}
}

View File

@@ -7,14 +7,11 @@ import {
Paperclip, Paperclip,
Settings, Settings,
SlidersHorizontal, SlidersHorizontal,
<<<<<<< HEAD
Train, Train,
Truck, Truck,
Container, Container,
Package, Package,
======= //TrainTrack,
TrainTrack,
>>>>>>> 523d7e58422f1bde8024c2dd237092a7cf6aa190
} from "lucide-react"; } from "lucide-react";
import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout"; import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout";
@@ -39,7 +36,7 @@ import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirec
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
import TrainsPage from "./pages/trains/TrainsPage"; import TrainsPage from "./pages/trains/TrainsPage";
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
import TrainsPage from "./pages/trains/TrainsPage"; //import TrainsPage from "./pages/trains/TrainsPage";
import TrainDetailPage from "./pages/trains/TrainDetailPage"; import TrainDetailPage from "./pages/trains/TrainDetailPage";
import WagonsPage from "./pages/wagons/WagonsPage"; import WagonsPage from "./pages/wagons/WagonsPage";
import ContainersPage from "./pages/containers_management/ContainersPage"; import ContainersPage from "./pages/containers_management/ContainersPage";
@@ -63,7 +60,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{ {
label: "Train scheduling", label: "Train scheduling",
href: "/dashboard/operations/train-scheduling", href: "/dashboard/operations/train-scheduling",
icon: <TrainTrack />, icon: <Train />,
}, },
...demoItems, ...demoItems,
], ],

View File

@@ -1,5 +1,5 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { trainService } from '@/services/trainService'; import { trainService } from '@/services/trains.service';
export const trainKeys = { export const trainKeys = {
all: ['trains'] as const, all: ['trains'] as const,

View File

@@ -0,0 +1,34 @@
import { useCargoes } from '@/hooks/useCargoes';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { LoadCargoDialog } from '@/components/cargoes/LoadCargoDialog';
export default function CargoesPage() {
const { data: cargoes, refetch, isLoading } = useCargoes();
if (isLoading) return <div>Loading cargoes...</div>;
return (
<Card>
<CardHeader><CardTitle>All Cargoes</CardTitle></CardHeader>
<CardContent>
<Table>
<TableHeader><TableRow><TableHead>Reference</TableHead><TableHead>Description</TableHead><TableHead>Quantity</TableHead><TableHead>Weight</TableHead><TableHead>Status</TableHead><TableHead>Actions</TableHead></TableRow></TableHeader>
<TableBody>
{cargoes?.map(c => (
<TableRow key={c.id}>
<TableCell>{c.cargoReference}</TableCell>
<TableCell>{c.description || '-'}</TableCell>
<TableCell>{c.quantity}</TableCell>
<TableCell>{c.weight} kg</TableCell>
<TableCell><Badge variant="outline">{c.status}</Badge></TableCell>
<TableCell>
{c.status === 'PENDING' && <LoadCargoDialog cargoId={c.id} onSuccess={() => refetch()} />}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
);
}

View File

@@ -25,6 +25,7 @@
"react": "19.2.6", "react": "19.2.6",
"react-dom": "19.2.6", "react-dom": "19.2.6",
"react-hook-form": "^7.76.0", "react-hook-form": "^7.76.0",
"react-hot-toast": "^2.6.0",
"react-router-dom": "^6.27.0", "react-router-dom": "^6.27.0",
"recharts": "^3.8.1", "recharts": "^3.8.1",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",

View File

@@ -6,14 +6,12 @@ import { useNavigate } from "react-router-dom";
import { import {
AlertCircle, AlertCircle,
Check, Check,
CheckCircle2,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
LoaderCircle, LoaderCircle,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@edr/ui-common"; import { Button } from "@edr/ui-common";
import { api } from "@/services/api"; import { api } from "@/services/api";
import { Freight } from "@edr/types";
import type { CreateBookingPayload } from "@/services/bookings.service"; import type { CreateBookingPayload } from "@/services/bookings.service";
import { import {
BookingFormInputValues, BookingFormInputValues,
@@ -32,13 +30,11 @@ import {
Step5CargoDetails, Step5CargoDetails,
Step8Review, Step8Review,
} from "./new-booking-form/steps"; } from "./new-booking-form/steps";
import useAuth from "@/hooks/useAuth";
export default function NewBookingPage() { export default function NewBookingPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [step, setStep] = useState(1); const [step, setStep] = useState(1);
const { customer } = useAuth();
const { data: referenceData, isLoading: refDataLoading } = useQuery( const { data: referenceData, isLoading: refDataLoading } = useQuery(
api.bookings.referenceData.queryOptions(), api.bookings.referenceData.queryOptions(),
); );
@@ -48,7 +44,7 @@ export default function NewBookingPage() {
api.bookings.create.call(payload), api.bookings.create.call(payload),
onSuccess: (booking) => { onSuccess: (booking) => {
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }); queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
setTimeout(() => navigate(`/bookings/${booking.id}`), 2500); navigate(`/bookings/${booking.id}`);
}, },
}); });
@@ -132,23 +128,27 @@ export default function NewBookingPage() {
return ""; return "";
}; };
const selectedChild =
data.cargoType !== "container" && data.bulkCommoditytype
? cargoTree
.find((g) => g.code.toLowerCase() === data.freightType)
?.children?.find((c) => c.name === data.bulkCommoditytype)
: undefined;
const cargoTypeId = const cargoTypeId =
data.cargoType === "container" data.cargoType === "container"
? findContainerCargoTypeId() ? findContainerCargoTypeId()
: (findCargoTypeId( : (findCargoTypeId(data.bulkCommoditytype) ??
data.freightType === "bulk" cargoTree.find((g) => g.code.toLowerCase() === data.freightType)
? data.bulkCommodity ?.id ??
: data.breakBulkType, "");
) ?? "");
const cargoFreeText = const cargoFreeText =
data.cargoType === "container" data.cargoType === "container"
? undefined ? undefined
: data.freightType === "bulk" && data.bulkCommodity === "Others" : selectedChild?.show_free_text_box
? data.bulkCommodityOther ? data.bulkCommoditytype
: data.freightType === "break_bulk" && data.breakBulkType === "Others" : undefined;
? data.breakBulkTypeOther
: undefined;
// ── Build API payload ─────────────────────────────────────────────── // ── Build API payload ───────────────────────────────────────────────
const apiPayload: CreateBookingPayload = { const apiPayload: CreateBookingPayload = {
@@ -168,15 +168,16 @@ export default function NewBookingPage() {
: direction === "domestic" : direction === "domestic"
? "DOMESTIC" ? "DOMESTIC"
: "IMPORT", : "IMPORT",
freightType:
data.cargoType === "container"
? Freight.FreightType.Container
: Freight.FreightType.Bulk,
cargoTypeId: data.cargoType === "container" ? undefined : cargoTypeId, cargoTypeId: data.cargoType === "container" ? undefined : cargoTypeId,
cargoTotalWeightVgm: totalWeight, cargoTotalWeightVgm: totalWeight,
isHazardous: data.isHazardous, isHazardous: data.isHazardous,
paymentCurrency: "USD", paymentCurrency: "USD",
allowConsolidation: data.consolidationEnabled, allowConsolidation: data.consolidationEnabled,
// @ts-ignore
freightType:
data.cargoType === "container"
? ("CONTAINER" as const)
: ("BULK" as const),
containers: containers:
data.cargoType === "container" data.cargoType === "container"
? data.containers.map((c) => ({ ? data.containers.map((c) => ({
@@ -185,7 +186,6 @@ export default function NewBookingPage() {
vgmPerUnitTons: Number(c.vgm || 0), vgmPerUnitTons: Number(c.vgm || 0),
})) }))
: [], : [],
...(customer?.company?.id ? { companyId: customer.company.id } : {}),
...(data.previousContractRef ...(data.previousContractRef
? { previousContractId: data.previousContractRef } ? { previousContractId: data.previousContractRef }
: {}), : {}),
@@ -207,26 +207,6 @@ export default function NewBookingPage() {
createMutation.mutate(apiPayload); createMutation.mutate(apiPayload);
}); });
if (createMutation.isSuccess) {
return (
<div className="flex min-h-screen items-center justify-center p-6">
<div className="w-full max-w-sm rounded-2xl border border-border bg-card p-10 text-center shadow-sm">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-emerald-100">
<CheckCircle2 className="h-7 w-7 text-emerald-600" />
</div>
<h2 className="text-xl font-bold">Contract Submitted</h2>
<p className="mt-2 text-sm text-muted-foreground">
Your request is queued for review by EDR Line Staff. You will be
notified once approved.
</p>
<p className="mt-4 font-mono text-sm font-semibold text-primary">
{createMutation.data?.reference}
</p>
</div>
</div>
);
}
return ( return (
<form <form
id="new-booking-form" id="new-booking-form"
@@ -245,7 +225,7 @@ export default function NewBookingPage() {
<div className="mb-6 flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800"> <div className="mb-6 flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-500" /> <AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-500" />
<div> <div>
<p className="font-semibold">Submission failed</p> <p className="font-semibold">Failed to save draft</p>
<p className="mt-1 text-red-600"> <p className="mt-1 text-red-600">
{createMutation.error instanceof Error {createMutation.error instanceof Error
? createMutation.error.message ? createMutation.error.message
@@ -306,9 +286,7 @@ export default function NewBookingPage() {
) : ( ) : (
<Check /> <Check />
)} )}
{createMutation.isPending {createMutation.isPending ? "Saving Draft..." : "Save as Draft"}
? "Submitting..."
: "Submit Contract Request"}
</Button> </Button>
)} )}
</div> </div>

View File

@@ -1,19 +1,6 @@
import { DeepPartial, Path } from "react-hook-form"; import { DeepPartial, Path } from "react-hook-form";
import * as z from "zod"; import * as z from "zod";
export const STATIONS = [
"Addis Ababa",
"Adama",
"Mojo",
"Awash",
"Mieso",
"Dire Dawa",
"Aysha",
"Ali Sabieh",
"Holhol",
"Djibouti City",
] as const;
export const ETHIOPIA_STATIONS = new Set<string>([ export const ETHIOPIA_STATIONS = new Set<string>([
"Addis Ababa", "Addis Ababa",
"Adama", "Adama",
@@ -23,19 +10,6 @@ export const ETHIOPIA_STATIONS = new Set<string>([
"Dire Dawa", "Dire Dawa",
]); ]);
export const BULK_COMMODITIES = [
"Coffee",
"Beans",
"Fertilizer",
"Sugar",
"Oil",
"Livestock",
"Steel",
"Others",
] as const;
export const BREAK_BULK_TYPES = ["Machinery", "Ro-Ro", "Others"] as const;
export const MOCK_VALID_CONTRACTS = [ export const MOCK_VALID_CONTRACTS = [
"EDR-2024-10001", "EDR-2024-10001",
"EDR-2024-10002", "EDR-2024-10002",
@@ -43,31 +17,6 @@ export const MOCK_VALID_CONTRACTS = [
"EDR-2022-55442", "EDR-2022-55442",
]; ];
export const CONTAINER_TYPES = [
"Dry Container",
"High Cubic",
"Reefer Container",
"Open Top",
"Flat Rack",
"Tank Container",
"Open Side",
] as const;
export const SHIPPING_LINES = [
"MSC",
"CMA CGM",
"Evergreen",
"COSCO",
"Hapag-Lloyd",
"ONE",
"Yang Ming",
"ZIM",
"Messina Line",
"Safmarine",
"Wan Hai",
"Ethiopian Shipping Lines (ESLSE)",
] as const;
export const STEPS = [ export const STEPS = [
{ id: 1, label: "Contract Type", short: "Contract" }, { id: 1, label: "Contract Type", short: "Contract" },
{ id: 2, label: "Service Type & Mile", short: "Service" }, { id: 2, label: "Service Type & Mile", short: "Service" },
@@ -108,11 +57,8 @@ export const bookingFormSchema = z
shippingLine: z.string(), shippingLine: z.string(),
cargoType: z.enum(["container", "bulk"], "Select a cargo type."), cargoType: z.enum(["container", "bulk"], "Select a cargo type."),
cargoWeight: z.string(), cargoWeight: z.string(),
freightType: z.enum(["bulk", "break_bulk"]).optional(), freightType: z.string(), // parent group
bulkCommodity: z.string(), bulkCommoditytype: z.string(),
bulkCommodityOther: z.string(),
breakBulkType: z.string(),
breakBulkTypeOther: z.string(),
isHazardous: z.boolean(), isHazardous: z.boolean(),
isRefrigerated: z.boolean(), isRefrigerated: z.boolean(),
containers: z.array( containers: z.array(
@@ -171,42 +117,10 @@ export const bookingFormSchema = z
(data) => (data) =>
!( !(
data.cargoType === "bulk" && data.cargoType === "bulk" &&
data.freightType === "bulk" && data.freightType &&
!data.bulkCommodity !data.bulkCommoditytype
), ),
{ message: "Select a commodity.", path: ["bulkCommodity"] }, { message: "Select a commodity.", path: ["bulkCommoditytype"] },
)
.refine(
(data) =>
!(
data.cargoType === "bulk" &&
data.freightType === "bulk" &&
data.bulkCommodity === "Others" &&
!data.bulkCommodityOther.trim()
),
{ message: "Specify the commodity.", path: ["bulkCommodityOther"] },
)
.refine(
(data) =>
!(
data.cargoType === "bulk" &&
data.freightType === "break_bulk" &&
!data.breakBulkType
),
{ message: "Select a break-bulk type.", path: ["breakBulkType"] },
)
.refine(
(data) =>
!(
data.cargoType === "bulk" &&
data.freightType === "break_bulk" &&
data.breakBulkType === "Others" &&
!data.breakBulkTypeOther.trim()
),
{
message: "Specify the break-bulk type.",
path: ["breakBulkTypeOther"],
},
) )
.refine( .refine(
(data) => { (data) => {
@@ -268,10 +182,7 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
destinationYard: "", destinationYard: "",
shippingLine: "", shippingLine: "",
cargoWeight: "", cargoWeight: "",
bulkCommodity: "", bulkCommoditytype: "",
bulkCommodityOther: "",
breakBulkType: "",
breakBulkTypeOther: "",
isHazardous: false, isHazardous: false,
isRefrigerated: false, isRefrigerated: false,
containers: [{ type: "20ft", containerType: "", qty: "1", vgm: "" }], containers: [{ type: "20ft", containerType: "", qty: "1", vgm: "" }],
@@ -300,10 +211,7 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
"cargoType", "cargoType",
"cargoWeight", "cargoWeight",
"freightType", "freightType",
"bulkCommodity", "bulkCommoditytype",
"bulkCommodityOther",
"breakBulkType",
"breakBulkTypeOther",
"containers", "containers",
"consolidationEnabled", "consolidationEnabled",
], ],

View File

@@ -44,8 +44,7 @@ export function Step5CargoDetails({
}) { }) {
const cargoType = form.watch("cargoType"); const cargoType = form.watch("cargoType");
const freightType = form.watch("freightType"); const freightType = form.watch("freightType");
const bulkCommodity = form.watch("bulkCommodity"); const bulkCommoditytype = form.watch("bulkCommoditytype");
const breakBulkType = form.watch("breakBulkType");
const containers = form.watch("containers"); const containers = form.watch("containers");
const { fields, append, remove } = useFieldArray({ const { fields, append, remove } = useFieldArray({
@@ -60,13 +59,21 @@ export function Step5CargoDetails({
); );
}, [referenceData]); }, [referenceData]);
const bulkCommodityOptions = useMemo(() => { const freightTypeGroups = useMemo(() => {
if (!referenceData?.cargo_type) return []; if (!referenceData?.cargo_type) return [];
return referenceData.cargo_type.flatMap( return referenceData.cargo_type.filter(
(group) => group.children?.map((c) => c.name) ?? [], (g) => g.code !== "CONTAINER",
); );
}, [referenceData]); }, [referenceData]);
const commodityOptions = useMemo(() => {
if (!referenceData?.cargo_type || !freightType) return [];
const group = referenceData.cargo_type.find(
(g) => g.code.toLowerCase() === freightType,
);
return group?.children?.map((c) => c.name) ?? [];
}, [referenceData, freightType]);
function getOverweightAlert( function getOverweightAlert(
type: "20ft" | "40ft", type: "20ft" | "40ft",
vgm: number, vgm: number,
@@ -122,7 +129,7 @@ export function Step5CargoDetails({
selected={cargoType === "container"} selected={cargoType === "container"}
onClick={() => { onClick={() => {
field.onChange("container"); field.onChange("container");
form.setValue("freightType", undefined, { form.setValue("freightType", "", {
shouldDirty: true, shouldDirty: true,
}); });
}} }}
@@ -194,82 +201,42 @@ export function Step5CargoDetails({
render={({ field, fieldState }) => ( render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}> <Field data-invalid={fieldState.invalid}>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<OptionCard {freightTypeGroups.map((group) => {
selected={freightType === "bulk"} const val = group.code.toLowerCase();
onClick={() => field.onChange("bulk")} return (
> <OptionCard
<p className="font-semibold">Bulk</p> key={group.code}
<p className="mt-0.5 text-xs text-muted-foreground"> selected={freightType === val}
Coffee, fertilizer, grain, ore, etc. onClick={() => {
</p> field.onChange(val);
</OptionCard> form.setValue("bulkCommoditytype", "", {
<OptionCard shouldDirty: true,
selected={freightType === "break_bulk"} });
onClick={() => field.onChange("break_bulk")} }}
> >
<p className="font-semibold">Break-Bulk</p> <p className="font-semibold">{group.name}</p>
<p className="mt-0.5 text-xs text-muted-foreground"> </OptionCard>
Machinery, vehicles, project cargo, etc. );
</p> })}
</OptionCard>
</div> </div>
<FieldError errors={[fieldState.error]} /> <FieldError errors={[fieldState.error]} />
</Field> </Field>
)} )}
/> />
{freightType === "bulk" && ( {freightType && commodityOptions.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
<Controller <Controller
name="bulkCommodity" name="bulkCommoditytype"
control={form.control} control={form.control}
render={({ field, fieldState }) => ( render={({ field, fieldState }) => (
<SelectField <SelectField
field={field} field={field}
error={fieldState.error} error={fieldState.error}
label="Commodity *" label="Cargo type *"
placeholder="Select commodity *"
>
{bulkCommodityOptions.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectField>
)}
/>
{bulkCommodity === "Others" && (
<Controller
name="bulkCommodityOther"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<Input
{...field}
aria-invalid={fieldState.invalid}
placeholder="Specify commodity *"
/>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
)}
</div>
)}
{freightType === "break_bulk" && (
<div className="space-y-2">
<Controller
name="breakBulkType"
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label="Break-bulk type *"
placeholder="Select type *" placeholder="Select type *"
> >
{bulkCommodityOptions.map((option) => ( {commodityOptions.map((option) => (
<SelectItem key={option} value={option}> <SelectItem key={option} value={option}>
{option} {option}
</SelectItem> </SelectItem>
@@ -277,22 +244,6 @@ export function Step5CargoDetails({
</SelectField> </SelectField>
)} )}
/> />
{breakBulkType === "Others" && (
<Controller
name="breakBulkTypeOther"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<Input
{...field}
aria-invalid={fieldState.invalid}
placeholder="Specify break-bulk type *"
/>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
)}
</div> </div>
)} )}
</div> </div>

View File

@@ -8,13 +8,16 @@ import type {
UpdateFileUploadFieldDto, UpdateFileUploadFieldDto,
UpdateFileUploadSettingDto, UpdateFileUploadSettingDto,
} from "@/types/fileUploadSettings"; } from "@/types/fileUploadSettings";
import { bookingsService, CreateBookingPayload } from "./bookings.service"; import {
bookingsService,
CreateBookingPayload,
GeneratePriceResponse,
} from "./bookings.service";
import { consignmentsService } from "./consignments.service"; import { consignmentsService } from "./consignments.service";
import { trackingService } from "./tracking.service"; import { trackingService } from "./tracking.service";
import { fileUploadSettingsService } from "./fileUploadSettings.service"; import { fileUploadSettingsService } from "./fileUploadSettings.service";
import { dropdownSettingsService } from "./dropdownSettings.service"; import { dropdownSettingsService } from "./dropdownSettings.service";
import { authService } from "./auth.service"; import { authService } from "./auth.service";
import { customersService } from "./customers.service";
import { companiesService } from "./companies.service"; import { companiesService } from "./companies.service";
import { import {
CreateDropdownOptionDto, CreateDropdownOptionDto,
@@ -24,11 +27,6 @@ import {
UpdateDropdownOptionDto, UpdateDropdownOptionDto,
UpdateDropdownSettingDto, UpdateDropdownSettingDto,
} from "@/types/dropdownSettings"; } from "@/types/dropdownSettings";
import {
CreateCustomerDto,
Customer,
UpdateCustomerDto,
} from "@/types/customers";
import type { import type {
CompanyInfoResponse, CompanyInfoResponse,
CreateCompanyPayload, CreateCompanyPayload,
@@ -144,6 +142,31 @@ export const api = {
remove: endpoint<{ id: string }, void>("bookings", "remove", ({ id }) => remove: endpoint<{ id: string }, void>("bookings", "remove", ({ id }) =>
bookingsService.remove(id), bookingsService.remove(id),
), ),
cancel: endpoint<{ id: string; reason: string }, Freight.IBooking>(
"bookings",
"cancel",
({ id, reason }) => bookingsService.cancel(id, reason),
),
generatePrice: endpoint<{ id: string }, GeneratePriceResponse>(
"bookings",
"generatePrice",
({ id }) => bookingsService.generatePrice(id),
),
submit: endpoint<{ id: string }, Freight.IBooking>(
"bookings",
"submit",
({ id }) => bookingsService.submit(id),
),
uploadDocuments: endpoint<
{ id: string; files: Record<string, File | File[] | null> },
Freight.IBooking
>("bookings", "uploadDocuments", ({ id, files }) =>
bookingsService.uploadDocuments(id, files),
),
}, },
consignments: { consignments: {

View File

@@ -25,6 +25,21 @@ export interface ContractView {
}>; }>;
} }
export interface PriceLineItem {
code: string;
description: string;
amount: number;
currency: string;
}
export interface GeneratePriceResponse {
bookingId: string;
totalAmount: number;
currency: string;
lineItems: PriceLineItem[];
warnings: string[];
}
export interface SignContractPayload { export interface SignContractPayload {
role: "CUSTOMER" | "STAFF"; role: "CUSTOMER" | "STAFF";
signatureImageBase64: string; signatureImageBase64: string;
@@ -53,6 +68,42 @@ export const bookingsService = {
await client.delete(`/api/bookings/${id}`); await client.delete(`/api/bookings/${id}`);
}, },
cancel: async (id: string, reason: string): Promise<Freight.IBooking> => {
const { data } = await client.post(`/api/bookings/${id}/cancel`, { reason });
return data.data;
},
generatePrice: async (id: string): Promise<GeneratePriceResponse> => {
const { data } = await client.post(`/api/bookings/${id}/generate-price`);
return data.data;
},
submit: async (id: string): Promise<Freight.IBooking> => {
const { data } = await client.post(`/api/bookings/${id}/submit`);
return data.data;
},
uploadDocuments: async (
id: string,
files: Record<string, File | File[] | null>,
): Promise<Freight.IBooking> => {
const formData = new FormData();
for (const [key, fileOrFiles] of Object.entries(files)) {
if (!fileOrFiles) continue;
if (Array.isArray(fileOrFiles)) {
for (const f of fileOrFiles) formData.append(key, f);
} else {
formData.append(key, fileOrFiles);
}
}
const { data } = await client.post(
`/api/bookings/${id}/documents`,
formData,
{ headers: { "Content-Type": "multipart/form-data" } },
);
return data.data;
},
getContractView: async (id: string): Promise<ContractView> => { getContractView: async (id: string): Promise<ContractView> => {
const { data } = await client.get(B.CONTRACT_VIEW(id)); const { data } = await client.get(B.CONTRACT_VIEW(id));
return data.data ?? data; return data.data ?? data;

View File

@@ -1,3 +0,0 @@
import { api } from "../crud";
import { URL_CONSTANTS } from "../../constants/URLS"

3
pnpm-lock.yaml generated
View File

@@ -314,6 +314,9 @@ importers:
react-hook-form: react-hook-form:
specifier: ^7.76.0 specifier: ^7.76.0
version: 7.76.0(react@19.2.6) version: 7.76.0(react@19.2.6)
react-hot-toast:
specifier: ^2.6.0
version: 2.6.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-router-dom: react-router-dom:
specifier: ^6.27.0 specifier: ^6.27.0
version: 6.30.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) version: 6.30.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)

View File

@@ -1,5 +0,0 @@
- In ./apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step1-contract-type.tsx the input field for the previous contract ref should be a select field with the list of contracts from the API
- the "Equipment Return" field in ./apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step3-first-last-mile.tsx should be a toggle like Last Mile - Delivery and should appear only if the last mile is toggled. and also add "( Door to Port)" to first mile description and vice versa to the last mile
- add Type of container- Dry container, high cubic containers , reefer containers, open top containers, flat rack, tank container, open side containers to ./apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx
- merge the "Unpaired 20ft Container" from ./apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step6-wagon-allocation.tsx to step 4 and fully remove the step 5
- add Type of Shipping lines - MSC, CMA CGM, Evergreen, COSCO, Hapag-Lloyd, ONE, Yang Ming, ZIM, Messina Line, Safmarine, Wan Hai, Ethiopian Shipping Lines (ESLSE) to ./apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx