mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 04:08:11 +00:00
feat(bookings): Integrate auto-pricing into DRAFT booking UI, and refactor customer-company relationships across services.
This commit is contained in:
@@ -36,6 +36,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";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -83,7 +84,7 @@ import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
|
|||||||
BackofficeModule,
|
BackofficeModule,
|
||||||
DemoPermissionsModule,
|
DemoPermissionsModule,
|
||||||
],
|
],
|
||||||
providers: [EdrOrgSeeder, DemoUsersSeeder, DemoBookingsSeeder, FileUploadSettingsSeeder],
|
providers: [EdrOrgSeeder, DemoUsersSeeder, DemoBookingsSeeder, PricingDataSeeder, FileUploadSettingsSeeder],
|
||||||
})
|
})
|
||||||
export class AppModule implements OnApplicationBootstrap {
|
export class AppModule implements OnApplicationBootstrap {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -91,6 +92,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,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
@@ -99,6 +101,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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,31 +77,38 @@ 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
|
||||||
.createQueryBuilder('booking')
|
.createQueryBuilder("booking")
|
||||||
.leftJoinAndSelect('booking.customer', 'customer')
|
.leftJoinAndSelect("booking.customer", "customer")
|
||||||
.leftJoinAndSelect('booking.originYard', 'originYard')
|
.leftJoinAndSelect("booking.originYard", "originYard")
|
||||||
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
.leftJoinAndSelect("booking.destinationYard", "destinationYard")
|
||||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
.leftJoinAndSelect("booking.bookingContainers", "bookingContainer")
|
||||||
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
.leftJoinAndSelect("bookingContainer.containerType", "containerType")
|
||||||
.leftJoin(TrainScheduleBooking, 'scheduleBooking', 'scheduleBooking.booking_id = booking.id')
|
.leftJoin(
|
||||||
.where('booking.freightType = :freightType', { freightType: 'CONTAINER' })
|
TrainScheduleBooking,
|
||||||
.andWhere('scheduleBooking.id IS NULL');
|
"scheduleBooking",
|
||||||
|
"scheduleBooking.booking_id = booking.id",
|
||||||
|
)
|
||||||
|
.where("booking.freightType = :freightType", { freightType: "CONTAINER" })
|
||||||
|
.andWhere("scheduleBooking.id IS NULL");
|
||||||
|
|
||||||
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 +119,44 @@ 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,
|
||||||
customer: booking.customer?.companyName ?? booking.customer?.email ?? 'Unknown customer',
|
customer:
|
||||||
containerType: booking.bookingContainers
|
booking.company?.name ?? booking.company?.email ?? "Unknown customer",
|
||||||
?.map((container) => container.containerType?.label ?? container.containerType?.code ?? 'Container')
|
containerType:
|
||||||
.join(', ') ?? 'Container',
|
booking.bookingContainers
|
||||||
quantity: booking.bookingContainers?.reduce((sum, container) => sum + Number(container.quantity ?? 0), 0) ?? 0,
|
?.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),
|
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 +184,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 +194,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 +313,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 +321,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 +332,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 +364,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 +437,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 +475,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 +501,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 +539,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 +559,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 +597,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,40 +622,54 @@ 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) {
|
||||||
const schedule = await this.dataSource.getRepository(TrainSchedule).findOne({
|
const schedule = await this.dataSource
|
||||||
where: { id },
|
.getRepository(TrainSchedule)
|
||||||
relations: {
|
.findOne({
|
||||||
trainSet: { locomotive: true, wagons: { wagonType: true, allocations: { booking: true } } },
|
where: { id },
|
||||||
originStation: true,
|
relations: {
|
||||||
destinationStation: true,
|
trainSet: {
|
||||||
scheduleBookings: { booking: { customer: true, originYard: true, destinationYard: true } },
|
locomotive: true,
|
||||||
},
|
wagons: { wagonType: true, allocations: { booking: true } },
|
||||||
});
|
},
|
||||||
|
originStation: true,
|
||||||
|
destinationStation: true,
|
||||||
|
scheduleBookings: {
|
||||||
|
booking: { company: true, originYard: true, destinationYard: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (!schedule) {
|
if (!schedule) {
|
||||||
throw new NotFoundException(`Train schedule ${id} not found`);
|
throw new NotFoundException(`Train schedule ${id} not found`);
|
||||||
@@ -567,67 +684,78 @@ 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) => ({
|
||||||
id: scheduleBooking.booking?.id ?? scheduleBooking.bookingId,
|
id: scheduleBooking.booking?.id ?? scheduleBooking.bookingId,
|
||||||
reference: scheduleBooking.booking?.reference ?? null,
|
reference: scheduleBooking.booking?.reference ?? null,
|
||||||
customer:
|
customer:
|
||||||
scheduleBooking.booking?.customer?.companyName ??
|
scheduleBooking.booking?.company?.name ??
|
||||||
scheduleBooking.booking?.customer?.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 +763,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",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -658,12 +788,12 @@ export class TrainSchedulingService {
|
|||||||
return this.dataSource.getRepository(Booking).find({
|
return this.dataSource.getRepository(Booking).find({
|
||||||
where: { id: In(bookingIds) },
|
where: { id: In(bookingIds) },
|
||||||
relations: {
|
relations: {
|
||||||
customer: true,
|
company: true,
|
||||||
originYard: true,
|
originYard: true,
|
||||||
destinationYard: true,
|
destinationYard: true,
|
||||||
bookingContainers: { containerType: true },
|
bookingContainers: { containerType: true },
|
||||||
},
|
},
|
||||||
order: { createdAt: 'ASC' },
|
order: { createdAt: "ASC" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -673,7 +803,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;
|
||||||
|
|||||||
@@ -1,95 +1,105 @@
|
|||||||
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";
|
||||||
|
|
||||||
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 { Customer } from '../modules/customers/entities/customer.entity';
|
import { Customer } from "../modules/customers/entities/customer.entity";
|
||||||
import { Locomotive } from '../modules/locomotives/entities/locomotive.entity';
|
import { Locomotive } from "../modules/locomotives/entities/locomotive.entity";
|
||||||
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
|
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";
|
||||||
|
|
||||||
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 +107,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 +135,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 +158,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 +185,86 @@ 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 } },
|
||||||
);
|
);
|
||||||
|
|
||||||
const [serviceType, customer, yards, containerTypes] = await Promise.all([
|
const [serviceType, customer, yards, containerTypes] = await Promise.all([
|
||||||
manager.getRepository(ServiceType).findOneByOrFail({ code: SERVICE_TYPE_CODE }),
|
manager
|
||||||
manager.getRepository(Customer).findOneByOrFail({ email: CUSTOMER_EMAIL }),
|
.getRepository(ServiceType)
|
||||||
|
.findOneByOrFail({ code: SERVICE_TYPE_CODE }),
|
||||||
|
manager
|
||||||
|
.getRepository(Customer)
|
||||||
|
.findOneByOrFail({ email: CUSTOMER_EMAIL }),
|
||||||
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,
|
||||||
customerId: customer.id,
|
companyId: customer.id,
|
||||||
status: 'APPROVED',
|
status: "APPROVED",
|
||||||
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 +276,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 +290,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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
800
apps/edr-freight-api/src/seed/pricing-data.seeder.ts
Normal file
800
apps/edr-freight-api/src/seed/pricing-data.seeder.ts
Normal 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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Info,
|
Info,
|
||||||
Clock,
|
|
||||||
Layers,
|
Layers,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
History,
|
History,
|
||||||
@@ -36,7 +35,6 @@ import {
|
|||||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
import type { GeneratePriceResponse } from "@/services/bookings.service";
|
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
@@ -46,6 +44,14 @@ import {
|
|||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Separator,
|
Separator,
|
||||||
|
Dialog,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogClose,
|
||||||
} from "@edr/ui-common";
|
} from "@edr/ui-common";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import useAuth from "@/hooks/useAuth";
|
import useAuth from "@/hooks/useAuth";
|
||||||
@@ -57,12 +63,40 @@ const PROGRESS_STAGES = [
|
|||||||
{ label: "Complete", icon: PackageCheck, statuses: ["DELIVERED"] },
|
{ label: "Complete", icon: PackageCheck, statuses: ["DELIVERED"] },
|
||||||
];
|
];
|
||||||
|
|
||||||
const STATUS_MAP: Record<string, { title: string; description: string; color: string; stage: number }> = {
|
const STATUS_MAP: Record<
|
||||||
DRAFT: { title: "Drafting Request", description: "Booking is being prepared and has not been submitted.", color: "text-slate-500", stage: 0 },
|
string,
|
||||||
CONFIRMED: { title: "Booking Confirmed", description: "Booking has been confirmed and approved.", color: "text-emerald-600", stage: 1 },
|
{ title: string; description: string; color: string; stage: number }
|
||||||
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 },
|
DRAFT: {
|
||||||
CANCELLED: { title: "Cancelled", description: "This booking process has been terminated.", color: "text-red-600", stage: -1 },
|
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 = [
|
const REQUIRED_DOC_FIELDS = [
|
||||||
@@ -74,10 +108,14 @@ const REQUIRED_DOC_FIELDS = [
|
|||||||
|
|
||||||
export default function BookingDetailPage() {
|
export default function BookingDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data: booking, isLoading, isError, error } = useQuery(
|
const {
|
||||||
|
data: booking,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
} = useQuery(
|
||||||
api.bookings.get.queryOptions({
|
api.bookings.get.queryOptions({
|
||||||
input: { id: id! },
|
input: { id: id! },
|
||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
@@ -85,7 +123,9 @@ export default function BookingDetailPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const refetchBooking = () => {
|
const refetchBooking = () => {
|
||||||
queryClient.invalidateQueries({ queryKey: api.bookings.get.queryKey({ id: id! }) });
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: api.bookings.get.queryKey({ id: id! }),
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -93,7 +133,9 @@ export default function BookingDetailPage() {
|
|||||||
<div className="container mx-auto flex items-center justify-center p-12">
|
<div className="container mx-auto flex items-center justify-center p-12">
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="flex flex-col items-center gap-4">
|
||||||
<LoaderCircle className="size-8 animate-spin text-primary" />
|
<LoaderCircle className="size-8 animate-spin text-primary" />
|
||||||
<p className="text-sm text-muted-foreground">Loading booking details…</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Loading booking details…
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -110,7 +152,9 @@ export default function BookingDetailPage() {
|
|||||||
Failed to load booking
|
Failed to load booking
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
{error instanceof Error ? error.message : "An unexpected error occurred."}
|
{error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "An unexpected error occurred."}
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -133,7 +177,9 @@ export default function BookingDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (booking.status === "DRAFT") {
|
if (booking.status === "DRAFT") {
|
||||||
return <DraftBookingView booking={booking} onBookingUpdated={refetchBooking} />;
|
return (
|
||||||
|
<DraftBookingView booking={booking} onBookingUpdated={refetchBooking} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ReadonlyBookingView booking={booking} />;
|
return <ReadonlyBookingView booking={booking} />;
|
||||||
@@ -151,20 +197,20 @@ function DraftBookingView({
|
|||||||
const { customer } = useAuth();
|
const { customer } = useAuth();
|
||||||
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
||||||
|
|
||||||
const [pricingData, setPricingData] = useState<GeneratePriceResponse | null>(null);
|
const [selectedFiles, setSelectedFiles] = useState<
|
||||||
const [selectedFiles, setSelectedFiles] = useState<Record<string, File | null>>({});
|
Record<string, File | null>
|
||||||
|
>({});
|
||||||
|
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
|
||||||
const [cancelReason, setCancelReason] = useState("");
|
const [cancelReason, setCancelReason] = useState("");
|
||||||
|
|
||||||
const anyFileSelected = Object.values(selectedFiles).some(Boolean);
|
const anyFileSelected = Object.values(selectedFiles).some(Boolean);
|
||||||
const allDocsProvided = !anyFileSelected;
|
|
||||||
|
|
||||||
const priceMutation = useMutation({
|
const pricingQuery = useQuery(
|
||||||
mutationFn: () => api.bookings.generatePrice.call({ id: booking.id }),
|
api.bookings.generatePrice.queryOptions({
|
||||||
onSuccess: (data) => {
|
input: { id: booking.id },
|
||||||
setPricingData(data);
|
enabled: !!booking.id,
|
||||||
queryClient.invalidateQueries({ queryKey: api.bookings.get.queryKey({ id: booking.id }) });
|
}),
|
||||||
},
|
);
|
||||||
});
|
|
||||||
|
|
||||||
const uploadMutation = useMutation({
|
const uploadMutation = useMutation({
|
||||||
mutationFn: (files: Record<string, File | File[] | null>) =>
|
mutationFn: (files: Record<string, File | File[] | null>) =>
|
||||||
@@ -187,6 +233,7 @@ function DraftBookingView({
|
|||||||
mutationFn: (reason: string) =>
|
mutationFn: (reason: string) =>
|
||||||
api.bookings.cancel.call({ id: booking.id, reason }),
|
api.bookings.cancel.call({ id: booking.id, reason }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
setCancelDialogOpen(false);
|
||||||
onBookingUpdated();
|
onBookingUpdated();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -211,17 +258,19 @@ function DraftBookingView({
|
|||||||
cancelMutation.mutate(reason);
|
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 companyName = (customer as any)?.company?.name ?? "—";
|
||||||
const companyTin = (customer as any)?.company?.tin ?? "—";
|
const companyTin = (customer as any)?.company?.tin ?? "—";
|
||||||
const contactName = (customer as any)?.profile
|
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 ?? "—";
|
const contactEmail = (customer as any)?.profile?.email ?? "—";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto max-w-5xl px-4 py-8">
|
<div className="container mx-auto px-4 py-8">
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<Breadcrumbs
|
<Breadcrumbs
|
||||||
items={[
|
items={[
|
||||||
@@ -251,14 +300,14 @@ function DraftBookingView({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{priceMutation.isError && (
|
{pricingQuery.isError && (
|
||||||
<div className="flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">
|
<div className="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">Pricing failed</p>
|
<p className="font-semibold">Pricing failed</p>
|
||||||
<p className="mt-1 text-red-600">
|
<p className="mt-1 text-red-600">
|
||||||
{priceMutation.error instanceof Error
|
{pricingQuery.error instanceof Error
|
||||||
? priceMutation.error.message
|
? pricingQuery.error.message
|
||||||
: "An unexpected error occurred."}
|
: "An unexpected error occurred."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -307,50 +356,85 @@ function DraftBookingView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Card className={cn(pricingData ? "border-emerald-200 bg-emerald-50/30" : "")}>
|
<Card
|
||||||
|
className={cn(
|
||||||
|
pricingQuery.isSuccess ? "border-emerald-200 bg-emerald-50/30" : "",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
<DollarSign className="size-4 text-primary" />
|
<DollarSign className="size-4 text-primary" />
|
||||||
Pricing Estimation
|
Pricing Estimation
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Generate a price estimate based on your booking details.
|
Price estimate based on your booking details.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{pricingData ? (
|
{pricingQuery.isLoading ? (
|
||||||
|
<div className="flex flex-col items-center gap-3 py-6 text-center">
|
||||||
|
<LoaderCircle className="size-6 animate-spin text-primary" />
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Calculating price…
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : pricingQuery.isError ? (
|
||||||
|
<div className="flex flex-col items-center gap-3 py-6 text-center">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Could not calculate price.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => pricingQuery.refetch()}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : pricingQuery.data ? (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="overflow-hidden rounded-lg border">
|
<div className="overflow-hidden rounded-lg border">
|
||||||
<table className="w-full text-left text-xs">
|
<table className="w-full text-left text-xs">
|
||||||
<thead className="bg-muted text-muted-foreground">
|
<thead className="bg-muted text-muted-foreground">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-2 font-semibold">Description</th>
|
<th className="px-4 py-2 font-semibold">Description</th>
|
||||||
<th className="px-4 py-2 font-semibold text-right">Amount</th>
|
<th className="px-4 py-2 font-semibold text-right">
|
||||||
|
Amount
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y">
|
<tbody className="divide-y">
|
||||||
{pricingData.lineItems.map((item, i) => (
|
{pricingQuery.data.lineItems.map((item, i) => (
|
||||||
<tr key={i}>
|
<tr key={i}>
|
||||||
<td className="px-4 py-2 text-foreground">{item.description}</td>
|
<td className="px-4 py-2 text-foreground">
|
||||||
|
{item.description}
|
||||||
|
</td>
|
||||||
<td className="px-4 py-2 text-right font-medium text-foreground">
|
<td className="px-4 py-2 text-right font-medium text-foreground">
|
||||||
{item.amount.toLocaleString()} {item.currency}
|
{item.amount.toLocaleString()} {item.currency}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
<tr className="bg-primary/5 font-bold">
|
<tr className="bg-primary/5 font-bold">
|
||||||
<td className="px-4 py-2 text-foreground">Total Estimated Cost</td>
|
<td className="px-4 py-2 text-foreground">
|
||||||
|
Total Estimated Cost
|
||||||
|
</td>
|
||||||
<td className="px-4 py-2 text-right text-foreground">
|
<td className="px-4 py-2 text-right text-foreground">
|
||||||
{pricingData.totalAmount.toLocaleString()} {pricingData.currency}
|
{pricingQuery.data.totalAmount.toLocaleString()}{" "}
|
||||||
|
{pricingQuery.data.currency}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{pricingData.warnings.length > 0 && (
|
{pricingQuery.data.warnings.length > 0 && (
|
||||||
<div className="flex flex-col gap-2 rounded-lg border border-amber-200 bg-amber-50 p-3">
|
<div className="flex flex-col gap-2 rounded-lg border border-amber-200 bg-amber-50 p-3">
|
||||||
{pricingData.warnings.map((w, i) => (
|
{pricingQuery.data.warnings.map((w, i) => (
|
||||||
<p key={i} className="flex items-start gap-2 text-xs text-amber-800">
|
<p
|
||||||
|
key={i}
|
||||||
|
className="flex items-start gap-2 text-xs text-amber-800"
|
||||||
|
>
|
||||||
<AlertTriangle className="mt-0.5 size-3 shrink-0" />
|
<AlertTriangle className="mt-0.5 size-3 shrink-0" />
|
||||||
{w}
|
{w}
|
||||||
</p>
|
</p>
|
||||||
@@ -362,40 +446,13 @@ function DraftBookingView({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="text-xs font-medium text-primary underline-offset-2 hover:underline"
|
className="text-xs font-medium text-primary underline-offset-2 hover:underline"
|
||||||
onClick={() => {
|
onClick={() => pricingQuery.refetch()}
|
||||||
setPricingData(null);
|
|
||||||
priceMutation.mutate();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Re-calculate pricing
|
Re-calculate pricing
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : null}
|
||||||
<div className="flex flex-col items-center gap-4 py-6 text-center">
|
|
||||||
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
|
|
||||||
<DollarSign className="size-6 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-foreground">No pricing yet</p>
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
Generate a price estimate to review before submitting.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={() => priceMutation.mutate()}
|
|
||||||
disabled={priceMutation.isPending}
|
|
||||||
>
|
|
||||||
{priceMutation.isPending ? (
|
|
||||||
<LoaderCircle className="mr-1 h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<DollarSign className="mr-1 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
{priceMutation.isPending ? "Calculating..." : "Request Pricing Estimation"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -406,8 +463,8 @@ function DraftBookingView({
|
|||||||
Required Documents
|
Required Documents
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Provide the necessary documents for this booking. Some information is
|
Provide the necessary documents for this booking. Some information
|
||||||
pre-filled from your company profile.
|
is pre-filled from your company profile.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-6">
|
<CardContent className="flex flex-col gap-6">
|
||||||
@@ -460,7 +517,10 @@ function DraftBookingView({
|
|||||||
accept=".pdf,.jpg,.jpeg,.png"
|
accept=".pdf,.jpg,.jpeg,.png"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
handleFileSelect(doc.key, e.target.files?.[0] ?? null);
|
handleFileSelect(
|
||||||
|
doc.key,
|
||||||
|
e.target.files?.[0] ?? null,
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -474,7 +534,9 @@ function DraftBookingView({
|
|||||||
onClick={() => fileInputRefs.current[doc.key]?.click()}
|
onClick={() => fileInputRefs.current[doc.key]?.click()}
|
||||||
>
|
>
|
||||||
<Upload className="size-3" />
|
<Upload className="size-3" />
|
||||||
{selectedFiles[doc.key] ? selectedFiles[doc.key]!.name : "Choose file"}
|
{selectedFiles[doc.key]
|
||||||
|
? selectedFiles[doc.key]!.name
|
||||||
|
: "Choose file"}
|
||||||
</button>
|
</button>
|
||||||
{selectedFiles[doc.key] && (
|
{selectedFiles[doc.key] && (
|
||||||
<button
|
<button
|
||||||
@@ -528,27 +590,61 @@ function DraftBookingView({
|
|||||||
If you no longer need this booking, you can cancel it.
|
If you no longer need this booking, you can cancel it.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
<CardContent className="flex flex-col gap-3">
|
||||||
<input
|
<p className="text-xs text-muted-foreground">
|
||||||
type="text"
|
Cancelling will terminate this booking request and cannot be
|
||||||
placeholder="Reason for cancellation (optional)"
|
undone.
|
||||||
className="flex-1 rounded-lg border border-border bg-background px-3 py-2 text-xs text-foreground outline-none focus:border-destructive/50"
|
</p>
|
||||||
value={cancelReason}
|
<Dialog open={cancelDialogOpen} onOpenChange={setCancelDialogOpen}>
|
||||||
onChange={(e) => setCancelReason(e.target.value)}
|
<DialogTrigger asChild>
|
||||||
/>
|
<Button type="button" variant="destructive" className="self-start">
|
||||||
<Button
|
<XCircle className="mr-1 h-4 w-4" />
|
||||||
type="button"
|
Cancel Booking
|
||||||
variant="destructive"
|
</Button>
|
||||||
onClick={handleCancel}
|
</DialogTrigger>
|
||||||
disabled={cancelMutation.isPending}
|
<DialogContent>
|
||||||
>
|
<DialogHeader>
|
||||||
{cancelMutation.isPending ? (
|
<DialogTitle>Cancel Booking</DialogTitle>
|
||||||
<LoaderCircle className="mr-1 h-4 w-4 animate-spin" />
|
<DialogDescription>
|
||||||
) : (
|
Are you sure you want to cancel this booking? This action
|
||||||
<XCircle className="mr-1 h-4 w-4" />
|
cannot be undone.
|
||||||
)}
|
</DialogDescription>
|
||||||
Cancel Booking
|
</DialogHeader>
|
||||||
</Button>
|
<div className="flex flex-col gap-3 py-2">
|
||||||
|
<label className="text-xs font-medium text-foreground">
|
||||||
|
Reason for cancellation
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Optional — provide a reason"
|
||||||
|
className="rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-destructive/50"
|
||||||
|
value={cancelReason}
|
||||||
|
onChange={(e) => setCancelReason(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="outline">
|
||||||
|
Keep Booking
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={cancelMutation.isPending}
|
||||||
|
>
|
||||||
|
{cancelMutation.isPending ? (
|
||||||
|
<LoaderCircle className="mr-1 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="mr-1 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Yes, Cancel Booking
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -556,9 +652,11 @@ function DraftBookingView({
|
|||||||
<div className="mx-auto flex max-w-5xl items-center justify-end gap-3">
|
<div className="mx-auto flex max-w-5xl items-center justify-end gap-3">
|
||||||
{!canConfirm && (
|
{!canConfirm && (
|
||||||
<p className="mr-auto text-xs text-muted-foreground">
|
<p className="mr-auto text-xs text-muted-foreground">
|
||||||
{!pricingData
|
{pricingQuery.isLoading
|
||||||
? "Request pricing estimation before confirming."
|
? "Calculating price…"
|
||||||
: "Upload documents before confirming."}
|
: pricingQuery.isError
|
||||||
|
? "Price calculation failed."
|
||||||
|
: "Upload documents before confirming."}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
@@ -599,7 +697,6 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
|||||||
return (
|
return (
|
||||||
<div className="container mx-auto max-w-7xl px-4 py-8">
|
<div className="container mx-auto max-w-7xl px-4 py-8">
|
||||||
<div className="flex flex-col gap-8">
|
<div className="flex flex-col gap-8">
|
||||||
|
|
||||||
<Breadcrumbs
|
<Breadcrumbs
|
||||||
items={[
|
items={[
|
||||||
{ label: "Bookings", href: "/bookings" },
|
{ label: "Bookings", href: "/bookings" },
|
||||||
@@ -631,26 +728,27 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{(booking.status === "CONFIRMED" || booking.status === "IN_TRANSIT") && (
|
{(booking.status === "CONFIRMED" ||
|
||||||
<Card className="border-primary/30 bg-primary/5">
|
booking.status === "IN_TRANSIT") && (
|
||||||
<CardContent className="flex flex-col gap-4 p-6 sm:flex-row sm:items-center sm:justify-between">
|
<Card className="border-primary/30 bg-primary/5">
|
||||||
<div>
|
<CardContent className="flex flex-col gap-4 p-6 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<p className="font-semibold text-foreground">Contract ready</p>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="font-semibold text-foreground">Contract ready</p>
|
||||||
Review the agreement and apply your digital signature.
|
<p className="text-sm text-muted-foreground">
|
||||||
</p>
|
Review the agreement and apply your digital signature.
|
||||||
</div>
|
</p>
|
||||||
<button
|
</div>
|
||||||
type="button"
|
<button
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground"
|
type="button"
|
||||||
onClick={() => navigate(`/bookings/${booking.id}/contract`)}
|
className="inline-flex items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground"
|
||||||
>
|
onClick={() => navigate(`/bookings/${booking.id}/contract`)}
|
||||||
<FileSignature className="size-4" />
|
>
|
||||||
View & sign contract
|
<FileSignature className="size-4" />
|
||||||
</button>
|
View & sign contract
|
||||||
</CardContent>
|
</button>
|
||||||
</Card>
|
</CardContent>
|
||||||
)}
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -658,14 +756,21 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
|||||||
<History className="size-4 text-primary" />
|
<History className="size-4 text-primary" />
|
||||||
Booking Status Lifecycle
|
Booking Status Lifecycle
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Track the journey from request to completion</CardDescription>
|
<CardDescription>
|
||||||
|
Track the journey from request to completion
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-8">
|
<CardContent className="flex flex-col gap-8">
|
||||||
<div className="relative flex w-full justify-between px-2">
|
<div className="relative flex w-full justify-between px-2">
|
||||||
<div className="absolute top-4 left-0 h-0.5 w-full bg-muted">
|
<div className="absolute top-4 left-0 h-0.5 w-full bg-muted">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-primary transition-all duration-500"
|
className="h-full bg-primary transition-all duration-500"
|
||||||
style={{ width: currentStageIndex >= 0 ? `${(currentStageIndex / (PROGRESS_STAGES.length - 1)) * 100}%` : '0%' }}
|
style={{
|
||||||
|
width:
|
||||||
|
currentStageIndex >= 0
|
||||||
|
? `${(currentStageIndex / (PROGRESS_STAGES.length - 1)) * 100}%`
|
||||||
|
: "0%",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -674,19 +779,32 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
|||||||
const isActive = idx === currentStageIndex;
|
const isActive = idx === currentStageIndex;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={stage.label} className="relative z-10 flex flex-col items-center gap-2">
|
<div
|
||||||
<div className={cn(
|
key={stage.label}
|
||||||
"flex size-8 items-center justify-center rounded-full border-2 transition-all duration-300 bg-background",
|
className="relative z-10 flex flex-col items-center gap-2"
|
||||||
isCompleted ? "border-primary text-primary" :
|
>
|
||||||
isActive ? "border-primary text-primary shadow-[0_0_10px_rgba(16,185,129,0.3)] scale-110" :
|
<div
|
||||||
"border-muted text-muted-foreground"
|
className={cn(
|
||||||
)}>
|
"flex size-8 items-center justify-center rounded-full border-2 transition-all duration-300 bg-background",
|
||||||
{isCompleted ? <CheckCircle2 className="size-4" /> : <stage.icon className="size-4" />}
|
isCompleted
|
||||||
|
? "border-primary text-primary"
|
||||||
|
: isActive
|
||||||
|
? "border-primary text-primary shadow-[0_0_10px_rgba(16,185,129,0.3)] scale-110"
|
||||||
|
: "border-muted text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isCompleted ? (
|
||||||
|
<CheckCircle2 className="size-4" />
|
||||||
|
) : (
|
||||||
|
<stage.icon className="size-4" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className={cn(
|
<span
|
||||||
"text-[9px] font-bold uppercase tracking-widest",
|
className={cn(
|
||||||
isActive ? "text-primary" : "text-muted-foreground"
|
"text-[9px] font-bold uppercase tracking-widest",
|
||||||
)}>
|
isActive ? "text-primary" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{stage.label}
|
{stage.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -695,26 +813,40 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 rounded-xl border border-border/50 bg-muted/20 p-5 md:flex-row md:items-center">
|
<div className="flex flex-col gap-4 rounded-xl border border-border/50 bg-muted/20 p-5 md:flex-row md:items-center">
|
||||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-background shadow-sm">
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-background shadow-sm">
|
||||||
{normalizedStatus === "CANCELLED" ? <AlertTriangle className="size-5 text-red-500" /> : <Info className="size-5 text-primary" />}
|
{normalizedStatus === "CANCELLED" ? (
|
||||||
</div>
|
<AlertTriangle className="size-5 text-red-500" />
|
||||||
<div className="flex flex-col gap-0.5">
|
) : (
|
||||||
<h4 className={cn("text-sm font-black uppercase tracking-tight", statusConfig.color)}>
|
<Info className="size-5 text-primary" />
|
||||||
{statusConfig.title}
|
)}
|
||||||
</h4>
|
</div>
|
||||||
<p className="text-xs font-medium text-muted-foreground">
|
<div className="flex flex-col gap-0.5">
|
||||||
{statusConfig.description}
|
<h4
|
||||||
</p>
|
className={cn(
|
||||||
</div>
|
"text-sm font-black uppercase tracking-tight",
|
||||||
{normalizedStatus !== "CANCELLED" && normalizedStatus !== "DELIVERED" && (
|
statusConfig.color,
|
||||||
<div className="ml-auto flex items-center gap-4 border-l border-border pl-6">
|
)}
|
||||||
|
>
|
||||||
|
{statusConfig.title}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
|
{statusConfig.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{normalizedStatus !== "CANCELLED" &&
|
||||||
|
normalizedStatus !== "DELIVERED" && (
|
||||||
|
<div className="ml-auto flex items-center gap-4 border-l border-border pl-6">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<p className="text-[9px] font-bold uppercase text-muted-foreground">Est. Waiting</p>
|
<p className="text-[9px] font-bold uppercase text-muted-foreground">
|
||||||
<p className="text-xs font-black text-foreground">1-2 Working Days</p>
|
Est. Waiting
|
||||||
|
</p>
|
||||||
|
<p className="text-xs font-black text-foreground">
|
||||||
|
1-2 Working Days
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<CreditCard className="size-5 text-muted-foreground" />
|
<CreditCard className="size-5 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -740,7 +872,10 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
|||||||
<Train className="size-5" />
|
<Train className="size-5" />
|
||||||
<ArrowRight className="size-4" />
|
<ArrowRight className="size-4" />
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline" className="border-primary/20 bg-primary/5 text-[9px] font-bold uppercase">
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="border-primary/20 bg-primary/5 text-[9px] font-bold uppercase"
|
||||||
|
>
|
||||||
Rail
|
Rail
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
@@ -752,9 +887,31 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
<InfoItem icon={<Layers />} label="Service" value={booking.serviceType === "RAIL_AND_FORWARDING" ? "Rail & Forwarding" : "Rail Only"} />
|
<InfoItem
|
||||||
<InfoItem icon={<ShieldCheck />} label="Return" value={booking.equipmentReturn === "WITH_RETURN" ? "With Return" : "Without Return"} />
|
icon={<Layers />}
|
||||||
<InfoItem icon={<FileText />} label="Trade" value={booking.tradeDirection === "IMPORT" ? "Import" : "Export"} />
|
label="Service"
|
||||||
|
value={
|
||||||
|
booking.serviceType === "RAIL_AND_FORWARDING"
|
||||||
|
? "Rail & Forwarding"
|
||||||
|
: "Rail Only"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InfoItem
|
||||||
|
icon={<ShieldCheck />}
|
||||||
|
label="Return"
|
||||||
|
value={
|
||||||
|
booking.equipmentReturn === "WITH_RETURN"
|
||||||
|
? "With Return"
|
||||||
|
: "Without Return"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InfoItem
|
||||||
|
icon={<FileText />}
|
||||||
|
label="Trade"
|
||||||
|
value={
|
||||||
|
booking.tradeDirection === "IMPORT" ? "Import" : "Export"
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -771,14 +928,23 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
|||||||
<h3 className="border-l-4 border-primary pl-3 text-xs font-bold uppercase tracking-wide text-foreground">
|
<h3 className="border-l-4 border-primary pl-3 text-xs font-bold uppercase tracking-wide text-foreground">
|
||||||
First Mile
|
First Mile
|
||||||
</h3>
|
</h3>
|
||||||
<InfoItem label="Address" value={booking.firstMileEnabled && booking.firstMilePickupAddress ? booking.firstMilePickupAddress : "Not requested"} />
|
<InfoItem
|
||||||
|
label="Address"
|
||||||
|
value={
|
||||||
|
booking.firstMileEnabled && booking.firstMilePickupAddress
|
||||||
|
? booking.firstMilePickupAddress
|
||||||
|
: "Not requested"
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<h3 className="border-l-4 border-primary pl-3 text-xs font-bold uppercase tracking-wide text-foreground">
|
<h3 className="border-l-4 border-primary pl-3 text-xs font-bold uppercase tracking-wide text-foreground">
|
||||||
Last Mile
|
Last Mile
|
||||||
</h3>
|
</h3>
|
||||||
<p className="pl-4 text-xs text-muted-foreground italic">
|
<p className="pl-4 text-xs text-muted-foreground italic">
|
||||||
{booking.lastMileEnabled && booking.lastMileDeliveryAddress ? booking.lastMileDeliveryAddress : "Not requested"}
|
{booking.lastMileEnabled && booking.lastMileDeliveryAddress
|
||||||
|
? booking.lastMileDeliveryAddress
|
||||||
|
: "Not requested"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -793,31 +959,57 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-6">
|
<CardContent className="flex flex-col gap-6">
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
<InfoItem icon={<Package />} label="Freight Type" value={booking.freightType === "BULK" ? "Bulk" : "Break Bulk"} />
|
<InfoItem
|
||||||
<InfoItem icon={<Weight />} label="Weight (VGM)" value={`${booking.cargoTotalWeightVgm} Tons`} />
|
icon={<Package />}
|
||||||
<InfoItem icon={<Ship />} label="Currency" value={booking.paymentCurrency} />
|
label="Freight Type"
|
||||||
|
value={
|
||||||
|
booking.freightType === "BULK" ? "Bulk" : "Break Bulk"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InfoItem
|
||||||
|
icon={<Weight />}
|
||||||
|
label="Weight (VGM)"
|
||||||
|
value={`${booking.cargoTotalWeightVgm} Tons`}
|
||||||
|
/>
|
||||||
|
<InfoItem
|
||||||
|
icon={<Ship />}
|
||||||
|
label="Currency"
|
||||||
|
value={booking.paymentCurrency}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{booking.containers && booking.containers.length > 0 && (
|
{booking.containers && booking.containers.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<h3 className="text-xs font-bold uppercase tracking-wide text-foreground">Load Details</h3>
|
<h3 className="text-xs font-bold uppercase tracking-wide text-foreground">
|
||||||
|
Load Details
|
||||||
|
</h3>
|
||||||
<div className="rounded-lg border overflow-hidden">
|
<div className="rounded-lg border overflow-hidden">
|
||||||
<table className="w-full text-left text-xs">
|
<table className="w-full text-left text-xs">
|
||||||
<thead className="bg-muted text-muted-foreground">
|
<thead className="bg-muted text-muted-foreground">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-3 py-2 font-semibold">Type</th>
|
<th className="px-3 py-2 font-semibold">Type</th>
|
||||||
<th className="px-3 py-2 font-semibold text-center">Quantity</th>
|
<th className="px-3 py-2 font-semibold text-center">
|
||||||
<th className="px-3 py-2 font-semibold text-right">VGM (Tons)</th>
|
Quantity
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2 font-semibold text-right">
|
||||||
|
VGM (Tons)
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y">
|
<tbody className="divide-y">
|
||||||
{booking.containers.map((c, i) => (
|
{booking.containers.map((c, i) => (
|
||||||
<tr key={i}>
|
<tr key={i}>
|
||||||
<td className="px-3 py-2 font-medium">{c.type}</td>
|
<td className="px-3 py-2 font-medium">
|
||||||
<td className="px-3 py-2 text-center">{c.qty} Units</td>
|
{c.type}
|
||||||
<td className="px-3 py-2 text-right">{c.vgm}t</td>
|
</td>
|
||||||
|
<td className="px-3 py-2 text-center">
|
||||||
|
{c.qty} Units
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right">
|
||||||
|
{c.vgm}t
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -839,7 +1031,10 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-4">
|
<CardContent className="flex flex-col gap-4">
|
||||||
<InfoItem label="Type" value={booking.contractType === "RENEWAL" ? "Renewal" : "New"} />
|
<InfoItem
|
||||||
|
label="Type"
|
||||||
|
value={booking.contractType === "RENEWAL" ? "Renewal" : "New"}
|
||||||
|
/>
|
||||||
<InfoItem label="Customer ID" value={booking.customerId} />
|
<InfoItem label="Customer ID" value={booking.customerId} />
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -860,15 +1055,21 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
|||||||
<CardContent className="flex flex-col gap-4">
|
<CardContent className="flex flex-col gap-4">
|
||||||
{booking.freightSubtype && (
|
{booking.freightSubtype && (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<p className="text-[9px] font-bold uppercase tracking-tight text-muted-foreground">Cargo Description</p>
|
<p className="text-[9px] font-bold uppercase tracking-tight text-muted-foreground">
|
||||||
<p className="text-xs text-foreground leading-relaxed italic">"{booking.freightSubtype}"</p>
|
Cargo Description
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-foreground leading-relaxed italic">
|
||||||
|
"{booking.freightSubtype}"
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{booking.financialTerms && (
|
{booking.financialTerms && (
|
||||||
<>
|
<>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<p className="text-[9px] font-bold uppercase tracking-tight text-muted-foreground">Financial Terms</p>
|
<p className="text-[9px] font-bold uppercase tracking-tight text-muted-foreground">
|
||||||
|
Financial Terms
|
||||||
|
</p>
|
||||||
<div className="rounded-lg bg-amber-50/50 border border-amber-100 p-2">
|
<div className="rounded-lg bg-amber-50/50 border border-amber-100 p-2">
|
||||||
<p className="text-xs text-amber-900 flex gap-2">
|
<p className="text-xs text-amber-900 flex gap-2">
|
||||||
<StickyNote className="size-3 shrink-0 mt-0.5 text-amber-500" />
|
<StickyNote className="size-3 shrink-0 mt-0.5 text-amber-500" />
|
||||||
@@ -879,7 +1080,9 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!booking.freightSubtype && !booking.financialTerms && (
|
{!booking.freightSubtype && !booking.financialTerms && (
|
||||||
<p className="text-xs text-muted-foreground italic">No additional information provided.</p>
|
<p className="text-xs text-muted-foreground italic">
|
||||||
|
No additional information provided.
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -917,17 +1120,23 @@ function RouteEndpoint({
|
|||||||
function InfoItem({
|
function InfoItem({
|
||||||
icon,
|
icon,
|
||||||
label,
|
label,
|
||||||
value
|
value,
|
||||||
}: {
|
}: {
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
label: string;
|
label: string;
|
||||||
value?: string | number | null
|
value?: string | number | null;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
{icon && <div className="mt-0.5 text-muted-foreground [&_svg]:size-3.5">{icon}</div>}
|
{icon && (
|
||||||
|
<div className="mt-0.5 text-muted-foreground [&_svg]:size-3.5">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<p className="text-[9px] font-bold uppercase tracking-tight text-muted-foreground">{label}</p>
|
<p className="text-[9px] font-bold uppercase tracking-tight text-muted-foreground">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
<p className="text-xs font-bold text-foreground">{value ?? "—"}</p>
|
<p className="text-xs font-bold text-foreground">{value ?? "—"}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -946,9 +1155,12 @@ function StatusBadge({ status }: { status: string }) {
|
|||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn("px-2 py-0.5 font-bold uppercase tracking-wider text-[9px]", statusColors[status] || "bg-muted")}
|
className={cn(
|
||||||
|
"px-2 py-0.5 font-bold uppercase tracking-wider text-[9px]",
|
||||||
|
statusColors[status] || "bg-muted",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{status.replace(/_/g, ' ')}
|
{status.replace(/_/g, " ")}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ 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,
|
||||||
@@ -28,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,
|
||||||
|
|||||||
5
tasks.md
5
tasks.md
@@ -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
|
|
||||||
Reference in New Issue
Block a user