diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index fff9ac3b5..42b5b4dae 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -36,6 +36,7 @@ import { import { EdrOrgSeeder } from "./seed/edr-org.seeder"; import { DemoUsersSeeder } from "./seed/demo-users.seeder"; import { DemoBookingsSeeder } from "./seed/demo-bookings.seeder"; +import { PricingDataSeeder } from "./seed/pricing-data.seeder"; import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder"; @Module({ @@ -83,7 +84,7 @@ import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder"; BackofficeModule, DemoPermissionsModule, ], - providers: [EdrOrgSeeder, DemoUsersSeeder, DemoBookingsSeeder, FileUploadSettingsSeeder], + providers: [EdrOrgSeeder, DemoUsersSeeder, DemoBookingsSeeder, PricingDataSeeder, FileUploadSettingsSeeder], }) export class AppModule implements OnApplicationBootstrap { constructor( @@ -91,6 +92,7 @@ export class AppModule implements OnApplicationBootstrap { private readonly edrOrgSeeder: EdrOrgSeeder, private readonly demoUsersSeeder: DemoUsersSeeder, private readonly demoBookingsSeeder: DemoBookingsSeeder, + private readonly pricingDataSeeder: PricingDataSeeder, private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder, ) { } @@ -99,6 +101,7 @@ export class AppModule implements OnApplicationBootstrap { await this.edrOrgSeeder.run(); await this.demoUsersSeeder.run(); await this.demoBookingsSeeder.run(); + await this.pricingDataSeeder.run(); await this.fileUploadSettingsSeeder.run(); } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 2a337afef..34c4a7843 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -3,25 +3,28 @@ import { ConflictException, Injectable, NotFoundException, -} from '@nestjs/common'; -import { InjectDataSource } from '@nestjs/typeorm'; -import { DataSource, EntityManager, In } from 'typeorm'; +} from "@nestjs/common"; +import { InjectDataSource } from "@nestjs/typeorm"; +import { DataSource, EntityManager, In } from "typeorm"; -import { Booking } from '../bookings/entities/booking.entity'; -import { Locomotive, type LocomotiveStatus } from '../locomotives/entities/locomotive.entity'; -import { LocomotivesRepository } from '../locomotives/locomotives.repository'; -import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; -import { TrainSet } from '../train-sets/entities/train-set.entity'; -import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; -import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; -import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity'; -import { WagonType } from '../wagon-types/entities/wagon-type.entity'; -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'; +import { Booking } from "../bookings/entities/booking.entity"; +import { + Locomotive, + type LocomotiveStatus, +} from "../locomotives/entities/locomotive.entity"; +import { LocomotivesRepository } from "../locomotives/locomotives.repository"; +import { TrainSetWagon } from "../train-sets/entities/train-set-wagon.entity"; +import { TrainSet } from "../train-sets/entities/train-set.entity"; +import { TrainScheduleBooking } from "../train-schedules/entities/train-schedule-booking.entity"; +import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity"; +import { WagonBookingAllocation } from "../train-schedules/entities/wagon-booking-allocation.entity"; +import { WagonType } from "../wagon-types/entities/wagon-type.entity"; +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_LENGTH_METERS = 760; @@ -74,31 +77,38 @@ export class TrainSchedulingService { private readonly dataSource: DataSource, private readonly locomotivesRepository: LocomotivesRepository, private readonly wagonTypesRepository: WagonTypesRepository, - ) {} + ) { } async getEligibleContainerBookings(query: GetEligibleContainerBookingsDto) { const bookingRepository = this.dataSource.getRepository(Booking); const queryBuilder = bookingRepository - .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'); + .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"); if (query.originStationId) { - queryBuilder.andWhere('booking.originYardId = :originStationId', { + queryBuilder.andWhere("booking.originYardId = :originStationId", { originStationId: query.originStationId, }); } if (query.destinationStationId) { - queryBuilder.andWhere('booking.destinationYardId = :destinationStationId', { - destinationStationId: query.destinationStationId, - }); + queryBuilder.andWhere( + "booking.destinationYardId = :destinationStationId", + { + destinationStationId: query.destinationStationId, + }, + ); } if (query.scheduleDate) { @@ -109,25 +119,44 @@ export class TrainSchedulingService { } if (query.status) { - queryBuilder.andWhere('booking.status = :status', { status: query.status }); + queryBuilder.andWhere("booking.status = :status", { + status: query.status, + }); } const bookings = await queryBuilder - .orderBy('booking.scheduled_date', 'ASC') - .addOrderBy('booking.created_at', 'ASC') + .orderBy("booking.scheduled_date", "ASC") + .addOrderBy("booking.created_at", "ASC") .getMany(); const items: EligibleBookingItem[] = bookings.map((booking) => ({ id: booking.id, reference: booking.reference, - customer: booking.customer?.companyName ?? booking.customer?.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, + 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, weightTons: this.roundTons(booking.cargoTotalWeightVgm), - origin: booking.originYard?.label ?? booking.originYard?.code ?? 'Unknown origin', - destination: booking.destinationYard?.label ?? booking.destinationYard?.code ?? 'Unknown destination', + origin: + booking.originYard?.label ?? + booking.originYard?.code ?? + "Unknown origin", + destination: + booking.destinationYard?.label ?? + booking.destinationYard?.code ?? + "Unknown destination", preferredDepartureDate: booking.scheduledDate.toISOString(), status: booking.status, })); @@ -155,7 +184,7 @@ export class TrainSchedulingService { if (!validation.valid) { throw new BadRequestException({ - message: 'train_schedule_invalid', + message: "train_schedule_invalid", violations: validation.violations, }); } @@ -165,92 +194,115 @@ export class TrainSchedulingService { validation.summary.totalWeightTons, ); - const createdSchedule = await this.dataSource.transaction(async (manager) => { - const locomotiveRepository = manager.getRepository(Locomotive); - const lockedLocomotive = await locomotiveRepository.findOne({ - where: { id: locomotive.id }, - lock: { mode: 'pessimistic_write' }, - }); + const createdSchedule = await this.dataSource.transaction( + async (manager) => { + const locomotiveRepository = manager.getRepository(Locomotive); + const lockedLocomotive = await locomotiveRepository.findOne({ + where: { id: locomotive.id }, + lock: { mode: "pessimistic_write" }, + }); - if (!lockedLocomotive) { - 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}`); + if (!lockedLocomotive) { + throw new NotFoundException(`Locomotive ${locomotive.id} not found`); } - return wagonPlan.allocations.map((allocation) => - manager.getRepository(WagonBookingAllocation).create({ - trainSetWagonId: wagon.id, - bookingId: allocation.bookingId, - allocatedWeightTons: allocation.allocatedWeightTons, + 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); - 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, { - status: 'ASSIGNED', - }); + const wagonBySequence = new Map( + 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); } @@ -261,7 +313,7 @@ export class TrainSchedulingService { const bookingIds = [...new Set(dto.bookingIds)]; 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({ @@ -269,7 +321,9 @@ export class TrainSchedulingService { }); 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); @@ -278,21 +332,29 @@ export class TrainSchedulingService { if (bookings.length !== bookingIds.length) { const foundIds = new Set(bookings.map((booking) => booking.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({ - where: { bookingId: In(bookingIds) }, - select: { bookingId: true }, - }); + const scheduledLinks = await this.dataSource + .getRepository(TrainScheduleBooking) + .find({ + where: { bookingId: In(bookingIds) }, + select: { bookingId: true }, + }); 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) { - 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); @@ -302,44 +364,62 @@ export class TrainSchedulingService { booking.destinationYardId !== dto.destinationStationId, ); 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( (booking) => this.toUtcDateKey(booking.scheduledDate) !== scheduleDateKey, ); 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) { - 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) { - violations.push('Selected bookings must share the same destination station'); + violations.push( + "Selected bookings must share the same destination station", + ); } const uniqueDateCount = new Set( bookings.map((booking) => this.toUtcDateKey(booking.scheduledDate)), ).size; 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( - 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( wagonPlan.reduce((sum, wagon) => sum + wagon.lengthMeters, 0), ); 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) { @@ -357,21 +437,25 @@ export class TrainSchedulingService { ); } - const availableLocomotiveCount = await this.dataSource.getRepository(Locomotive).count({ - where: { status: 'AVAILABLE' as LocomotiveStatus }, - }); + const availableLocomotiveCount = await this.dataSource + .getRepository(Locomotive) + .count({ + where: { status: "AVAILABLE" as LocomotiveStatus }, + }); if (availableLocomotiveCount === 0) { - violations.push('No available locomotive exists for scheduling'); + violations.push("No available locomotive exists for scheduling"); } else { - const capableLocomotives = await this.dataSource.getRepository(Locomotive).find({ - where: { status: 'AVAILABLE' }, - }); + const capableLocomotives = await this.dataSource + .getRepository(Locomotive) + .find({ + where: { status: "AVAILABLE" }, + }); const canPull = capableLocomotives.some( (locomotive) => Number(locomotive.maxPullWeightTons) >= totalWeightTons, ); if (!canPull) { - violations.push('No available locomotive can pull the total weight'); + violations.push("No available locomotive can pull the total weight"); } } @@ -391,14 +475,21 @@ export class TrainSchedulingService { }; } - calculateNW5WagonPlan(totalBookingWeightTons: number, wagonType: WagonType): WagonPlanRecord[] { + calculateNW5WagonPlan( + totalBookingWeightTons: number, + wagonType: WagonType, + ): WagonPlanRecord[] { const wagonCapacityTons = Number(wagonType.capacityTons); const wagonsNeeded = Math.ceil(totalBookingWeightTons / wagonCapacityTons); let remainingWeight = this.roundTons(totalBookingWeightTons); return Array.from({ length: wagonsNeeded }, (_, index) => { - const assignedWeightTons = this.roundTons(Math.min(wagonCapacityTons, remainingWeight)); - remainingWeight = this.roundTons(Math.max(0, remainingWeight - assignedWeightTons)); + const assignedWeightTons = this.roundTons( + Math.min(wagonCapacityTons, remainingWeight), + ); + remainingWeight = this.roundTons( + Math.max(0, remainingWeight - assignedWeightTons), + ); return { sequenceNo: index + 1, @@ -410,15 +501,20 @@ export class TrainSchedulingService { }); } - async selectOrValidateLocomotive(locomotiveId: string, totalWeightTons: number) { + async selectOrValidateLocomotive( + locomotiveId: string, + totalWeightTons: number, + ) { const locomotive = await this.locomotivesRepository.findById(locomotiveId); if (!locomotive) { throw new NotFoundException(`Locomotive ${locomotiveId} not found`); } - if (locomotive.status !== 'AVAILABLE') { - throw new BadRequestException(`Locomotive ${locomotive.code} is not available`); + if (locomotive.status !== "AVAILABLE") { + throw new BadRequestException( + `Locomotive ${locomotive.code} is not available`, + ); } if (Number(locomotive.maxPullWeightTons) < totalWeightTons) { @@ -443,7 +539,7 @@ export class TrainSchedulingService { totalWeightTons, totalLengthMeters, wagonCount: wagonPlan.length, - status: 'ASSIGNED', + status: "ASSIGNED", }); const savedTrainSet = await manager.getRepository(TrainSet).save(trainSet); @@ -463,11 +559,16 @@ export class TrainSchedulingService { return savedTrainSet; } - allocateBookingsToWagons(bookings: Booking[], baseWagonPlan: WagonPlanRecord[]): WagonPlanRecord[] { + allocateBookingsToWagons( + bookings: Booking[], + baseWagonPlan: WagonPlanRecord[], + ): WagonPlanRecord[] { const remaining = bookings.map((booking) => ({ bookingId: booking.id, bookingReference: booking.reference, - remainingWeightTons: this.roundTons(Number(booking.cargoTotalWeightVgm ?? 0)), + remainingWeightTons: this.roundTons( + Number(booking.cargoTotalWeightVgm ?? 0), + ), })); let bookingIndex = 0; @@ -496,7 +597,9 @@ export class TrainSchedulingService { booking.remainingWeightTons - allocatedWeightTons, ); wagonRemaining = this.roundTons(wagonRemaining - allocatedWeightTons); - assignedWeightTons = this.roundTons(assignedWeightTons + allocatedWeightTons); + assignedWeightTons = this.roundTons( + assignedWeightTons + allocatedWeightTons, + ); if (booking.remainingWeightTons <= 0) { bookingIndex += 1; @@ -519,40 +622,54 @@ export class TrainSchedulingService { destinationStation: true, scheduleBookings: true, }, - order: { scheduledDepartureDate: 'DESC', createdAt: 'DESC' }, + order: { scheduledDepartureDate: "DESC", createdAt: "DESC" }, }); return schedules.map((schedule) => ({ id: schedule.id, scheduleDate: schedule.scheduledDepartureDate, - origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null, + origin: + schedule.originStation?.label ?? schedule.originStation?.code ?? null, destination: - schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null, + schedule.destinationStation?.label ?? + schedule.destinationStation?.code ?? + null, locomotive: schedule.trainSet?.locomotive ? { - id: schedule.trainSet.locomotive.id, - code: schedule.trainSet.locomotive.code, - name: schedule.trainSet.locomotive.name ?? null, - } + id: schedule.trainSet.locomotive.id, + code: schedule.trainSet.locomotive.code, + name: schedule.trainSet.locomotive.name ?? null, + } : null, wagonCount: schedule.trainSet?.wagonCount ?? 0, - totalWeightTons: this.roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0)), - totalLengthMeters: this.roundTons(Number(schedule.trainSet?.totalLengthMeters ?? 0)), + totalWeightTons: this.roundTons( + Number(schedule.trainSet?.totalWeightTons ?? 0), + ), + totalLengthMeters: this.roundTons( + Number(schedule.trainSet?.totalLengthMeters ?? 0), + ), bookingsCount: schedule.scheduleBookings?.length ?? 0, status: schedule.status, })); } async getContainerTrainScheduleById(id: string) { - 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: { customer: 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 }, + }, + }, + }); if (!schedule) { throw new NotFoundException(`Train schedule ${id} not found`); @@ -567,67 +684,78 @@ export class TrainSchedulingService { destinationStation: schedule.destinationStation, trainSet: schedule.trainSet ? { - id: schedule.trainSet.id, - status: schedule.trainSet.status, - wagonCount: schedule.trainSet.wagonCount, - totalWeightTons: this.roundTons(Number(schedule.trainSet.totalWeightTons)), - totalLengthMeters: this.roundTons(Number(schedule.trainSet.totalLengthMeters)), - locomotive: schedule.trainSet.locomotive - ? { - id: schedule.trainSet.locomotive.id, - code: schedule.trainSet.locomotive.code, - name: schedule.trainSet.locomotive.name, - status: schedule.trainSet.locomotive.status, - maxPullWeightTons: this.roundTons( - Number(schedule.trainSet.locomotive.maxPullWeightTons), - ), + id: schedule.trainSet.id, + status: schedule.trainSet.status, + wagonCount: schedule.trainSet.wagonCount, + totalWeightTons: this.roundTons( + Number(schedule.trainSet.totalWeightTons), + ), + totalLengthMeters: this.roundTons( + Number(schedule.trainSet.totalLengthMeters), + ), + locomotive: schedule.trainSet.locomotive + ? { + id: schedule.trainSet.locomotive.id, + 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, - 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, - allocations: - wagon.allocations?.map((allocation) => ({ - id: allocation.id, - bookingId: allocation.bookingId, - bookingReference: allocation.booking?.reference ?? null, - allocatedWeightTons: this.roundTons(Number(allocation.allocatedWeightTons)), - })) ?? [], - })), - } + : null, + allocations: + wagon.allocations?.map((allocation) => ({ + id: allocation.id, + bookingId: allocation.bookingId, + bookingReference: allocation.booking?.reference ?? null, + allocatedWeightTons: this.roundTons( + Number(allocation.allocatedWeightTons), + ), + })) ?? [], + })), + } : null, bookings: schedule.scheduleBookings?.map((scheduleBooking) => ({ id: scheduleBooking.booking?.id ?? scheduleBooking.bookingId, reference: scheduleBooking.booking?.reference ?? null, customer: - scheduleBooking.booking?.customer?.companyName ?? - scheduleBooking.booking?.customer?.email ?? + scheduleBooking.booking?.company?.name ?? + scheduleBooking.booking?.company?.email ?? null, - weightTons: this.roundTons(Number(scheduleBooking.booking?.cargoTotalWeightVgm ?? 0)), + weightTons: this.roundTons( + Number(scheduleBooking.booking?.cargoTotalWeightVgm ?? 0), + ), status: scheduleBooking.booking?.status ?? null, })) ?? [], }; } async cancelTrainSchedule(id: string) { - const schedule = await this.dataSource.getRepository(TrainSchedule).findOne({ - where: { id }, - relations: { trainSet: { locomotive: true } }, - }); + const schedule = await this.dataSource + .getRepository(TrainSchedule) + .findOne({ + where: { id }, + relations: { trainSet: { locomotive: true } }, + }); if (!schedule) { throw new NotFoundException(`Train schedule ${id} not found`); @@ -635,19 +763,21 @@ export class TrainSchedulingService { await this.dataSource.transaction(async (manager) => { await manager.getRepository(TrainSchedule).update(schedule.id, { - status: 'CANCELLED', + status: "CANCELLED", }); if (schedule.trainSetId) { await manager.getRepository(TrainSet).update(schedule.trainSetId, { - status: 'CANCELLED', + status: "CANCELLED", }); } if (schedule.trainSet?.locomotiveId) { - await manager.getRepository(Locomotive).update(schedule.trainSet.locomotiveId, { - status: 'AVAILABLE', - }); + await manager + .getRepository(Locomotive) + .update(schedule.trainSet.locomotiveId, { + status: "AVAILABLE", + }); } }); @@ -658,12 +788,12 @@ export class TrainSchedulingService { return this.dataSource.getRepository(Booking).find({ where: { id: In(bookingIds) }, relations: { - customer: true, + company: true, originYard: true, destinationYard: true, bookingContainers: { containerType: true }, }, - order: { createdAt: 'ASC' }, + order: { createdAt: "ASC" }, }); } @@ -673,7 +803,7 @@ export class TrainSchedulingService { } 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)) { return 0; diff --git a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts index 507040eb0..e215dbd9b 100644 --- a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts @@ -1,95 +1,105 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { randomUUID } from 'crypto'; -import { DataSource } from 'typeorm'; +import { Injectable, Logger } from "@nestjs/common"; +import { randomUUID } from "crypto"; +import { DataSource } from "typeorm"; -import { BookingContainer } from '../modules/bookings/entities/booking-container.entity'; -import { Booking } from '../modules/bookings/entities/booking.entity'; -import { Customer } from '../modules/customers/entities/customer.entity'; -import { Locomotive } from '../modules/locomotives/entities/locomotive.entity'; -import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; -import { Yard } from '../modules/rule-engine/entities/yard.entity'; -import { WagonType } from '../modules/wagon-types/entities/wagon-type.entity'; -import { ContainerType } from '../modules/rule-engine/entities/container-type.entity'; +import { BookingContainer } from "../modules/bookings/entities/booking-container.entity"; +import { Booking } from "../modules/bookings/entities/booking.entity"; +import { Customer } from "../modules/customers/entities/customer.entity"; +import { Locomotive } from "../modules/locomotives/entities/locomotive.entity"; +import { ServiceType } from "../modules/rule-engine/entities/service-type.entity"; +import { Yard } from "../modules/rule-engine/entities/yard.entity"; +import { WagonType } from "../modules/wagon-types/entities/wagon-type.entity"; +import { ContainerType } from "../modules/rule-engine/entities/container-type.entity"; -const SEED_FLAG = 'SEED_DEMO_BOOKINGS'; +const SEED_FLAG = "SEED_DEMO_BOOKINGS"; -const SERVICE_TYPE_CODE = 'RAIL_CONTAINER'; -const CUSTOMER_EMAIL = 'train-scheduling-demo@edr.local'; +const SERVICE_TYPE_CODE = "RAIL_CONTAINER"; +const CUSTOMER_EMAIL = "train-scheduling-demo@edr.local"; const YARDS = [ - { code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti', displayOrder: 1 }, - { code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia', displayOrder: 2 }, - { code: 'DIRE_DAWA', label: 'Dire Dawa', country: 'Ethiopia', displayOrder: 3 }, + { code: "DJIBOUTI", label: "Djibouti", country: "Djibouti", displayOrder: 1 }, + { + code: "ADDIS_ABABA", + label: "Addis Ababa", + country: "Ethiopia", + displayOrder: 2, + }, + { + code: "DIRE_DAWA", + label: "Dire Dawa", + country: "Ethiopia", + displayOrder: 3, + }, ]; const CONTAINER_TYPES = [ - { code: '20FT', label: '20FT', sizeFt: 20 }, - { code: '40FT', label: '40FT', sizeFt: 40 }, + { code: "20FT", label: "20FT", sizeFt: 20 }, + { code: "40FT", label: "40FT", sizeFt: 40 }, ]; const DEMO_BOOKINGS = [ { - reference: 'BKG-CONT-001', - containerCode: '40FT', + reference: "BKG-CONT-001", + containerCode: "40FT", quantity: 20, totalWeightTons: 500, - originCode: 'DJIBOUTI', - destinationCode: 'ADDIS_ABABA', - scheduledDate: '2026-06-20T08:00:00.000Z', + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-20T08:00:00.000Z", }, { - reference: 'BKG-CONT-002', - containerCode: '20FT', + reference: "BKG-CONT-002", + containerCode: "20FT", quantity: 10, totalWeightTons: 300, - originCode: 'DJIBOUTI', - destinationCode: 'ADDIS_ABABA', - scheduledDate: '2026-06-20T08:00:00.000Z', + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-20T08:00:00.000Z", }, { - reference: 'BKG-CONT-003', - containerCode: '40FT', + reference: "BKG-CONT-003", + containerCode: "40FT", quantity: 15, totalWeightTons: 450, - originCode: 'DJIBOUTI', - destinationCode: 'ADDIS_ABABA', - scheduledDate: '2026-06-20T08:00:00.000Z', + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-20T08:00:00.000Z", }, { - reference: 'BKG-CONT-007', - containerCode: '20FT', + reference: "BKG-CONT-007", + containerCode: "20FT", quantity: 6, totalWeightTons: 180, - originCode: 'DJIBOUTI', - destinationCode: 'ADDIS_ABABA', - scheduledDate: '2026-06-20T08:00:00.000Z', + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-20T08:00:00.000Z", }, { - reference: 'BKG-CONT-004', - containerCode: '40FT', + reference: "BKG-CONT-004", + containerCode: "40FT", quantity: 12, totalWeightTons: 360, - originCode: 'ADDIS_ABABA', - destinationCode: 'DIRE_DAWA', - scheduledDate: '2026-06-20T08:00:00.000Z', + originCode: "ADDIS_ABABA", + destinationCode: "DIRE_DAWA", + scheduledDate: "2026-06-20T08:00:00.000Z", }, { - reference: 'BKG-CONT-005', - containerCode: '20FT', + reference: "BKG-CONT-005", + containerCode: "20FT", quantity: 8, totalWeightTons: 160, - originCode: 'DJIBOUTI', - destinationCode: 'ADDIS_ABABA', - scheduledDate: '2026-06-21T08:00:00.000Z', + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-21T08:00:00.000Z", }, { - reference: 'BKG-CONT-006', - containerCode: '40FT', + reference: "BKG-CONT-006", + containerCode: "40FT", quantity: 80, totalWeightTons: 3600, - originCode: 'DJIBOUTI', - destinationCode: 'ADDIS_ABABA', - scheduledDate: '2026-06-20T08:00:00.000Z', + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-20T08:00:00.000Z", }, ]; @@ -97,24 +107,26 @@ const DEMO_BOOKINGS = [ export class DemoBookingsSeeder { private readonly logger = new Logger(DemoBookingsSeeder.name); - constructor(private readonly dataSource: DataSource) {} + constructor(private readonly dataSource: DataSource) { } async run() { - const shouldSeed = process.env[SEED_FLAG]?.trim().toLowerCase() === 'true'; + const shouldSeed = process.env[SEED_FLAG]?.trim().toLowerCase() === "true"; 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; } await this.dataSource.transaction(async (manager) => { await manager.getRepository(WagonType).upsert( { - code: 'NW5', - name: 'Flat Wagon', + code: "NW5", + name: "Flat Wagon", capacityTons: 70, lengthMeters: 14, maxWagonsPerTrain: 53, - supportedLoadTypes: ['CONTAINER'], + supportedLoadTypes: ["CONTAINER"], isActive: true, }, { conflictPaths: { code: true } }, @@ -123,16 +135,16 @@ export class DemoBookingsSeeder { await manager.getRepository(Locomotive).upsert( [ { - code: 'LOC-001', - name: 'Demo Locomotive 1', + code: "LOC-001", + name: "Demo Locomotive 1", maxPullWeightTons: 3500, - status: 'AVAILABLE', + status: "AVAILABLE", }, { - code: 'LOC-002', - name: 'Demo Locomotive 2', + code: "LOC-002", + name: "Demo Locomotive 2", maxPullWeightTons: 2500, - status: 'AVAILABLE', + status: "AVAILABLE", }, ], { conflictPaths: { code: true } }, @@ -146,8 +158,8 @@ export class DemoBookingsSeeder { await manager.getRepository(ServiceType).upsert( { code: SERVICE_TYPE_CODE, - serviceName: 'Rail Container Service', - description: 'Temporary service type for train scheduling demos', + serviceName: "Rail Container Service", + description: "Temporary service type for train scheduling demos", canBeBookedAlone: true, includesFirstMile: false, includesLastMile: false, @@ -173,74 +185,86 @@ export class DemoBookingsSeeder { await manager.getRepository(Customer).upsert( { - userId: '00000000-0000-0000-0000-000000000111', - firstName: 'Train', - lastName: 'Scheduling', + userId: "00000000-0000-0000-0000-000000000111", + firstName: "Train", + lastName: "Scheduling", email: CUSTOMER_EMAIL, - phone: '251900000001', - companyName: 'Train Scheduling Demo Customer', + phone: "251900000001", + companyName: "Train Scheduling Demo Customer", companyEmail: CUSTOMER_EMAIL, - companyPhone: '251900000001', - companyLocation: 'Addis Ababa', - companyAddress: 'Demo Address', - customerType: 'DEMO', - status: 'ACTIVE', - contactPersonName: 'Train Scheduling', - contactPersonPhone: '251900000001', - tinNumber: '1234567890', - vatNumber: '1234567890', - fanNumber: '1234567890123456', - generalManagerName: 'Demo Manager', + companyPhone: "251900000001", + companyLocation: "Addis Ababa", + companyAddress: "Demo Address", + customerType: "DEMO", + status: "ACTIVE", + contactPersonName: "Train Scheduling", + contactPersonPhone: "251900000001", + tinNumber: "1234567890", + vatNumber: "1234567890", + fanNumber: "1234567890123456", + generalManagerName: "Demo Manager", generalManagerEmail: CUSTOMER_EMAIL, - generalManagerPhone: '251900000001', + generalManagerPhone: "251900000001", }, { conflictPaths: { email: true } }, ); const [serviceType, customer, yards, containerTypes] = await Promise.all([ - manager.getRepository(ServiceType).findOneByOrFail({ code: SERVICE_TYPE_CODE }), - manager.getRepository(Customer).findOneByOrFail({ email: CUSTOMER_EMAIL }), + manager + .getRepository(ServiceType) + .findOneByOrFail({ code: SERVICE_TYPE_CODE }), + manager + .getRepository(Customer) + .findOneByOrFail({ email: CUSTOMER_EMAIL }), manager.getRepository(Yard).find(), manager.getRepository(ContainerType).find(), ]); const yardByCode = new Map(yards.map((yard) => [yard.code, yard])); const containerTypeByCode = new Map( - containerTypes.map((containerType) => [containerType.code, containerType]), + containerTypes.map((containerType) => [ + containerType.code, + containerType, + ]), ); for (const demoBooking of DEMO_BOOKINGS) { const origin = yardByCode.get(demoBooking.originCode); const destination = yardByCode.get(demoBooking.destinationCode); - const containerType = containerTypeByCode.get(demoBooking.containerCode); + const containerType = containerTypeByCode.get( + demoBooking.containerCode, + ); 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( { reference: demoBooking.reference, - customerId: customer.id, - status: 'APPROVED', + companyId: customer.id, + status: "APPROVED", scheduledDate: new Date(demoBooking.scheduledDate), totalAmount: 0, - paymentStatus: 'PENDING', - contractType: 'NEW', + paymentStatus: "PENDING", + contractType: "NEW", serviceTypeId: serviceType.id, - equipmentReturn: 'WITHOUT_RETURN', + equipmentReturn: "WITHOUT_RETURN", originYardId: origin.id, destinationYardId: destination.id, - tradeDirection: 'IMPORT', - freightType: 'CONTAINER', + tradeDirection: "IMPORT", + freightType: "CONTAINER", cargoTypeId: null, cargoFreeText: null, shippingLineId: null, cargoTotalWeightVgm: demoBooking.totalWeightTons, isHazardous: false, - paymentCurrency: 'USD', + paymentCurrency: "USD", allowConsolidation: false, priorityScore: 0, versionNumber: 1, @@ -252,7 +276,9 @@ export class DemoBookingsSeeder { reference: demoBooking.reference, }); - await manager.getRepository(BookingContainer).delete({ bookingId: booking.id }); + await manager + .getRepository(BookingContainer) + .delete({ bookingId: booking.id }); await manager.getRepository(BookingContainer).insert({ id: randomUUID(), bookingId: booking.id, @@ -264,11 +290,13 @@ export class DemoBookingsSeeder { weightLimitRuleId: null, isOverweight: demoBooking.totalWeightTons > 70, 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"); } } diff --git a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts new file mode 100644 index 000000000..671436a73 --- /dev/null +++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts @@ -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 { + 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(); + 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 { + 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 { + 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 { + 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, + ): Promise { + 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, + ): Promise { + 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, + yardByCode: Map, + stByCode: Map, + slByCode: Map, + cargoByCode: Map, + ): Promise { + 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`); + } +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx index 73977fc7c..07243cdec 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx @@ -16,7 +16,6 @@ import { ShieldCheck, AlertTriangle, Info, - Clock, Layers, CheckCircle2, History, @@ -36,7 +35,6 @@ import { import Breadcrumbs from "@/components/Breadcrumbs"; import { api } from "@/services/api"; import type { Freight } from "@edr/types"; -import type { GeneratePriceResponse } from "@/services/bookings.service"; import { Card, CardHeader, @@ -46,6 +44,14 @@ import { Badge, Button, Separator, + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, + DialogClose, } from "@edr/ui-common"; import { cn } from "@/lib/utils"; import useAuth from "@/hooks/useAuth"; @@ -57,12 +63,40 @@ const PROGRESS_STAGES = [ { label: "Complete", icon: PackageCheck, statuses: ["DELIVERED"] }, ]; -const STATUS_MAP: Record = { - DRAFT: { title: "Drafting Request", description: "Booking is being prepared and has not been submitted.", color: "text-slate-500", stage: 0 }, - CONFIRMED: { title: "Booking Confirmed", description: "Booking has been confirmed and approved.", color: "text-emerald-600", stage: 1 }, - IN_TRANSIT: { title: "Cargo Moving", description: "Shipment is currently moving through the rail network.", color: "text-sky-600", stage: 2 }, - DELIVERED: { title: "Service Complete", description: "Cargo delivered and service successfully terminated.", color: "text-emerald-600", stage: 3 }, - CANCELLED: { title: "Cancelled", description: "This booking process has been terminated.", color: "text-red-600", stage: -1 }, +const STATUS_MAP: Record< + string, + { title: string; description: string; color: string; stage: number } +> = { + DRAFT: { + title: "Drafting Request", + description: "Booking is being prepared and has not been submitted.", + color: "text-slate-500", + stage: 0, + }, + CONFIRMED: { + title: "Booking Confirmed", + description: "Booking has been confirmed and approved.", + color: "text-emerald-600", + stage: 1, + }, + IN_TRANSIT: { + title: "Cargo Moving", + description: "Shipment is currently moving through the rail network.", + color: "text-sky-600", + stage: 2, + }, + DELIVERED: { + title: "Service Complete", + description: "Cargo delivered and service successfully terminated.", + color: "text-emerald-600", + stage: 3, + }, + CANCELLED: { + title: "Cancelled", + description: "This booking process has been terminated.", + color: "text-red-600", + stage: -1, + }, }; const REQUIRED_DOC_FIELDS = [ @@ -74,10 +108,14 @@ const REQUIRED_DOC_FIELDS = [ export default function BookingDetailPage() { const { id } = useParams<{ id: string }>(); - const navigate = useNavigate(); const queryClient = useQueryClient(); - const { data: booking, isLoading, isError, error } = useQuery( + const { + data: booking, + isLoading, + isError, + error, + } = useQuery( api.bookings.get.queryOptions({ input: { id: id! }, enabled: !!id, @@ -85,7 +123,9 @@ export default function BookingDetailPage() { ); const refetchBooking = () => { - queryClient.invalidateQueries({ queryKey: api.bookings.get.queryKey({ id: id! }) }); + queryClient.invalidateQueries({ + queryKey: api.bookings.get.queryKey({ id: id! }), + }); }; if (isLoading) { @@ -93,7 +133,9 @@ export default function BookingDetailPage() {
-

Loading booking details…

+

+ Loading booking details… +

); @@ -110,7 +152,9 @@ export default function BookingDetailPage() { Failed to load booking

- {error instanceof Error ? error.message : "An unexpected error occurred."} + {error instanceof Error + ? error.message + : "An unexpected error occurred."}

@@ -133,7 +177,9 @@ export default function BookingDetailPage() { } if (booking.status === "DRAFT") { - return ; + return ( + + ); } return ; @@ -151,20 +197,20 @@ function DraftBookingView({ const { customer } = useAuth(); const fileInputRefs = useRef>({}); - const [pricingData, setPricingData] = useState(null); - const [selectedFiles, setSelectedFiles] = useState>({}); + const [selectedFiles, setSelectedFiles] = useState< + Record + >({}); + const [cancelDialogOpen, setCancelDialogOpen] = useState(false); const [cancelReason, setCancelReason] = useState(""); const anyFileSelected = Object.values(selectedFiles).some(Boolean); - const allDocsProvided = !anyFileSelected; - const priceMutation = useMutation({ - mutationFn: () => api.bookings.generatePrice.call({ id: booking.id }), - onSuccess: (data) => { - setPricingData(data); - queryClient.invalidateQueries({ queryKey: api.bookings.get.queryKey({ id: booking.id }) }); - }, - }); + const pricingQuery = useQuery( + api.bookings.generatePrice.queryOptions({ + input: { id: booking.id }, + enabled: !!booking.id, + }), + ); const uploadMutation = useMutation({ mutationFn: (files: Record) => @@ -187,6 +233,7 @@ function DraftBookingView({ mutationFn: (reason: string) => api.bookings.cancel.call({ id: booking.id, reason }), onSuccess: () => { + setCancelDialogOpen(false); onBookingUpdated(); }, }); @@ -211,17 +258,19 @@ function DraftBookingView({ cancelMutation.mutate(reason); } - const canConfirm = !!pricingData && !uploadMutation.isPending && !submitMutation.isPending; + const canConfirm = + pricingQuery.isSuccess && !uploadMutation.isPending && !submitMutation.isPending; const companyName = (customer as any)?.company?.name ?? "—"; const companyTin = (customer as any)?.company?.tin ?? "—"; const contactName = (customer as any)?.profile - ? `${(customer as any).profile.firstName ?? ""} ${(customer as any).profile.lastName ?? ""}`.trim() || "—" + ? `${(customer as any).profile.firstName ?? ""} ${(customer as any).profile.lastName ?? ""}`.trim() || + "—" : "—"; const contactEmail = (customer as any)?.profile?.email ?? "—"; return ( -
+
- {priceMutation.isError && ( + {pricingQuery.isError && (

Pricing failed

- {priceMutation.error instanceof Error - ? priceMutation.error.message + {pricingQuery.error instanceof Error + ? pricingQuery.error.message : "An unexpected error occurred."}

@@ -307,50 +356,85 @@ function DraftBookingView({
)} - + Pricing Estimation - Generate a price estimate based on your booking details. + Price estimate based on your booking details. - {pricingData ? ( + {pricingQuery.isLoading ? ( +
+ +

+ Calculating price… +

+
+ ) : pricingQuery.isError ? ( +
+

+ Could not calculate price. +

+ +
+ ) : pricingQuery.data ? (
- + - {pricingData.lineItems.map((item, i) => ( + {pricingQuery.data.lineItems.map((item, i) => ( - + ))} - +
DescriptionAmount + Amount +
{item.description} + {item.description} + {item.amount.toLocaleString()} {item.currency}
Total Estimated Cost + Total Estimated Cost + - {pricingData.totalAmount.toLocaleString()} {pricingData.currency} + {pricingQuery.data.totalAmount.toLocaleString()}{" "} + {pricingQuery.data.currency}
- {pricingData.warnings.length > 0 && ( + {pricingQuery.data.warnings.length > 0 && (
- {pricingData.warnings.map((w, i) => ( -

+ {pricingQuery.data.warnings.map((w, i) => ( +

{w}

@@ -362,40 +446,13 @@ function DraftBookingView({
- ) : ( -
-
- -
-
-

No pricing yet

-

- Generate a price estimate to review before submitting. -

-
- -
- )} + ) : null}
@@ -406,8 +463,8 @@ function DraftBookingView({ Required Documents - Provide the necessary documents for this booking. Some information is - pre-filled from your company profile. + Provide the necessary documents for this booking. Some information + is pre-filled from your company profile. @@ -460,7 +517,10 @@ function DraftBookingView({ accept=".pdf,.jpg,.jpeg,.png" className="hidden" onChange={(e) => { - handleFileSelect(doc.key, e.target.files?.[0] ?? null); + handleFileSelect( + doc.key, + e.target.files?.[0] ?? null, + ); }} /> {selectedFiles[doc.key] && ( + +

+ Cancelling will terminate this booking request and cannot be + undone. +

+ + + + + + + Cancel Booking + + Are you sure you want to cancel this booking? This action + cannot be undone. + + +
+ + setCancelReason(e.target.value)} + autoFocus + /> +
+ + + + + + +
+
@@ -556,9 +652,11 @@ function DraftBookingView({
{!canConfirm && (

- {!pricingData - ? "Request pricing estimation before confirming." - : "Upload documents before confirming."} + {pricingQuery.isLoading + ? "Calculating price…" + : pricingQuery.isError + ? "Price calculation failed." + : "Upload documents before confirming."}

)} - - - )} + {(booking.status === "CONFIRMED" || + booking.status === "IN_TRANSIT") && ( + + +
+

Contract ready

+

+ Review the agreement and apply your digital signature. +

+
+ +
+
+ )} @@ -658,14 +756,21 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) { Booking Status Lifecycle - Track the journey from request to completion + + Track the journey from request to completion +
= 0 ? `${(currentStageIndex / (PROGRESS_STAGES.length - 1)) * 100}%` : '0%' }} + style={{ + width: + currentStageIndex >= 0 + ? `${(currentStageIndex / (PROGRESS_STAGES.length - 1)) * 100}%` + : "0%", + }} />
@@ -674,19 +779,32 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) { const isActive = idx === currentStageIndex; return ( -
-
- {isCompleted ? : } +
+
+ {isCompleted ? ( + + ) : ( + + )}
- + {stage.label}
@@ -695,26 +813,40 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
-
- {normalizedStatus === "CANCELLED" ? : } -
-
-

- {statusConfig.title} -

-

- {statusConfig.description} -

-
- {normalizedStatus !== "CANCELLED" && normalizedStatus !== "DELIVERED" && ( -
+
+ {normalizedStatus === "CANCELLED" ? ( + + ) : ( + + )} +
+
+

+ {statusConfig.title} +

+

+ {statusConfig.description} +

+
+ {normalizedStatus !== "CANCELLED" && + normalizedStatus !== "DELIVERED" && ( +
-

Est. Waiting

-

1-2 Working Days

+

+ Est. Waiting +

+

+ 1-2 Working Days +

-
- )} +
+ )}
@@ -740,7 +872,10 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
- + Rail
@@ -752,9 +887,31 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
- } label="Service" value={booking.serviceType === "RAIL_AND_FORWARDING" ? "Rail & Forwarding" : "Rail Only"} /> - } label="Return" value={booking.equipmentReturn === "WITH_RETURN" ? "With Return" : "Without Return"} /> - } label="Trade" value={booking.tradeDirection === "IMPORT" ? "Import" : "Export"} /> + } + label="Service" + value={ + booking.serviceType === "RAIL_AND_FORWARDING" + ? "Rail & Forwarding" + : "Rail Only" + } + /> + } + label="Return" + value={ + booking.equipmentReturn === "WITH_RETURN" + ? "With Return" + : "Without Return" + } + /> + } + label="Trade" + value={ + booking.tradeDirection === "IMPORT" ? "Import" : "Export" + } + />
@@ -771,14 +928,23 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {

First Mile

- +

Last Mile

- {booking.lastMileEnabled && booking.lastMileDeliveryAddress ? booking.lastMileDeliveryAddress : "Not requested"} + {booking.lastMileEnabled && booking.lastMileDeliveryAddress + ? booking.lastMileDeliveryAddress + : "Not requested"}

@@ -793,31 +959,57 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
- } label="Freight Type" value={booking.freightType === "BULK" ? "Bulk" : "Break Bulk"} /> - } label="Weight (VGM)" value={`${booking.cargoTotalWeightVgm} Tons`} /> - } label="Currency" value={booking.paymentCurrency} /> + } + label="Freight Type" + value={ + booking.freightType === "BULK" ? "Bulk" : "Break Bulk" + } + /> + } + label="Weight (VGM)" + value={`${booking.cargoTotalWeightVgm} Tons`} + /> + } + label="Currency" + value={booking.paymentCurrency} + />
{booking.containers && booking.containers.length > 0 && ( <>
-

Load Details

+

+ Load Details +

- - + + {booking.containers.map((c, i) => ( - - - + + + ))} @@ -839,7 +1031,10 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) { - +
@@ -860,15 +1055,21 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) { {booking.freightSubtype && (
-

Cargo Description

-

"{booking.freightSubtype}"

+

+ Cargo Description +

+

+ "{booking.freightSubtype}" +

)} {booking.financialTerms && ( <>
-

Financial Terms

+

+ Financial Terms +

@@ -879,7 +1080,9 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) { )} {!booking.freightSubtype && !booking.financialTerms && ( -

No additional information provided.

+

+ No additional information provided. +

)} @@ -917,17 +1120,23 @@ function RouteEndpoint({ function InfoItem({ icon, label, - value + value, }: { icon?: React.ReactNode; label: string; - value?: string | number | null + value?: string | number | null; }) { return (
- {icon &&
{icon}
} + {icon && ( +
+ {icon} +
+ )}
-

{label}

+

+ {label} +

{value ?? "—"}

@@ -946,9 +1155,12 @@ function StatusBadge({ status }: { status: string }) { return ( - {status.replace(/_/g, ' ')} + {status.replace(/_/g, " ")} ); } diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index 672b1f562..847fac2bd 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -18,7 +18,6 @@ import { trackingService } from "./tracking.service"; import { fileUploadSettingsService } from "./fileUploadSettings.service"; import { dropdownSettingsService } from "./dropdownSettings.service"; import { authService } from "./auth.service"; -import { customersService } from "./customers.service"; import { companiesService } from "./companies.service"; import { CreateDropdownOptionDto, @@ -28,11 +27,6 @@ import { UpdateDropdownOptionDto, UpdateDropdownSettingDto, } from "@/types/dropdownSettings"; -import { - CreateCustomerDto, - Customer, - UpdateCustomerDto, -} from "@/types/customers"; import type { CompanyInfoResponse, CreateCompanyPayload, diff --git a/tasks.md b/tasks.md deleted file mode 100644 index b829182c4..000000000 --- a/tasks.md +++ /dev/null @@ -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
TypeQuantityVGM (Tons) + Quantity + + VGM (Tons) +
{c.type}{c.qty} Units{c.vgm}t + {c.type} + + {c.qty} Units + + {c.vgm}t +