diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index e5e829acb..390ecbbdb 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -10,7 +10,8 @@ "lint": "eslint src", "test": "jest", "test:e2e": "jest --config ./test/jest-e2e.json", - "type-check": "tsc --noEmit" + "type-check": "tsc --noEmit", + "seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts" }, "dependencies": { "@edr/api-common": "workspace:*", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index b753cfc99..4242f685e 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -19,6 +19,7 @@ import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module"; import { TrainSetsModule } from "./modules/train-sets/train-sets.module"; import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module"; import { TrainSchedulingModule } from "./modules/train-scheduling/train-scheduling.module"; +import { SchedulingRescheduleModule } from "./modules/scheduling-reschedule/scheduling-reschedule.module"; import { CustomersModule } from "./modules/customers/customers.module"; import { CompaniesModule } from "./modules/companies/companies.module"; import { TrackingModule } from "./modules/tracking/tracking.module"; @@ -82,6 +83,7 @@ import { OverviewModule } from './modules/overview/overview.module'; TrainSetsModule, TrainSchedulesModule, TrainSchedulingModule, + SchedulingRescheduleModule, CustomersModule, CompaniesModule, TrackingModule, diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index 2ebae8175..393a97f9f 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -15,3 +15,9 @@ export const BookingStaff = (permission: string | string[]) => ); export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view); + +export const TrainSchedulingView = () => + BookingStaff(FREIGHT_PERMS.trainScheduling.view); + +export const TrainSchedulingManage = () => + BookingStaff(FREIGHT_PERMS.trainScheduling.manage); diff --git a/apps/edr-freight-api/src/config/app.config.ts b/apps/edr-freight-api/src/config/app.config.ts index 8fa1ac47d..4f7ec23bb 100644 --- a/apps/edr-freight-api/src/config/app.config.ts +++ b/apps/edr-freight-api/src/config/app.config.ts @@ -1,7 +1,17 @@ import { registerAs } from "@nestjs/config"; +const numberFromEnv = (key: string, fallback: number): number => { + const value = Number(process.env[key]); + return Number.isFinite(value) && value > 0 ? value : fallback; +}; + export default registerAs("app", () => ({ env: process.env.NODE_ENV ?? "development", port: parseInt(process.env.PORT ?? "3001", 10), apiPrefix: "api", + trainScheduling: { + maxTrainWeightTons: numberFromEnv("TRAIN_SCHEDULING_MAX_WEIGHT_TONS", 3500), + maxTrainLengthMeters: numberFromEnv("TRAIN_SCHEDULING_MAX_LENGTH_METERS", 760), + maxWagonsPerTrain: numberFromEnv("TRAIN_SCHEDULING_MAX_WAGONS_PER_TRAIN", 53), + }, })); diff --git a/apps/edr-freight-api/src/contracts/contract-pricing-schedule.builder.ts b/apps/edr-freight-api/src/contracts/contract-pricing-schedule.builder.ts index dd961a14b..d5439ff11 100644 --- a/apps/edr-freight-api/src/contracts/contract-pricing-schedule.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-pricing-schedule.builder.ts @@ -61,7 +61,10 @@ export class ContractPricingScheduleBuilder { booking.destinationYard?.label ?? booking.destinationYard?.code ?? '—', containerLines: (booking.bookingContainers ?? []).map((c) => ({ label: - c.containerType?.label ?? c.containerType?.code ?? c.containerTypeId, + c.containerType?.label ?? + c.containerType?.code ?? + c.containerTypeId ?? + '—', quantity: c.quantity, vgmPerUnitTons: Number(c.vgmPerUnitTons), })), diff --git a/apps/edr-freight-api/src/migrations/1750400000000-AddSchedulingAllocationEnhancements.ts b/apps/edr-freight-api/src/migrations/1750400000000-AddSchedulingAllocationEnhancements.ts new file mode 100644 index 000000000..a0ca64303 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750400000000-AddSchedulingAllocationEnhancements.ts @@ -0,0 +1,321 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddSchedulingAllocationEnhancements1750400000000 + implements MigrationInterface +{ + name = 'AddSchedulingAllocationEnhancements1750400000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS wagons_required NUMERIC(6,2) NULL, + ADD COLUMN IF NOT EXISTS scheduling_status VARCHAR(30) NOT NULL DEFAULT 'NOT_SCHEDULED', + ADD COLUMN IF NOT EXISTS hold_started_at TIMESTAMPTZ NULL, + ADD COLUMN IF NOT EXISTS hold_expires_at TIMESTAMPTZ NULL, + ADD COLUMN IF NOT EXISTS scheduled_at TIMESTAMPTZ NULL; + `); + + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS train_number VARCHAR(20) NULL, + ADD COLUMN IF NOT EXISTS direction VARCHAR(10) NULL, + ADD COLUMN IF NOT EXISTS actual_departure_at TIMESTAMPTZ NULL, + ADD COLUMN IF NOT EXISTS actual_arrival_at TIMESTAMPTZ NULL, + ADD COLUMN IF NOT EXISTS prepared_by_user_id UUID NULL, + ADD COLUMN IF NOT EXISTS checked_by_user_id UUID NULL, + ADD COLUMN IF NOT EXISTS max_wagons INT NOT NULL DEFAULT 53; + `); + + await queryRunner.query(` + ALTER TABLE freight.train_set_wagons + ADD COLUMN IF NOT EXISTS physical_wagon_id UUID NULL, + ADD COLUMN IF NOT EXISTS status VARCHAR(20) NOT NULL DEFAULT 'PLANNED'; + `); + + await queryRunner.query(` + ALTER TABLE freight.wagon_booking_allocations + ADD COLUMN IF NOT EXISTS load_type VARCHAR(20) NULL, + ADD COLUMN IF NOT EXISTS status VARCHAR(20) NOT NULL DEFAULT 'PLANNED', + ADD COLUMN IF NOT EXISTS confirmed_at TIMESTAMPTZ NULL, + ADD COLUMN IF NOT EXISTS confirmed_by_user_id UUID NULL; + `); + + await queryRunner.query(` + ALTER TABLE freight.wagon_types + ADD COLUMN IF NOT EXISTS equated_length_m NUMERIC(10,3) NULL, + ADD COLUMN IF NOT EXISTS tare_weight_tons NUMERIC(10,3) NULL, + ADD COLUMN IF NOT EXISTS supports_container BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS max_container_gross_t NUMERIC(10,3) NULL; + `); + + await queryRunner.query(` + ALTER TABLE freight.wagons + ADD COLUMN IF NOT EXISTS train_set_wagon_id UUID NULL, + ADD COLUMN IF NOT EXISTS current_train_schedule_id UUID NULL; + `); + + await queryRunner.query(` + ALTER TABLE freight.containers + ADD COLUMN IF NOT EXISTS booking_id UUID NULL, + ADD COLUMN IF NOT EXISTS wagon_booking_allocation_id UUID NULL, + ADD COLUMN IF NOT EXISTS booking_container_id UUID NULL; + `); + + await queryRunner.query(` + ALTER TABLE freight.cargoes + ADD COLUMN IF NOT EXISTS wagon_booking_allocation_id UUID NULL, + ADD COLUMN IF NOT EXISTS booking_id UUID NULL, + ADD COLUMN IF NOT EXISTS load_type VARCHAR(20) NULL; + `); + + await queryRunner.query(` + ALTER TABLE freight.cargoes + ALTER COLUMN container_id DROP NOT NULL; + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.wagon_allocation_container_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + wagon_booking_allocation_id UUID NOT NULL, + booking_container_id UUID NULL, + container_id UUID NULL, + container_number VARCHAR(64) NULL, + container_type_id UUID NOT NULL, + position_on_wagon SMALLINT NULL, + seal_number VARCHAR(64) NULL, + chassis_number VARCHAR(64) NULL, + gross_weight_tons NUMERIC(10,3) NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL, + CONSTRAINT fk_waci_allocation FOREIGN KEY (wagon_booking_allocation_id) + REFERENCES freight.wagon_booking_allocations(id) ON DELETE CASCADE, + CONSTRAINT fk_waci_booking_container FOREIGN KEY (booking_container_id) + REFERENCES freight.booking_container(id) ON DELETE SET NULL, + CONSTRAINT fk_waci_container FOREIGN KEY (container_id) + REFERENCES freight.containers(id) ON DELETE SET NULL, + CONSTRAINT fk_waci_container_type FOREIGN KEY (container_type_id) + REFERENCES freight.container_types(id) + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.wagon_allocation_bulk_loads ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + wagon_booking_allocation_id UUID NOT NULL UNIQUE, + booking_id UUID NOT NULL, + cargo_type_id UUID NULL, + cargo_description TEXT NULL, + pricing_unit VARCHAR(20) NOT NULL DEFAULT 'PER_TON', + quantity NUMERIC(12,3) NOT NULL DEFAULT 0, + weight_tons NUMERIC(10,3) NOT NULL DEFAULT 0, + truck_plate_number VARCHAR(32) NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL, + CONSTRAINT fk_wabl_allocation FOREIGN KEY (wagon_booking_allocation_id) + REFERENCES freight.wagon_booking_allocations(id) ON DELETE CASCADE, + CONSTRAINT fk_wabl_booking FOREIGN KEY (booking_id) + REFERENCES freight.bookings(id), + CONSTRAINT fk_wabl_cargo_type FOREIGN KEY (cargo_type_id) + REFERENCES freight.cargo_types(id) ON DELETE SET NULL + ); + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_bookings_scheduling_status + ON freight.bookings(scheduling_status); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_train_schedules_train_number + ON freight.train_schedules(train_number); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_train_set_wagons_physical_wagon + ON freight.train_set_wagons(physical_wagon_id); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wagons_train_set_wagon_id + ON freight.wagons(train_set_wagon_id); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wagons_current_train_schedule_id + ON freight.wagons(current_train_schedule_id); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_waci_allocation + ON freight.wagon_allocation_container_items(wagon_booking_allocation_id); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wabl_booking + ON freight.wagon_allocation_bulk_loads(booking_id); + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.train_set_wagons + ADD CONSTRAINT fk_train_set_wagons_physical_wagon + FOREIGN KEY (physical_wagon_id) REFERENCES freight.wagons(id) ON DELETE SET NULL; + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + `); + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.wagons + ADD CONSTRAINT fk_wagons_train_set_wagon + FOREIGN KEY (train_set_wagon_id) REFERENCES freight.train_set_wagons(id) ON DELETE SET NULL; + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + `); + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.wagons + ADD CONSTRAINT fk_wagons_current_train_schedule + FOREIGN KEY (current_train_schedule_id) REFERENCES freight.train_schedules(id) ON DELETE SET NULL; + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + `); + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.containers + ADD CONSTRAINT fk_containers_booking + FOREIGN KEY (booking_id) REFERENCES freight.bookings(id) ON DELETE SET NULL; + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + `); + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.containers + ADD CONSTRAINT fk_containers_wagon_allocation + FOREIGN KEY (wagon_booking_allocation_id) REFERENCES freight.wagon_booking_allocations(id) ON DELETE SET NULL; + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + `); + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.containers + ADD CONSTRAINT fk_containers_booking_container + FOREIGN KEY (booking_container_id) REFERENCES freight.booking_container(id) ON DELETE SET NULL; + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + `); + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.cargoes + ADD CONSTRAINT fk_cargoes_wagon_allocation + FOREIGN KEY (wagon_booking_allocation_id) REFERENCES freight.wagon_booking_allocations(id) ON DELETE SET NULL; + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + `); + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE freight.cargoes + ADD CONSTRAINT fk_cargoes_booking + FOREIGN KEY (booking_id) REFERENCES freight.bookings(id) ON DELETE SET NULL; + EXCEPTION WHEN duplicate_object THEN NULL; + END $$; + `); + + await queryRunner.query(` + UPDATE freight.wagon_types SET + equated_length_m = 1.3, + tare_weight_tons = 22.4, + supports_container = true, + max_container_gross_t = 30.48 + WHERE code = 'NW5'; + `); + await queryRunner.query(` + UPDATE freight.wagon_types SET + equated_length_m = 1.6, + tare_weight_tons = 25.2, + supports_container = false + WHERE code = 'PW2'; + `); + await queryRunner.query(` + UPDATE freight.wagon_types SET + equated_length_m = 1.5, + tare_weight_tons = 25.2, + supports_container = false + WHERE code = 'KW2'; + `); + await queryRunner.query(` + UPDATE freight.wagon_types SET + equated_length_m = 1.3, + tare_weight_tons = 23.4, + supports_container = false + WHERE code = 'CW3'; + `); + await queryRunner.query(` + UPDATE freight.wagon_types SET + equated_length_m = 1.3, + tare_weight_tons = 24.8, + supports_container = false + WHERE code = 'CW4'; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_allocation_bulk_loads;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_allocation_container_items;`); + + await queryRunner.query(` + ALTER TABLE freight.cargoes + ALTER COLUMN container_id SET NOT NULL; + `); + + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS wagons_required, + DROP COLUMN IF EXISTS scheduling_status, + DROP COLUMN IF EXISTS hold_started_at, + DROP COLUMN IF EXISTS hold_expires_at, + DROP COLUMN IF EXISTS scheduled_at; + `); + await queryRunner.query(` + ALTER TABLE freight.train_schedules + DROP COLUMN IF EXISTS train_number, + DROP COLUMN IF EXISTS direction, + DROP COLUMN IF EXISTS actual_departure_at, + DROP COLUMN IF EXISTS actual_arrival_at, + DROP COLUMN IF EXISTS prepared_by_user_id, + DROP COLUMN IF EXISTS checked_by_user_id, + DROP COLUMN IF EXISTS max_wagons; + `); + await queryRunner.query(` + ALTER TABLE freight.train_set_wagons + DROP COLUMN IF EXISTS physical_wagon_id, + DROP COLUMN IF EXISTS status; + `); + await queryRunner.query(` + ALTER TABLE freight.wagon_booking_allocations + DROP COLUMN IF EXISTS load_type, + DROP COLUMN IF EXISTS status, + DROP COLUMN IF EXISTS confirmed_at, + DROP COLUMN IF EXISTS confirmed_by_user_id; + `); + await queryRunner.query(` + ALTER TABLE freight.wagon_types + DROP COLUMN IF EXISTS equated_length_m, + DROP COLUMN IF EXISTS tare_weight_tons, + DROP COLUMN IF EXISTS supports_container, + DROP COLUMN IF EXISTS max_container_gross_t; + `); + await queryRunner.query(` + ALTER TABLE freight.wagons + DROP COLUMN IF EXISTS train_set_wagon_id, + DROP COLUMN IF EXISTS current_train_schedule_id; + `); + await queryRunner.query(` + ALTER TABLE freight.containers + DROP COLUMN IF EXISTS booking_id, + DROP COLUMN IF EXISTS wagon_booking_allocation_id, + DROP COLUMN IF EXISTS booking_container_id; + `); + await queryRunner.query(` + ALTER TABLE freight.cargoes + DROP COLUMN IF EXISTS wagon_booking_allocation_id, + DROP COLUMN IF EXISTS booking_id, + DROP COLUMN IF EXISTS load_type; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1750500000000-AddWagonReadiness.ts b/apps/edr-freight-api/src/migrations/1750500000000-AddWagonReadiness.ts new file mode 100644 index 000000000..17cc23f9a --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750500000000-AddWagonReadiness.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddWagonReadiness1750500000000 implements MigrationInterface { + name = 'AddWagonReadiness1750500000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagons + ADD COLUMN IF NOT EXISTS readiness VARCHAR(20) NOT NULL DEFAULT 'IMPORT_READY' + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wagons_readiness + ON freight.wagons (readiness) + WHERE deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wagons_readiness`); + await queryRunner.query(` + ALTER TABLE freight.wagons + DROP COLUMN IF EXISTS readiness + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1750600000000-AddGovernmentBookingFields.ts b/apps/edr-freight-api/src/migrations/1750600000000-AddGovernmentBookingFields.ts new file mode 100644 index 000000000..ce833e4ac --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750600000000-AddGovernmentBookingFields.ts @@ -0,0 +1,46 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddGovernmentBookingFields1750600000000 implements MigrationInterface { + name = 'AddGovernmentBookingFields1750600000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS is_government BOOLEAN NOT NULL DEFAULT false + `); + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS government_institution VARCHAR(255) NULL + `); + await queryRunner.query(` + ALTER TABLE freight.bookings + ALTER COLUMN company_id DROP NOT NULL + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_bookings_is_government + ON freight.bookings (is_government) + WHERE is_government = true AND deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_is_government`); + await queryRunner.query(` + UPDATE freight.bookings + SET company_id = '00000000-0000-0000-0000-000000000000' + WHERE company_id IS NULL + `); + await queryRunner.query(` + ALTER TABLE freight.bookings + ALTER COLUMN company_id SET NOT NULL + `); + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS government_institution + `); + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS is_government + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1750700000000-CreateSchedulingEvents.ts b/apps/edr-freight-api/src/migrations/1750700000000-CreateSchedulingEvents.ts new file mode 100644 index 000000000..9f92f70b4 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750700000000-CreateSchedulingEvents.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateSchedulingEvents1750700000000 implements MigrationInterface { + name = 'CreateSchedulingEvents1750700000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.scheduling_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + train_schedule_id UUID NOT NULL, + trigger VARCHAR(40) NOT NULL, + actor_user_id UUID NULL, + reason TEXT NULL, + plan_snapshot JSONB NOT NULL DEFAULT '{}', + displaced_booking_ids JSONB NOT NULL DEFAULT '[]', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ NULL + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_scheduling_events_train_schedule_id + ON freight.scheduling_events (train_schedule_id) + WHERE deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_scheduling_events_train_schedule_id`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.scheduling_events`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1750800000000-FixContainerWagonsPerUnit.ts b/apps/edr-freight-api/src/migrations/1750800000000-FixContainerWagonsPerUnit.ts new file mode 100644 index 000000000..1bb1a9518 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750800000000-FixContainerWagonsPerUnit.ts @@ -0,0 +1,47 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** 20ft = 0.5 wagon slots (2 per wagon); 40ft = 1.0 wagon slot (1 per wagon). */ +export class FixContainerWagonsPerUnit1750800000000 implements MigrationInterface { + name = 'FixContainerWagonsPerUnit1750800000000'; + + public async up(queryRunner: QueryRunner): Promise { + const hasContainerTypes = await queryRunner.hasTable('freight.container_types'); + if (!hasContainerTypes) { + return; + } + + await queryRunner.query(` + UPDATE freight.container_types + SET wagons_per_unit = 0.50 + WHERE size_ft = 20 OR code LIKE '20%'; + `); + await queryRunner.query(` + UPDATE freight.container_types + SET wagons_per_unit = 1.00 + WHERE size_ft = 40 OR code LIKE '40%'; + `); + + const hasBookingContainer = await queryRunner.hasTable('freight.booking_container'); + if (!hasBookingContainer) { + return; + } + + await queryRunner.query(` + UPDATE freight.booking_container bc + SET wagons_required = CEILING(bc.quantity * ct.wagons_per_unit) + FROM freight.container_types ct + WHERE ct.id = bc.container_type_id; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + const hasContainerTypes = await queryRunner.hasTable('freight.container_types'); + if (!hasContainerTypes) { + return; + } + + await queryRunner.query(` + UPDATE freight.container_types SET wagons_per_unit = 1.00; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1750900000000-AddContainerNumberToBookingContainer.ts b/apps/edr-freight-api/src/migrations/1750900000000-AddContainerNumberToBookingContainer.ts new file mode 100644 index 000000000..eb28da8f3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750900000000-AddContainerNumberToBookingContainer.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddContainerNumberToBookingContainer1750900000000 implements MigrationInterface { + name = "AddContainerNumberToBookingContainer1750900000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.booking_container + ALTER COLUMN container_type_id DROP NOT NULL; + `); + + await queryRunner.query(` + ALTER TABLE freight.booking_container + ADD COLUMN container_number varchar(64); + `); + + await queryRunner.query(` + ALTER TABLE freight.wagon_allocation_container_items + ALTER COLUMN container_type_id DROP NOT NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagon_allocation_container_items + ALTER COLUMN container_type_id SET NOT NULL; + `); + + await queryRunner.query(` + ALTER TABLE freight.booking_container + DROP COLUMN container_number; + `); + + await queryRunner.query(` + ALTER TABLE freight.booking_container + ALTER COLUMN container_type_id SET NOT NULL; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1751000000000-CreateTrainSchedulingGlobalRules.ts b/apps/edr-freight-api/src/migrations/1751000000000-CreateTrainSchedulingGlobalRules.ts new file mode 100644 index 000000000..ac9741c5a --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1751000000000-CreateTrainSchedulingGlobalRules.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CreateTrainSchedulingGlobalRules1751000000000 implements MigrationInterface { + name = "CreateTrainSchedulingGlobalRules1751000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE freight.train_scheduling_global_rules ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + max_train_length_meters numeric(10, 2) NOT NULL DEFAULT 760, + max_train_weight_tons numeric(10, 3) NOT NULL DEFAULT 3500, + max_wagons_per_train integer NOT NULL DEFAULT 53, + max_20ft_container_weight_tons numeric(8, 3) NOT NULL DEFAULT 30, + max_20ft_pair_weight_diff_tons numeric(8, 3) NOT NULL DEFAULT 10, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL + ); + `); + + await queryRunner.query(` + INSERT INTO freight.train_scheduling_global_rules ( + max_train_length_meters, + max_train_weight_tons, + max_wagons_per_train, + max_20ft_container_weight_tons, + max_20ft_pair_weight_diff_tons + ) VALUES (760, 3500, 53, 30, 10); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.train_scheduling_global_rules;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1751000000001-AddDeletedAtToTrainSchedulingGlobalRules.ts b/apps/edr-freight-api/src/migrations/1751000000001-AddDeletedAtToTrainSchedulingGlobalRules.ts new file mode 100644 index 000000000..fd72f6c3b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1751000000001-AddDeletedAtToTrainSchedulingGlobalRules.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddDeletedAtToTrainSchedulingGlobalRules1751000000001 + implements MigrationInterface +{ + name = "AddDeletedAtToTrainSchedulingGlobalRules1751000000001"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + ADD COLUMN IF NOT EXISTS deleted_at timestamptz NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + DROP COLUMN IF EXISTS deleted_at; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index d0b74dfc8..b0d4121a9 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -159,18 +159,20 @@ export class BookingPricingService { async buildEvalInputForBooking(booking: Booking): Promise { const containers = await Promise.all( - (booking.bookingContainers ?? []).map(async (bc) => { - const ct = await this.containerTypesService.findById(bc.containerTypeId); - const vgm = Number(bc.vgmPerUnitTons); - const qty = bc.quantity; - return { - containerTypeId: bc.containerTypeId, - quantity: qty, - vgmPerUnitTons: vgm, - totalVgmTons: qty * vgm, - isReefer: ct.isReefer, - }; - }), + (booking.bookingContainers ?? []) + .filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null) + .map(async (bc) => { + const ct = await this.containerTypesService.findById(bc.containerTypeId); + const vgm = Number(bc.vgmPerUnitTons); + const qty = bc.quantity; + return { + containerTypeId: bc.containerTypeId, + quantity: qty, + vgmPerUnitTons: vgm, + totalVgmTons: qty * vgm, + isReefer: ct.isReefer, + }; + }), ); return { freightType: booking.freightType as 'CONTAINER' | 'BULK', @@ -179,6 +181,7 @@ export class BookingPricingService { paymentCurrency: booking.paymentCurrency, tradeDirection: booking.tradeDirection, isHazardous: booking.isHazardous, + isGovernment: booking.isGovernment, allowConsolidation: booking.allowConsolidation, shippingLineId: booking.shippingLineId, containers, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 50cf3f345..12d6e0d5a 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -54,6 +54,7 @@ import { type AuthUserPayload, resolveAuthUserId, } from '../../common/resolve-auth-user-id'; +import { assertFreightPermission } from '../../common/freight-permission.util'; @ApiTags('bookings') @Controller('bookings') @@ -75,10 +76,12 @@ export class BookingsController { create( @Body() dto: CreateBookingDto, @UploadedFiles() files: Express.Multer.File[], - @Request() req: { user?: { id?: string; sub?: string } }, + @CurrentUser() user: TCurrentUser, ) { - const userId = req.user?.id ?? req.user?.sub; - return this.bookingsService.create(dto, files ?? [], userId); + if (dto.isGovernment) { + assertFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept); + } + return this.bookingsService.create(dto, files ?? [], user?.id); } @Patch(':id') @@ -244,6 +247,20 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + @Post(':id/government-expedite') + @BookingStaff(FREIGHT_PERMS.bookings.staffAccept) + @ApiOperation({ summary: 'Expedite government booking to PAID / ELIGIBLE for scheduling' }) + async governmentExpedite( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUserPayload, + ) { + const booking = await this.bookingsService.governmentExpedite( + id, + resolveAuthUserId(user), + ); + return this.transitionService.enrichBookingResponse(booking); + } + @Post(':id/approval-steps/:stepId/approve') @BookingStaff([ FREIGHT_PERMS.bookings.approveLineStaff, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.spec.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.spec.ts new file mode 100644 index 000000000..7d4ff199c --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.spec.ts @@ -0,0 +1,70 @@ +import { DataSource, Repository } from 'typeorm'; + +import { Booking } from './entities/booking.entity'; +import { BookingsRepository } from './bookings.repository'; + +function mockQueryBuilder() { + const qb = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + leftJoin: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + addOrderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getMany: jest.fn(), + getManyAndCount: jest.fn().mockResolvedValue([[], 0]), + }; + return qb; +} + +describe('BookingsRepository', () => { + let repository: jest.Mocked>; + let dataSource: { getRepository: jest.Mock }; + let bookingsRepository: BookingsRepository; + + beforeEach(() => { + repository = { + createQueryBuilder: jest.fn(), + } as unknown as jest.Mocked>; + dataSource = { getRepository: jest.fn() }; + bookingsRepository = new BookingsRepository(repository, dataSource as unknown as DataSource); + }); + + it('findEligibleForScheduling does not filter by schedule date', async () => { + const qb = mockQueryBuilder(); + const bookings = [ + { id: 'b1', scheduledDate: new Date('2026-06-20T08:00:00.000Z') }, + { id: 'b2', scheduledDate: new Date('2026-06-21T14:00:00.000Z') }, + ]; + qb.getMany.mockResolvedValue(bookings); + repository.createQueryBuilder.mockReturnValue(qb as never); + + const result = await bookingsRepository.findEligibleForScheduling({ + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + freightType: 'CONTAINER', + }); + + expect(result).toHaveLength(2); + const dateFilters = qb.andWhere.mock.calls.filter(([clause]) => + String(clause).includes('scheduled_date'), + ); + expect(dateFilters).toHaveLength(0); + }); + + it('applyListFilters excludes assigned bookings when assignedToSchedule is false', async () => { + const qb = mockQueryBuilder(); + repository.createQueryBuilder.mockReturnValue(qb as never); + dataSource.getRepository.mockReturnValue({ find: jest.fn().mockResolvedValue([]) }); + + await bookingsRepository.findAllPaginated({ + page: 1, + pageSize: 10, + assignedToSchedule: 'false', + }); + + expect(qb.andWhere).toHaveBeenCalledWith(expect.stringContaining('NOT EXISTS')); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 17664f6ca..46600b148 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -1,7 +1,8 @@ import { BaseRepository } from '@edr/api-common'; +import { SchedulingStatus } from '@edr/types'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { DataSource, FindOptionsWhere, Repository, SelectQueryBuilder } from 'typeorm'; +import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; import { BookingApprovalStep } from './entities/booking-approval-step.entity'; @@ -9,6 +10,7 @@ import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; import { BookingContainer } from './entities/booking-container.entity'; import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity'; import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity'; +import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; import { Booking } from './entities/booking.entity'; import { BookingContractSignature, @@ -20,6 +22,8 @@ import { ContainerWeightResult } from '../rule-engine/rule-engine.service'; export interface BookingListFilterOptions { statuses?: string[]; status?: string; + schedulingStatuses?: string[]; + assignedToSchedule?: 'true' | 'false'; companyId?: string; contractType?: string; serviceTypeId?: string; @@ -427,17 +431,37 @@ export class BookingsRepository extends BaseRepository { this.applyListFilters(qb, options); - const sortField = - options.sortBy === 'priorityScore' - ? 'booking.priorityScore' - : 'booking.createdAt'; - qb.orderBy(sortField, options.sortOrder ?? 'DESC'); + if (options.sortBy === 'isGovernment') { + qb.orderBy('booking.isGovernment', 'DESC') + .addOrderBy('booking.priorityScore', 'DESC') + .addOrderBy('booking.scheduledDate', 'ASC'); + } else { + const sortField = + options.sortBy === 'priorityScore' + ? 'booking.priorityScore' + : options.sortBy === 'scheduledDate' + ? 'booking.scheduledDate' + : 'booking.createdAt'; + qb.orderBy(sortField, options.sortOrder ?? 'DESC'); + } const [items, total] = await qb .skip((page - 1) * pageSize) .take(pageSize) .getManyAndCount(); + if (items.length) { + const links = await this.dataSource.getRepository(TrainScheduleBooking).find({ + where: { bookingId: In(items.map((item) => item.id)) }, + select: { bookingId: true, trainScheduleId: true }, + }); + const scheduleByBooking = new Map(links.map((link) => [link.bookingId, link.trainScheduleId])); + for (const item of items) { + (item as Booking & { trainScheduleId?: string | null }).trainScheduleId = + scheduleByBooking.get(item.id) ?? null; + } + } + return { items, total }; } @@ -556,6 +580,26 @@ export class BookingsRepository extends BaseRepository { } else if (options.consolidationPaired === 'false') { qb.andWhere('booking.consolidation_partner_id IS NULL'); } + if (options.schedulingStatuses?.length) { + qb.andWhere('booking.scheduling_status IN (:...schedulingStatuses)', { + schedulingStatuses: options.schedulingStatuses, + }); + } + if (options.assignedToSchedule === 'true') { + qb.andWhere( + `EXISTS ( + SELECT 1 FROM freight.train_schedule_bookings tsb + WHERE tsb.booking_id = booking.id AND tsb.deleted_at IS NULL + )`, + ); + } else if (options.assignedToSchedule === 'false') { + qb.andWhere( + `NOT EXISTS ( + SELECT 1 FROM freight.train_schedule_bookings tsb + WHERE tsb.booking_id = booking.id AND tsb.deleted_at IS NULL + )`, + ); + } } async findAndCountFiltered(where: FindOptionsWhere, options: { @@ -605,4 +649,99 @@ export class BookingsRepository extends BaseRepository { } return repo.save(repo.create(data)); } + + private bookingRepo(manager?: EntityManager) { + return manager ? manager.getRepository(Booking) : this.repository; + } + + findEligibleForScheduling(options: { + freightType?: string; + originStationId?: string; + destinationStationId?: string; + schedulingStatus?: string; + }): Promise { + const qb = this.repository + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.company', 'company') + .leftJoinAndSelect('booking.originYard', 'originYard') + .leftJoinAndSelect('booking.destinationYard', 'destinationYard') + .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoinAndSelect('bookingContainer.containerType', 'containerType') + .leftJoinAndSelect('booking.cargoType', 'cargoType') + .leftJoin( + TrainScheduleBooking, + 'scheduleBooking', + 'scheduleBooking.booking_id = booking.id', + ) + .where('booking.status = :paidStatus', { paidStatus: 'PAID' }) + .andWhere('scheduleBooking.id IS NULL'); + + if (options.freightType) { + qb.andWhere('booking.freightType = :freightType', { freightType: options.freightType }); + } + + if (options.originStationId) { + qb.andWhere('booking.originYardId = :originStationId', { + originStationId: options.originStationId, + }); + } + if (options.destinationStationId) { + qb.andWhere('booking.destinationYardId = :destinationStationId', { + destinationStationId: options.destinationStationId, + }); + } + if (options.schedulingStatus) { + qb.andWhere('booking.scheduling_status = :schedulingStatus', { + schedulingStatus: options.schedulingStatus, + }); + } + + return qb + .orderBy('booking.priority_score', 'DESC') + .addOrderBy('booking.scheduled_date', 'ASC') + .addOrderBy('booking.created_at', 'ASC') + .getMany(); + } + + findByIdsForScheduling(bookingIds: string[], manager?: EntityManager): Promise { + if (!bookingIds.length) return Promise.resolve([]); + return this.bookingRepo(manager).find({ + where: { id: In(bookingIds) }, + relations: { + company: true, + originYard: true, + destinationYard: true, + bookingContainers: { containerType: true }, + cargoType: true, + }, + order: { priorityScore: 'DESC', createdAt: 'ASC' }, + }); + } + + async updateSchedulingFields( + bookingId: string, + fields: Partial< + Pick< + Booking, + 'schedulingStatus' | 'wagonsRequired' | 'scheduledAt' | 'holdStartedAt' | 'holdExpiresAt' + > + >, + manager?: EntityManager, + ): Promise { + await this.bookingRepo(manager).update(bookingId, fields as never); + } + + async setHoldWindowOnPaid(bookingId: string, manager?: EntityManager): Promise { + const now = new Date(); + const expires = new Date(now.getTime() + 3 * 60 * 60 * 1000); + await this.updateSchedulingFields( + bookingId, + { + schedulingStatus: SchedulingStatus.Holding, + holdStartedAt: now, + holdExpiresAt: expires, + }, + manager, + ); + } } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 4553e7a59..d8e796b36 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -4,6 +4,7 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; +import { SchedulingStatus } from '@edr/types'; // import { CustomersService } from '../customers/customers.service'; import { CompaniesService } from '../companies/companies.service'; import { FilesService } from '../files/files.service'; @@ -64,6 +65,7 @@ export class BookingsService { paymentCurrency: string; tradeDirection: string; isHazardous?: boolean; + isGovernment?: boolean; allowConsolidation?: boolean; shippingLineId?: string | null; containers: CreateBookingContainerDto[]; @@ -92,6 +94,7 @@ export class BookingsService { paymentCurrency: dto.paymentCurrency, tradeDirection: dto.tradeDirection, isHazardous: dto.isHazardous ?? false, + isGovernment: dto.isGovernment ?? false, allowConsolidation: dto.freightType === 'CONTAINER' ? dto.allowConsolidation : false, shippingLineId: dto.shippingLineId, @@ -178,8 +181,15 @@ export class BookingsService { // customerId = customer.id; // } - let companyId = dto.companyId; - if (!companyId) { + const isGovernment = dto.isGovernment === true; + + let companyId: string | null | undefined = dto.companyId; + if (isGovernment) { + if (!dto.governmentInstitution?.trim()) { + throw new BadRequestException('governmentInstitution is required for government bookings'); + } + companyId = dto.companyId ?? null; + } else if (!companyId) { if (!userId) { throw new BadRequestException( 'companyId is required or must be resolvable from auth token', @@ -209,6 +219,7 @@ export class BookingsService { paymentCurrency: dto.paymentCurrency, tradeDirection: dto.tradeDirection, isHazardous: dto.isHazardous, + isGovernment, allowConsolidation, shippingLineId: dto.shippingLineId, containers, @@ -220,7 +231,9 @@ export class BookingsService { const booking = await this.bookingsRepository.create({ reference, - companyId, + companyId: companyId ?? null, + isGovernment, + governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null, trainId: dto.trainId, contractType: dto.contractType, previousContractId: dto.previousContractId, @@ -300,12 +313,13 @@ export class BookingsService { const freightType = (dto.freightType ?? existing.freightType) as FreightType; let containers = dto.containers ?? - existing.bookingContainers?.map((bc) => ({ - containerTypeId: bc.containerTypeId, - quantity: bc.quantity, - vgmPerUnitTons: Number(bc.vgmPerUnitTons), - })) ?? - []; + (existing.bookingContainers ?? []) + .filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null) + .map((bc) => ({ + containerTypeId: bc.containerTypeId, + quantity: bc.quantity, + vgmPerUnitTons: Number(bc.vgmPerUnitTons), + })); let cargoTypeId = dto.cargoTypeId !== undefined ? dto.cargoTypeId : existing.cargoTypeId; @@ -403,6 +417,19 @@ export class BookingsService { return { booking, warnings }; } + /** Parse comma-separated scheduling status query values. */ + private parseSchedulingStatusFilter(filter: FilterBookingDto): { + schedulingStatuses?: string[]; + } { + const raw = filter.schedulingStatuses; + if (!raw) return {}; + const schedulingStatuses = raw + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + return schedulingStatuses.length ? { schedulingStatuses } : {}; + } + /** Parse comma-separated or repeated status query values. */ private parseStatusFilter(filter: FilterBookingDto): { statuses?: string[]; @@ -433,11 +460,14 @@ export class BookingsService { const page = filter.page ?? 1; const pageSize = filter.pageSize ?? 20; const statusFilter = this.parseStatusFilter(filter); + const schedulingStatusFilter = this.parseSchedulingStatusFilter(filter); return this.bookingsRepository.findAllPaginated({ page, pageSize, ...statusFilter, + ...schedulingStatusFilter, + assignedToSchedule: filter.assignedToSchedule, companyId: filter.companyId, contractType: filter.contractType, serviceTypeId: filter.serviceTypeId, @@ -695,12 +725,13 @@ export class BookingsService { return true; } if (dto.containers !== undefined) { - const existingContainers = - existing.bookingContainers?.map((bc) => ({ + const existingContainers = (existing.bookingContainers ?? []) + .filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null) + .map((bc) => ({ containerTypeId: bc.containerTypeId, quantity: bc.quantity, vgmPerUnitTons: Number(bc.vgmPerUnitTons), - })) ?? []; + })); if (JSON.stringify(existingContainers) !== JSON.stringify(containers)) { return true; } @@ -714,4 +745,32 @@ export class BookingsService { } return false; } + + /** Staff expedite: mark a government booking PAID and ready for scheduling (no commercial hold). */ + async governmentExpedite(id: string, staffUserId: string): Promise { + const booking = await this.findById(id); + if (!booking.isGovernment) { + throw new BadRequestException('Only government bookings can be expedited'); + } + const blocked = ['PAID', 'IN_TRANSIT', 'COMPLETED', 'CANCELLED', 'REJECTED']; + if (blocked.includes(booking.status)) { + throw new BadRequestException(`Cannot expedite booking in status ${booking.status}`); + } + + await this.bookingsRepository.update(id, { + status: 'PAID', + paymentStatus: 'PAID', + schedulingStatus: SchedulingStatus.Eligible, + holdStartedAt: null, + holdExpiresAt: null, + }); + await this.bookingsRepository.createReviewNote( + id, + `Government booking expedited to PAID by staff (${staffUserId})`, + 'STAFF_NOTE', + staffUserId, + ); + + return this.findById(id); + } } diff --git a/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts b/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts index 2d805ba8f..541d5d09f 100644 --- a/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts @@ -76,11 +76,12 @@ export class ConsolidationService { } async slotsFromBooking(booking: Booking): Promise { - const lines = - booking.bookingContainers?.map((bc) => ({ + const lines = (booking.bookingContainers ?? []) + .filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null) + .map((bc) => ({ containerTypeId: bc.containerTypeId, quantity: bc.quantity, - })) ?? []; + })); return this.slotsFromContainerLines(lines); } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts index 8089d0307..194bd5a83 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts @@ -12,6 +12,7 @@ import { IsString, IsUUID, Min, + MinLength, Validate, ValidateIf, ValidateNested, @@ -66,7 +67,21 @@ export class CreateBookingDto { // @IsUUID() // customerId?: string; + @ApiPropertyOptional({ description: 'Staff only: government booking flag' }) + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === 'true' || value === true) + isGovernment?: boolean; + + @ApiPropertyOptional({ description: 'Required when isGovernment is true' }) + @ValidateIf((o) => o.isGovernment === true) + @IsString() + @MinLength(2) + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + governmentInstitution?: string; + @ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target company' }) + @ValidateIf((o) => o.isGovernment !== true) @IsOptional() @IsUUID() companyId?: string; diff --git a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts index 03fe73683..b88c381ae 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts @@ -84,8 +84,25 @@ export class FilterBookingDto { @Transform(({ value }) => (value ? parseInt(value, 10) : 20)) pageSize?: number; + @ApiPropertyOptional({ + description: 'Comma-separated scheduling statuses (NOT_SCHEDULED,HOLDING,ELIGIBLE,SCHEDULED)', + }) + @IsOptional() + @Transform(({ value }) => { + if (value === undefined || value === null || value === '') return undefined; + if (Array.isArray(value)) return value.map(String).join(','); + return String(value); + }) + schedulingStatuses?: string; + + @ApiPropertyOptional({ enum: ['true', 'false'], description: 'Filter by train schedule assignment' }) + @IsOptional() + @IsIn(['true', 'false']) + assignedToSchedule?: 'true' | 'false'; + @ApiPropertyOptional({ default: 'createdAt' }) @IsOptional() + @IsIn(['createdAt', 'priorityScore', 'scheduledDate', 'isGovernment']) sortBy?: string; @ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'DESC' }) diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts index dc7691456..8a09245ea 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts @@ -15,12 +15,15 @@ export class BookingContainer extends BaseEntity { @JoinColumn({ name: 'booking_id' }) booking?: Booking; - @Column({ name: 'container_type_id', type: 'uuid' }) - containerTypeId!: string; + @Column({ name: 'container_type_id', type: 'uuid', nullable: true }) + containerTypeId?: string | null; - @ManyToOne(() => ContainerType) + @ManyToOne(() => ContainerType, { nullable: true }) @JoinColumn({ name: 'container_type_id' }) - containerType?: ContainerType; + containerType?: ContainerType | null; + + @Column({ name: 'container_number', type: 'varchar', length: 64, nullable: true }) + containerNumber?: string | null; @Column({ name: 'quantity', type: 'smallint' }) quantity!: number; diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-review-note.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-review-note.entity.ts index af39a469c..91171a793 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking-review-note.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-review-note.entity.ts @@ -2,7 +2,7 @@ import { BaseEntity } from '@edr/api-common'; import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; import { Booking } from './booking.entity'; -export const REVIEW_NOTE_TYPES = ['CHANGES_REQUESTED', 'REJECTION'] as const; +export const REVIEW_NOTE_TYPES = ['CHANGES_REQUESTED', 'REJECTION', 'STAFF_NOTE'] as const; export type ReviewNoteType = (typeof REVIEW_NOTE_TYPES)[number]; @Entity({ schema: 'freight', name: 'booking_review_note' }) diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 59ab1cb7a..b3104d322 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -1,4 +1,5 @@ import { BaseEntity } from '@edr/api-common'; +import { SchedulingStatus } from '@edr/types'; import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; // import { Customer } from '../../customers/entities/customer.entity'; import { Company } from '../../companies/entities/company.entity'; @@ -51,6 +52,16 @@ export type PaymentStatus = (typeof PAYMENT_STATUSES)[number]; export const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const; export type FreightType = (typeof FREIGHT_TYPES)[number]; +export const SCHEDULING_STATUSES = [ + SchedulingStatus.NotScheduled, + SchedulingStatus.Holding, + SchedulingStatus.Eligible, + SchedulingStatus.Scheduled, + SchedulingStatus.Dispatched, +] as const; + +export type BookingSchedulingStatus = (typeof SCHEDULING_STATUSES)[number]; + /** Statuses where the customer may edit booking fields. */ export const CUSTOMER_EDITABLE_STATUSES: BookingStatus[] = [ 'DRAFT', @@ -69,16 +80,24 @@ export class Booking extends BaseEntity { // @JoinColumn({ name: 'customer_id' }) // customer?: Customer; - @Column({ name: 'company_id', type: 'uuid' }) - companyId!: string; + @Column({ name: 'company_id', type: 'uuid', nullable: true }) + companyId?: string | null; - @ManyToOne(() => Company) + @ManyToOne(() => Company, { nullable: true }) @JoinColumn({ name: 'company_id' }) - company?: Company; + company?: Company | null; + @Column({ name: 'is_government', type: 'boolean', default: false }) + isGovernment!: boolean; + + @Column({ name: 'government_institution', type: 'varchar', length: 255, nullable: true }) + governmentInstitution?: string | null; + + /** @deprecated Fleet master data link — scheduling uses train_schedule_bookings instead. */ @Column({ name: 'train_id', type: 'uuid', nullable: true }) trainId?: string | null; + /** @deprecated Use train_schedule_bookings for operational scheduling. */ @ManyToOne(() => Train, { nullable: true }) @JoinColumn({ name: 'train_id' }) train?: Train | null; @@ -240,6 +259,21 @@ export class Booking extends BaseEntity { @JoinColumn({ name: 'consolidation_partner_id' }) consolidationPartner?: Booking | null; + @Column({ name: 'wagons_required', type: 'numeric', precision: 6, scale: 2, nullable: true }) + wagonsRequired?: number | null; + + @Column({ name: 'scheduling_status', type: 'varchar', length: 30, default: 'NOT_SCHEDULED' }) + schedulingStatus!: string; + + @Column({ name: 'hold_started_at', type: 'timestamptz', nullable: true }) + holdStartedAt?: Date | null; + + @Column({ name: 'hold_expires_at', type: 'timestamptz', nullable: true }) + holdExpiresAt?: Date | null; + + @Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true }) + scheduledAt?: Date | null; + @OneToMany(() => BookingContainer, (bc) => bc.booking) bookingContainers?: BookingContainer[]; diff --git a/apps/edr-freight-api/src/modules/cargoes/cargoes.service.ts b/apps/edr-freight-api/src/modules/cargoes/cargoes.service.ts index f5940d6a6..6c79f0a76 100644 --- a/apps/edr-freight-api/src/modules/cargoes/cargoes.service.ts +++ b/apps/edr-freight-api/src/modules/cargoes/cargoes.service.ts @@ -159,9 +159,12 @@ export class CargoesService { cargo.status = 'DELIVERED'; if (dto?.deliveryRemarks) cargo.description = dto.deliveryRemarks; - const remaining = await this.cargoRepo.count({ - where: { containerId: cargo.containerId, status: 'LOADED' }, - }); + const remaining = + cargo.containerId != null + ? await this.cargoRepo.count({ + where: { containerId: cargo.containerId, status: 'LOADED' }, + }) + : 0; if (remaining === 0 && cargo.container) { cargo.container.status = 'AVAILABLE'; await this.containerRepo.save(cargo.container); diff --git a/apps/edr-freight-api/src/modules/cargoes/entities/cargoes.entity.ts b/apps/edr-freight-api/src/modules/cargoes/entities/cargoes.entity.ts index 7c2f752e5..ffc4bb26a 100644 --- a/apps/edr-freight-api/src/modules/cargoes/entities/cargoes.entity.ts +++ b/apps/edr-freight-api/src/modules/cargoes/entities/cargoes.entity.ts @@ -1,7 +1,9 @@ // apps/edr-freight-api/src/modules/cargoes/entities/cargo.entity.ts import { Entity, Column, ManyToOne, JoinColumn } from 'typeorm'; import { BaseEntity } from '@edr/api-common'; +import { Booking } from '../../bookings/entities/booking.entity'; import { Container } from '../../container-management/entities/container.entity'; +import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity'; @Entity({ name: 'cargoes', schema: 'freight' }) export class Cargo extends BaseEntity { @@ -11,8 +13,8 @@ export class Cargo extends BaseEntity { @Column({ name: 'shipment_id', type: 'uuid' }) shipmentId!: string; - @Column({ name: 'container_id', type: 'uuid' }) - containerId!: string; + @Column({ name: 'container_id', type: 'uuid', nullable: true }) + containerId!: string | null; @Column({ name: 'cargo_type_id', type: 'uuid', nullable: true }) cargoTypeId!: string | null; // optional link to cargo_types table @@ -38,8 +40,24 @@ export class Cargo extends BaseEntity { @Column({ name: 'unloaded_at', type: 'timestamp', nullable: true }) unloadedAt!: Date | null; - // Relationship to Container - @ManyToOne(() => Container, (container) => container.cargoes, { onDelete: 'RESTRICT' }) + @Column({ name: 'wagon_booking_allocation_id', type: 'uuid', nullable: true }) + wagonBookingAllocationId!: string | null; + + @ManyToOne(() => WagonBookingAllocation, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'wagon_booking_allocation_id' }) + wagonBookingAllocation?: WagonBookingAllocation | null; + + @Column({ name: 'booking_id', type: 'uuid', nullable: true }) + bookingId!: string | null; + + @ManyToOne(() => Booking, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking | null; + + @Column({ name: 'load_type', type: 'varchar', length: 20, nullable: true }) + loadType!: string | null; + + @ManyToOne(() => Container, (container) => container.cargoes, { onDelete: 'RESTRICT', nullable: true }) @JoinColumn({ name: 'container_id' }) - container!: Container; + container!: Container | null; } \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/container-management/entities/container.entity.ts b/apps/edr-freight-api/src/modules/container-management/entities/container.entity.ts index a5c7ee9c1..e6fdd47ee 100644 --- a/apps/edr-freight-api/src/modules/container-management/entities/container.entity.ts +++ b/apps/edr-freight-api/src/modules/container-management/entities/container.entity.ts @@ -1,6 +1,9 @@ // apps/edr-freight-api/src/modules/container-management/entities/container.entity.ts import { Entity, Column, ManyToOne, OneToMany, JoinColumn } from 'typeorm'; import { BaseEntity } from '@edr/api-common'; +import { Booking } from '../../bookings/entities/booking.entity'; +import { BookingContainer } from '../../bookings/entities/booking-container.entity'; +import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity'; import { Wagon } from '../../wagons/entities/wagon.entity'; import { Cargo } from '../../cargoes/entities/cargoes.entity'; @@ -34,7 +37,27 @@ sealNumber!: string | null; @Column({ type: 'varchar', default: 'AVAILABLE' }) status!: string; // AVAILABLE, LOADED, IN_TRANSIT, MAINTENANCE, DAMAGED - // Relationship to Wagon + @Column({ name: 'booking_id', type: 'uuid', nullable: true }) + bookingId!: string | null; + + @ManyToOne(() => Booking, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking | null; + + @Column({ name: 'wagon_booking_allocation_id', type: 'uuid', nullable: true }) + wagonBookingAllocationId!: string | null; + + @ManyToOne(() => WagonBookingAllocation, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'wagon_booking_allocation_id' }) + wagonBookingAllocation?: WagonBookingAllocation | null; + + @Column({ name: 'booking_container_id', type: 'uuid', nullable: true }) + bookingContainerId!: string | null; + + @ManyToOne(() => BookingContainer, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'booking_container_id' }) + bookingContainer?: BookingContainer | null; + @ManyToOne(() => Wagon, (wagon) => wagon.containers, { onDelete: 'SET NULL' }) @JoinColumn({ name: 'wagon_id' }) wagon!: Wagon | null; diff --git a/apps/edr-freight-api/src/modules/overview/overview.repository.ts b/apps/edr-freight-api/src/modules/overview/overview.repository.ts index 3275a6a8f..2c49ff8da 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.repository.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.repository.ts @@ -124,7 +124,7 @@ export class OverviewRepository { this.wagonRepository .createQueryBuilder('wagon') .where('wagon.deleted_at IS NULL') - .andWhere('wagon.status = :status', { status: 'AVAILABLE' }) + .andWhere('wagon.status = :status', { status: Freight.WagonStatus.Available }) .getCount(), this.containerRepository .createQueryBuilder('container') diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 549b3db4f..9cd90e18a 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -16,6 +16,7 @@ import * as fs from "fs"; import * as path from "path"; import * as Handlebars from "handlebars"; import { ConfigService } from "@nestjs/config"; +import { SchedulingStatus } from "@edr/types"; import { Booking } from "../bookings/entities/booking.entity"; type PaymentMethod = PaymentEntity["method"]; @@ -168,7 +169,14 @@ export class PaymentService { const ordersStatus = bizContent.order_status; if (ordersStatus == "PAY_SUCCESS") { await this.datasource.transaction(async (mg) => { - await mg.update(Booking, { id: resp.refId }, { status: "PAID" }); + const now = new Date(); + const holdExpires = new Date(now.getTime() + 3 * 60 * 60 * 1000); + await mg.update(Booking, { id: resp.refId }, { + status: "PAID", + schedulingStatus: SchedulingStatus.Holding, + holdStartedAt: now, + holdExpiresAt: holdExpires, + }); await mg.update(PaymentEntity, { id: resp.id }, { status: "success" }); }); } diff --git a/apps/edr-freight-api/src/modules/rule-engine/government-priority.constants.ts b/apps/edr-freight-api/src/modules/rule-engine/government-priority.constants.ts new file mode 100644 index 000000000..690352602 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/government-priority.constants.ts @@ -0,0 +1,2 @@ +/** Ensures government bookings outrank commercial priority (max ~1,500 today). */ +export const GOVERNMENT_PRIORITY_BONUS = 50_000; diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index bf4162f15..9bf3635e9 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -36,6 +36,7 @@ import { SHIPPING_LINES_REPOSITORY, } from './interfaces/shipping-lines.repository.interface'; import { DEFAULT_APPROVAL_RULE_ROWS } from './approval-rules.defaults'; +import { GOVERNMENT_PRIORITY_BONUS } from './government-priority.constants'; export interface BookingContainerEvalInput { containerTypeId: string; @@ -54,6 +55,7 @@ export interface BookingEvaluationInput { paymentCurrency: string; tradeDirection: string; isHazardous: boolean; + isGovernment?: boolean; allowConsolidation?: boolean; shippingLineId?: string | null; containers: BookingContainerEvalInput[]; @@ -180,6 +182,10 @@ export class RuleEngineService { } } + if (input.isGovernment) { + priorityScore += GOVERNMENT_PRIORITY_BONUS; + } + let shippingLineMapped = false; if (input.shippingLineId) { const line = await this.shippingLinesRepo.findById(input.shippingLineId); diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/dto/preview-reschedule.dto.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/dto/preview-reschedule.dto.ts new file mode 100644 index 000000000..d1b6f80f3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/dto/preview-reschedule.dto.ts @@ -0,0 +1,46 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + ArrayMinSize, + IsArray, + IsDateString, + IsIn, + IsOptional, + IsString, + IsUUID, +} from 'class-validator'; + +import { RESCHEDULE_TRIGGERS } from '../entities/scheduling-event.entity'; + +export class PreviewRescheduleDto { + @ApiProperty({ type: [String] }) + @IsArray() + @ArrayMinSize(1) + @IsUUID('4', { each: true }) + incomingBookingIds!: string[]; + + @ApiProperty({ enum: RESCHEDULE_TRIGGERS }) + @IsIn([...RESCHEDULE_TRIGGERS]) + trigger!: (typeof RESCHEDULE_TRIGGERS)[number]; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + reason?: string; + + @ApiPropertyOptional({ example: '2026-06-22T08:00:00.000Z' }) + @IsOptional() + @IsDateString() + newDepartureDate?: string; +} + +export class ExecuteRescheduleDto extends PreviewRescheduleDto { + @ApiProperty({ type: [String], description: 'Booking IDs to assign after reschedule' }) + @IsArray() + @IsUUID('4', { each: true }) + finalBookingIds!: string[]; + + @ApiProperty({ type: [String], description: 'Booking IDs removed from the schedule' }) + @IsArray() + @IsUUID('4', { each: true }) + displacedBookingIds!: string[]; +} diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/entities/scheduling-event.entity.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/entities/scheduling-event.entity.ts new file mode 100644 index 000000000..c8814755c --- /dev/null +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/entities/scheduling-event.entity.ts @@ -0,0 +1,33 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +export const RESCHEDULE_TRIGGERS = [ + 'GOVERNMENT_PREEMPT', + 'TRAIN_MAINTENANCE', + 'MANUAL', + 'CAPACITY_REBALANCE', +] as const; + +export type RescheduleTrigger = (typeof RESCHEDULE_TRIGGERS)[number]; + +@Entity({ schema: 'freight', name: 'scheduling_events' }) +@Index(['trainScheduleId']) +export class SchedulingEvent extends BaseEntity { + @Column({ name: 'train_schedule_id', type: 'uuid' }) + trainScheduleId!: string; + + @Column({ name: 'trigger', type: 'varchar', length: 40 }) + trigger!: RescheduleTrigger; + + @Column({ name: 'actor_user_id', type: 'uuid', nullable: true }) + actorUserId?: string | null; + + @Column({ name: 'reason', type: 'text', nullable: true }) + reason?: string | null; + + @Column({ name: 'plan_snapshot', type: 'jsonb' }) + planSnapshot!: Record; + + @Column({ name: 'displaced_booking_ids', type: 'jsonb', default: '[]' }) + displacedBookingIds!: string[]; +} diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.controller.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.controller.ts new file mode 100644 index 000000000..0145db3db --- /dev/null +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.controller.ts @@ -0,0 +1,65 @@ +import { Body, Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '@edr/api-common'; + +import { TrainSchedulingManage } from '../../common/booking-guards'; +import { + type AuthUserPayload, + resolveAuthUserId, +} from '../../common/resolve-auth-user-id'; +import { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto'; +import { SchedulingRescheduleService } from './scheduling-reschedule.service'; + +@ApiTags('train-scheduling') +@ApiBearerAuth() +@Controller('train-scheduling/schedules/:id/reschedule') +export class SchedulingRescheduleController { + constructor(private readonly schedulingRescheduleService: SchedulingRescheduleService) {} + + @Post('preview') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Preview reschedule / government preempt plan' }) + preview( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: PreviewRescheduleDto, + ) { + return this.schedulingRescheduleService.previewReschedule(id, dto); + } + + @Post('execute') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Execute a confirmed reschedule plan' }) + execute( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: ExecuteRescheduleDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.schedulingRescheduleService.executeReschedule( + id, + dto, + resolveAuthUserId(user), + ); + } +} + +@ApiTags('train-scheduling') +@ApiBearerAuth() +@Controller('train-scheduling/schedules/:id') +export class SchedulingMaintenanceController { + constructor(private readonly schedulingRescheduleService: SchedulingRescheduleService) {} + + @Post('maintenance') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Reschedule train for maintenance (new departure + rebalance)' }) + maintenance( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: PreviewRescheduleDto & { newDepartureDate: string }, + @CurrentUser() user: AuthUserPayload, + ) { + return this.schedulingRescheduleService.maintenanceReschedule( + id, + dto, + resolveAuthUserId(user), + ); + } +} diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.module.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.module.ts new file mode 100644 index 000000000..fa141057f --- /dev/null +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.module.ts @@ -0,0 +1,26 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { BookingsModule } from '../bookings/bookings.module'; +import { TrainSchedulesModule } from '../train-schedules/train-schedules.module'; +import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; +import { SchedulingEvent } from './entities/scheduling-event.entity'; +import { + SchedulingMaintenanceController, + SchedulingRescheduleController, +} from './scheduling-reschedule.controller'; +import { SchedulingRescheduleRepository } from './scheduling-reschedule.repository'; +import { SchedulingRescheduleService } from './scheduling-reschedule.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([SchedulingEvent]), + BookingsModule, + TrainSchedulesModule, + TrainSchedulingModule, + ], + controllers: [SchedulingRescheduleController, SchedulingMaintenanceController], + providers: [SchedulingRescheduleRepository, SchedulingRescheduleService], + exports: [SchedulingRescheduleService], +}) +export class SchedulingRescheduleModule {} diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.repository.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.repository.ts new file mode 100644 index 000000000..5a328ed0b --- /dev/null +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.repository.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { SchedulingEvent, type RescheduleTrigger } from './entities/scheduling-event.entity'; + +@Injectable() +export class SchedulingRescheduleRepository { + constructor( + @InjectRepository(SchedulingEvent) + private readonly repository: Repository, + ) {} + + /** Persist an audit record for a completed reschedule. */ + async createEvent(data: { + trainScheduleId: string; + trigger: RescheduleTrigger; + actorUserId?: string; + reason?: string; + planSnapshot: Record; + displacedBookingIds: string[]; + }): Promise { + return this.repository.save(this.repository.create(data)); + } +} diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts new file mode 100644 index 000000000..905e827e8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts @@ -0,0 +1,230 @@ +import { BadRequestException } from '@nestjs/common'; + +import { compareSchedulingPriority } from '../scheduling/compare-scheduling-priority.util'; +import { SchedulingRescheduleService } from './scheduling-reschedule.service'; + +const makeBooking = ( + id: string, + reference: string, + extra: Record = {}, +) => ({ + id, + reference, + freightType: 'CONTAINER', + cargoTotalWeightVgm: 100, + scheduledDate: new Date('2026-06-20T08:00:00.000Z'), + originYardId: 'yard-origin', + destinationYardId: 'yard-destination', + status: 'PAID', + isGovernment: false, + priorityScore: 50, + bookingContainers: [ + { + id: `${id}-line`, + wagonsRequired: 5, + quantity: 1, + vgmPerUnitTons: 100, + }, + ], + ...extra, +}); + +describe('compareSchedulingPriority', () => { + it('orders government before commercial', () => { + const sorted = [ + { + isGovernment: false, + priorityScore: 50000, + scheduledDate: new Date('2026-06-20'), + }, + { + isGovernment: true, + priorityScore: 100, + scheduledDate: new Date('2026-06-25'), + }, + ].sort(compareSchedulingPriority); + + expect(sorted[0]?.isGovernment).toBe(true); + }); +}); + +describe('SchedulingRescheduleService', () => { + let service: SchedulingRescheduleService; + let trainSchedulesRepository: Record; + let bookingsRepository: Record; + let trainSchedulingService: Record; + let schedulingRescheduleRepository: Record; + + beforeEach(() => { + trainSchedulesRepository = { + findByIdWithFullGraph: jest.fn(), + updateStatus: jest.fn(), + }; + bookingsRepository = { + findByIdsForScheduling: jest.fn(), + updateSchedulingFields: jest.fn(), + }; + trainSchedulingService = { + previewTrainSchedule: jest.fn(), + unassignBooking: jest.fn(), + assignBookingsToSchedule: jest.fn(), + }; + schedulingRescheduleRepository = { + createEvent: jest.fn().mockResolvedValue({ id: 'event-1' }), + }; + + service = new SchedulingRescheduleService( + trainSchedulesRepository as never, + bookingsRepository as never, + trainSchedulingService as never, + schedulingRescheduleRepository as never, + ); + }); + + it('rejects reschedule on dispatched trains', async () => { + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({ + id: 'sched-1', + status: 'DISPATCHED', + scheduleBookings: [], + }); + + await expect( + service.previewReschedule('sched-1', { + incomingBookingIds: ['gov-1'], + trigger: 'GOVERNMENT_PREEMPT', + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('displaces lower-priority commercial when government incoming exceeds capacity', async () => { + const commercial = makeBooking('c1', 'BKG-COMM', { priorityScore: 10, isGovernment: false }); + const government = makeBooking('g1', 'BKG-GOV', { + isGovernment: true, + priorityScore: 60000, + governmentInstitution: 'Ministry', + }); + + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({ + id: 'sched-1', + status: 'DRAFT', + scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'), + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + scheduleBookings: [{ bookingId: 'c1', booking: commercial }], + }); + bookingsRepository.findByIdsForScheduling.mockResolvedValue([government]); + + trainSchedulingService.previewTrainSchedule.mockImplementation( + async ({ bookingIds }: { bookingIds: string[] }) => ({ + valid: bookingIds.length <= 1, + violations: bookingIds.length > 1 ? ['Train capacity exceeded'] : [], + warnings: [], + }), + ); + + const plan = await service.previewReschedule('sched-1', { + incomingBookingIds: ['g1'], + trigger: 'GOVERNMENT_PREEMPT', + }); + + expect(plan.retained.map((b) => b.id)).toEqual(['g1']); + expect(plan.displaced.map((b) => b.id)).toEqual(['c1']); + expect(plan.finalBookingIds).toEqual(['g1']); + }); + + it('readmits high-priority commercial when spare capacity remains', async () => { + const low = makeBooking('c-low', 'BKG-LOW', { priorityScore: 5 }); + const high = makeBooking('c-high', 'BKG-HIGH', { priorityScore: 500 }); + const government = makeBooking('g1', 'BKG-GOV', { isGovernment: true, priorityScore: 60000 }); + + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({ + id: 'sched-1', + status: 'DRAFT', + scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'), + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + scheduleBookings: [ + { bookingId: 'c-low', booking: low }, + { bookingId: 'c-high', booking: high }, + ], + }); + bookingsRepository.findByIdsForScheduling.mockResolvedValue([government]); + + const fitAttempts = new Map(); + trainSchedulingService.previewTrainSchedule.mockImplementation( + async ({ bookingIds }: { bookingIds: string[] }) => { + const key = [...bookingIds].sort().join(','); + const attempt = (fitAttempts.get(key) ?? 0) + 1; + fitAttempts.set(key, attempt); + + const fits = + bookingIds.length === 1 || + (key === 'c-high,g1' && attempt > 1); + + return { + valid: fits, + violations: fits ? [] : ['Train capacity exceeded'], + warnings: [], + }; + }, + ); + + const plan = await service.previewReschedule('sched-1', { + incomingBookingIds: ['g1'], + trigger: 'GOVERNMENT_PREEMPT', + }); + + expect(plan.retained.map((b) => b.id)).toEqual(['g1']); + expect(plan.readmitted.map((b) => b.id)).toEqual(['c-high']); + expect(plan.displaced.map((b) => b.id)).toEqual(['c-low']); + expect(plan.finalBookingIds).toEqual(['g1', 'c-high']); + }); + + it('maintenance reschedule updates departure and rebalances bookings', async () => { + const commercial = makeBooking('c1', 'BKG-COMM', { priorityScore: 10 }); + const schedule = { + id: 'sched-1', + status: 'DRAFT', + scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'), + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + scheduleBookings: [{ bookingId: 'c1', booking: commercial }], + }; + + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(schedule); + bookingsRepository.findByIdsForScheduling.mockResolvedValue([commercial]); + trainSchedulingService.previewTrainSchedule.mockResolvedValue({ + valid: true, + violations: [], + warnings: [], + }); + trainSchedulesRepository.updateStatus.mockResolvedValue(undefined); + trainSchedulingService.assignBookingsToSchedule.mockResolvedValue({ id: 'sched-1' }); + + const result = await service.maintenanceReschedule( + 'sched-1', + { + incomingBookingIds: ['c1'], + trigger: 'TRAIN_MAINTENANCE', + reason: 'Locomotive service', + newDepartureDate: '2026-06-22T10:00:00.000Z', + }, + 'staff-1', + ); + + expect(trainSchedulesRepository.updateStatus).toHaveBeenCalledWith( + 'sched-1', + 'DRAFT', + { scheduledDepartureDate: new Date('2026-06-22T10:00:00.000Z') }, + ); + expect(schedulingRescheduleRepository.createEvent).toHaveBeenCalledWith( + expect.objectContaining({ + trigger: 'TRAIN_MAINTENANCE', + actorUserId: 'staff-1', + reason: 'Locomotive service', + }), + ); + expect(result.plan.trigger).toBe('TRAIN_MAINTENANCE'); + expect(result.plan.finalBookingIds).toEqual(['c1']); + }); +}); diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts new file mode 100644 index 000000000..7191a203e --- /dev/null +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts @@ -0,0 +1,230 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { SchedulingStatus, TrainScheduleStatus } from '@edr/types'; + +import { Booking } from '../bookings/entities/booking.entity'; +import { BookingsRepository } from '../bookings/bookings.repository'; +import { compareSchedulingPriority } from '../scheduling/compare-scheduling-priority.util'; +import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; +import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; +import { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto'; +import { SchedulingRescheduleRepository } from './scheduling-reschedule.repository'; + +export interface RescheduleBookingSummary { + id: string; + reference: string; + isGovernment: boolean; + priorityScore: number; + governmentInstitution?: string | null; +} + +export interface ReschedulePlan { + scheduleId: string; + trigger: PreviewRescheduleDto['trigger']; + retained: RescheduleBookingSummary[]; + displaced: RescheduleBookingSummary[]; + readmitted: RescheduleBookingSummary[]; + finalBookingIds: string[]; + warnings: string[]; +} + +@Injectable() +export class SchedulingRescheduleService { + constructor( + private readonly trainSchedulesRepository: TrainSchedulesRepository, + private readonly bookingsRepository: BookingsRepository, + private readonly trainSchedulingService: TrainSchedulingService, + private readonly schedulingRescheduleRepository: SchedulingRescheduleRepository, + ) {} + + /** Preview who is retained, displaced, and readmitted on a schedule. */ + async previewReschedule( + scheduleId: string, + dto: PreviewRescheduleDto, + ): Promise { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (schedule.status === TrainScheduleStatus.Dispatched) { + throw new BadRequestException('Cannot reschedule a dispatched train'); + } + + const currentOnSchedule = (schedule.scheduleBookings ?? []) + .map((link) => link.booking) + .filter((b): b is Booking => Boolean(b)); + + const incoming = await this.bookingsRepository.findByIdsForScheduling(dto.incomingBookingIds); + if (incoming.length !== dto.incomingBookingIds.length) { + throw new BadRequestException('One or more incoming bookings were not found'); + } + + const mergedMap = new Map(); + for (const booking of [...currentOnSchedule, ...incoming]) { + mergedMap.set(booking.id, booking); + } + const sorted = [...mergedMap.values()].sort(compareSchedulingPriority); + + const warnings: string[] = []; + const retained: Booking[] = []; + + for (const booking of sorted) { + const candidate = [...retained, booking]; + const fits = await this.bookingsFitOnSchedule(candidate, schedule, scheduleId); + if (fits) { + retained.push(booking); + } else if (currentOnSchedule.some((b) => b.id === booking.id)) { + warnings.push(`Booking ${booking.reference} will be displaced from the train`); + } + } + + const retainedIds = new Set(retained.map((b) => b.id)); + const displacedFromCurrent = currentOnSchedule.filter((b) => !retainedIds.has(b.id)); + const readmitted: Booking[] = []; + + const displacedCommercial = displacedFromCurrent + .filter((b) => !b.isGovernment) + .sort(compareSchedulingPriority); + + for (const booking of displacedCommercial) { + const candidate = [...retained, ...readmitted, booking]; + const fits = await this.bookingsFitOnSchedule(candidate, schedule, scheduleId); + if (fits) { + readmitted.push(booking); + warnings.push(`Booking ${booking.reference} readmitted after government placement`); + } + } + + const finalIds = [...retained, ...readmitted].map((b) => b.id); + const displacedIds = new Set(displacedFromCurrent.map((b) => b.id)); + for (const id of readmitted.map((b) => b.id)) { + displacedIds.delete(id); + } + const displaced = displacedFromCurrent.filter((b) => displacedIds.has(b.id)); + + return { + scheduleId, + trigger: dto.trigger, + retained: retained.map((b) => this.toSummary(b)), + displaced: displaced.map((b) => this.toSummary(b)), + readmitted: readmitted.map((b) => this.toSummary(b)), + finalBookingIds: finalIds, + warnings, + }; + } + + /** Execute a confirmed reschedule plan. */ + async executeReschedule( + scheduleId: string, + dto: ExecuteRescheduleDto, + actorUserId?: string, + ) { + const plan = await this.previewReschedule(scheduleId, dto); + const expectedDisplaced = new Set(plan.displaced.map((b) => b.id)); + const providedDisplaced = new Set(dto.displacedBookingIds); + if ( + expectedDisplaced.size !== providedDisplaced.size || + [...expectedDisplaced].some((id) => !providedDisplaced.has(id)) + ) { + throw new BadRequestException('Displaced booking list does not match current preview'); + } + + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + + if (dto.newDepartureDate && schedule) { + await this.trainSchedulesRepository.updateStatus( + scheduleId, + schedule.status as TrainScheduleStatus, + { scheduledDepartureDate: new Date(dto.newDepartureDate) }, + ); + } + + for (const bookingId of dto.displacedBookingIds) { + try { + await this.trainSchedulingService.unassignBooking(scheduleId, bookingId); + } catch { + await this.bookingsRepository.updateSchedulingFields(bookingId, { + schedulingStatus: SchedulingStatus.Eligible, + wagonsRequired: null, + }); + } + } + + const assignResult = await this.trainSchedulingService.assignBookingsToSchedule(scheduleId, { + bookingIds: dto.finalBookingIds, + forceAssign: dto.trigger === 'GOVERNMENT_PREEMPT', + }); + + await this.schedulingRescheduleRepository.createEvent({ + trainScheduleId: scheduleId, + trigger: dto.trigger, + actorUserId, + reason: dto.reason, + planSnapshot: plan as unknown as Record, + displacedBookingIds: dto.displacedBookingIds, + }); + + return { plan, schedule: assignResult }; + } + + /** Maintenance shortcut: new departure + rebalance. */ + async maintenanceReschedule( + scheduleId: string, + dto: PreviewRescheduleDto & { newDepartureDate: string }, + actorUserId?: string, + ) { + const currentIds = ( + await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId) + )?.scheduleBookings?.map((l) => l.bookingId) ?? []; + + const preview = await this.previewReschedule(scheduleId, { + ...dto, + trigger: 'TRAIN_MAINTENANCE', + incomingBookingIds: currentIds.length ? currentIds : dto.incomingBookingIds, + }); + + return this.executeReschedule( + scheduleId, + { + ...dto, + trigger: 'TRAIN_MAINTENANCE', + incomingBookingIds: dto.incomingBookingIds, + finalBookingIds: preview.finalBookingIds, + displacedBookingIds: preview.displaced.map((b) => b.id), + }, + actorUserId, + ); + } + + private async bookingsFitOnSchedule( + bookings: Booking[], + schedule: { scheduledDepartureDate: Date; originStationId: string; destinationStationId: string }, + scheduleId: string, + ): Promise { + if (!bookings.length) return true; + const preview = await this.trainSchedulingService.previewTrainSchedule({ + bookingIds: bookings.map((b) => b.id), + scheduleDate: schedule.scheduledDepartureDate.toISOString(), + originStationId: schedule.originStationId, + destinationStationId: schedule.destinationStationId, + targetScheduleId: scheduleId, + }); + return preview.valid; + } + + private toSummary(booking: Booking): RescheduleBookingSummary { + return { + id: booking.id, + reference: booking.reference, + isGovernment: booking.isGovernment, + priorityScore: booking.priorityScore, + governmentInstitution: booking.governmentInstitution, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/scheduling/compare-scheduling-priority.util.ts b/apps/edr-freight-api/src/modules/scheduling/compare-scheduling-priority.util.ts new file mode 100644 index 000000000..a7f7350c4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/scheduling/compare-scheduling-priority.util.ts @@ -0,0 +1,19 @@ +export interface SchedulingPriorityBooking { + isGovernment?: boolean; + priorityScore?: number | null; + scheduledDate: Date | string; +} + +/** Government first, then priority score, then earliest scheduled date. */ +export function compareSchedulingPriority( + a: SchedulingPriorityBooking, + b: SchedulingPriorityBooking, +): number { + const govDiff = Number(Boolean(b.isGovernment)) - Number(Boolean(a.isGovernment)); + if (govDiff !== 0) return govDiff; + + const priorityDiff = (b.priorityScore ?? 0) - (a.priorityScore ?? 0); + if (priorityDiff !== 0) return priorityDiff; + + return new Date(a.scheduledDate).getTime() - new Date(b.scheduledDate).getTime(); +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts index 4edd09f09..feb7fa318 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts @@ -1,4 +1,5 @@ import { BaseEntity } from '@edr/api-common'; +import { TrainScheduleStatus as TrainScheduleStatusEnum } from '@edr/types'; import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm'; import { Yard } from '../../rule-engine/entities/yard.entity'; @@ -7,11 +8,11 @@ import { TrainSet } from '../../train-sets/entities/train-set.entity'; import { TrainScheduleBooking } from './train-schedule-booking.entity'; export const TRAIN_SCHEDULE_STATUSES = [ - 'DRAFT', - 'SCHEDULED', - 'DISPATCHED', - 'ARRIVED', - 'CANCELLED', + TrainScheduleStatusEnum.Draft, + TrainScheduleStatusEnum.Scheduled, + TrainScheduleStatusEnum.Dispatched, + TrainScheduleStatusEnum.Arrived, + TrainScheduleStatusEnum.Cancelled, ] as const; export type TrainScheduleStatus = (typeof TRAIN_SCHEDULE_STATUSES)[number]; @@ -57,6 +58,27 @@ export class TrainSchedule extends BaseEntity { @Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' }) status!: TrainScheduleStatus; + @Column({ name: 'train_number', type: 'varchar', length: 20, nullable: true }) + trainNumber?: string | null; + + @Column({ name: 'direction', type: 'varchar', length: 10, nullable: true }) + direction?: string | null; + + @Column({ name: 'actual_departure_at', type: 'timestamptz', nullable: true }) + actualDepartureAt?: Date | null; + + @Column({ name: 'actual_arrival_at', type: 'timestamptz', nullable: true }) + actualArrivalAt?: Date | null; + + @Column({ name: 'prepared_by_user_id', type: 'uuid', nullable: true }) + preparedByUserId?: string | null; + + @Column({ name: 'checked_by_user_id', type: 'uuid', nullable: true }) + checkedByUserId?: string | null; + + @Column({ name: 'max_wagons', type: 'int', default: 53 }) + maxWagons!: number; + @OneToMany(() => TrainScheduleBooking, (scheduleBooking) => scheduleBooking.trainSchedule) scheduleBookings?: TrainScheduleBooking[]; } diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-allocation-bulk-load.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-allocation-bulk-load.entity.ts new file mode 100644 index 000000000..b3ecb4618 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-allocation-bulk-load.entity.ts @@ -0,0 +1,53 @@ +import { BaseEntity } from '@edr/api-common'; +import { BulkPricingUnit } from '@edr/types'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Booking } from '../../bookings/entities/booking.entity'; +import { CargoType } from '../../rule-engine/entities/cargo-type.entity'; +import { WagonBookingAllocation } from './wagon-booking-allocation.entity'; + +export const BULK_PRICING_UNITS = [ + BulkPricingUnit.PerWagon, + BulkPricingUnit.PerTon, + BulkPricingUnit.PerItem, +] as const; + +@Entity({ schema: 'freight', name: 'wagon_allocation_bulk_loads' }) +@Index(['bookingId']) +export class WagonAllocationBulkLoad extends BaseEntity { + @Column({ name: 'wagon_booking_allocation_id', type: 'uuid', unique: true }) + wagonBookingAllocationId!: string; + + @ManyToOne(() => WagonBookingAllocation, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'wagon_booking_allocation_id' }) + allocation?: WagonBookingAllocation; + + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; + + @Column({ name: 'cargo_type_id', type: 'uuid', nullable: true }) + cargoTypeId?: string | null; + + @ManyToOne(() => CargoType, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'cargo_type_id' }) + cargoType?: CargoType | null; + + @Column({ name: 'cargo_description', type: 'text', nullable: true }) + cargoDescription?: string | null; + + @Column({ name: 'pricing_unit', type: 'varchar', length: 20, default: BulkPricingUnit.PerTon }) + pricingUnit!: string; + + @Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3, default: 0 }) + quantity!: number; + + @Column({ name: 'weight_tons', type: 'numeric', precision: 10, scale: 3, default: 0 }) + weightTons!: number; + + @Column({ name: 'truck_plate_number', type: 'varchar', length: 32, nullable: true }) + truckPlateNumber?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-allocation-container-item.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-allocation-container-item.entity.ts new file mode 100644 index 000000000..3885e6d15 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-allocation-container-item.entity.ts @@ -0,0 +1,54 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { BookingContainer } from '../../bookings/entities/booking-container.entity'; +import { Container } from '../../container-management/entities/container.entity'; +import { ContainerType } from '../../rule-engine/entities/container-type.entity'; +import { WagonBookingAllocation } from './wagon-booking-allocation.entity'; + +@Entity({ schema: 'freight', name: 'wagon_allocation_container_items' }) +@Index(['wagonBookingAllocationId']) +export class WagonAllocationContainerItem extends BaseEntity { + @Column({ name: 'wagon_booking_allocation_id', type: 'uuid' }) + wagonBookingAllocationId!: string; + + @ManyToOne(() => WagonBookingAllocation, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'wagon_booking_allocation_id' }) + allocation?: WagonBookingAllocation; + + @Column({ name: 'booking_container_id', type: 'uuid', nullable: true }) + bookingContainerId?: string | null; + + @ManyToOne(() => BookingContainer, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'booking_container_id' }) + bookingContainer?: BookingContainer | null; + + @Column({ name: 'container_id', type: 'uuid', nullable: true }) + containerId?: string | null; + + @ManyToOne(() => Container, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'container_id' }) + container?: Container | null; + + @Column({ name: 'container_number', type: 'varchar', length: 64, nullable: true }) + containerNumber?: string | null; + + @Column({ name: 'container_type_id', type: 'uuid', nullable: true }) + containerTypeId?: string | null; + + @ManyToOne(() => ContainerType, { nullable: true }) + @JoinColumn({ name: 'container_type_id' }) + containerType?: ContainerType | null; + + @Column({ name: 'position_on_wagon', type: 'smallint', nullable: true }) + positionOnWagon?: number | null; + + @Column({ name: 'seal_number', type: 'varchar', length: 64, nullable: true }) + sealNumber?: string | null; + + @Column({ name: 'chassis_number', type: 'varchar', length: 64, nullable: true }) + chassisNumber?: string | null; + + @Column({ name: 'gross_weight_tons', type: 'numeric', precision: 10, scale: 3, nullable: true }) + grossWeightTons?: number | null; +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-booking-allocation.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-booking-allocation.entity.ts index 4c78256fe..6bec9f74e 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-booking-allocation.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-booking-allocation.entity.ts @@ -1,8 +1,22 @@ import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { AllocationLoadType, AllocationStatus } from '@edr/types'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; import { Booking } from '../../bookings/entities/booking.entity'; import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity'; +import { WagonAllocationContainerItem } from './wagon-allocation-container-item.entity'; + +export const ALLOCATION_LOAD_TYPES = [ + AllocationLoadType.Container, + AllocationLoadType.Bulk, +] as const; + +export const ALLOCATION_STATUSES = [ + AllocationStatus.Planned, + AllocationStatus.Reserved, + AllocationStatus.Loaded, + AllocationStatus.Departed, +] as const; @Entity({ schema: 'freight', name: 'wagon_booking_allocations' }) @Index(['trainSetWagonId', 'bookingId']) @@ -23,4 +37,19 @@ export class WagonBookingAllocation extends BaseEntity { @Column({ name: 'allocated_weight_tons', type: 'numeric', precision: 10, scale: 3 }) allocatedWeightTons!: number; + + @Column({ name: 'load_type', type: 'varchar', length: 20, nullable: true }) + loadType?: string | null; + + @Column({ name: 'status', type: 'varchar', length: 20, default: 'PLANNED' }) + status!: string; + + @Column({ name: 'confirmed_at', type: 'timestamptz', nullable: true }) + confirmedAt?: Date | null; + + @Column({ name: 'confirmed_by_user_id', type: 'uuid', nullable: true }) + confirmedByUserId?: string | null; + + @OneToMany(() => WagonAllocationContainerItem, (item) => item.allocation) + containerItems?: WagonAllocationContainerItem[]; } diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts index d7360226f..4ccfcd469 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts @@ -1,7 +1,7 @@ import { BaseRepository } from '@edr/api-common'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { DeepPartial, EntityManager, In, Repository } from 'typeorm'; import { TrainScheduleBooking } from './entities/train-schedule-booking.entity'; @@ -13,4 +13,38 @@ export class TrainScheduleBookingsRepository extends BaseRepository[], + manager?: EntityManager, + ): Promise { + if (!records.length) return []; + const repo = this.repo(manager); + return repo.save(repo.create(records)); + } + + async deleteByScheduleAndBooking( + trainScheduleId: string, + bookingId: string, + manager?: EntityManager, + ): Promise { + await this.repo(manager).delete({ trainScheduleId, bookingId }); + } + + async existsForBooking(bookingId: string, manager?: EntityManager): Promise { + const count = await this.repo(manager).count({ where: { bookingId } }); + return count > 0; + } + + findByBookingIds(bookingIds: string[], manager?: EntityManager): Promise { + if (!bookingIds.length) return Promise.resolve([]); + return this.repo(manager).find({ + where: { bookingId: In(bookingIds) }, + select: { id: true, bookingId: true, trainScheduleId: true }, + }); + } } diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.module.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.module.ts index 9fa40897a..f9ce84f7d 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.module.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.module.ts @@ -3,22 +3,38 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { TrainScheduleBooking } from './entities/train-schedule-booking.entity'; import { TrainSchedule } from './entities/train-schedule.entity'; +import { WagonAllocationBulkLoad } from './entities/wagon-allocation-bulk-load.entity'; +import { WagonAllocationContainerItem } from './entities/wagon-allocation-container-item.entity'; import { WagonBookingAllocation } from './entities/wagon-booking-allocation.entity'; import { TrainScheduleBookingsRepository } from './train-schedule-bookings.repository'; import { TrainSchedulesRepository } from './train-schedules.repository'; +import { WagonAllocationBulkLoadsRepository } from './wagon-allocation-bulk-loads.repository'; +import { WagonAllocationContainerItemsRepository } from './wagon-allocation-container-items.repository'; import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.repository'; @Module({ - imports: [TypeOrmModule.forFeature([TrainSchedule, TrainScheduleBooking, WagonBookingAllocation])], + imports: [ + TypeOrmModule.forFeature([ + TrainSchedule, + TrainScheduleBooking, + WagonBookingAllocation, + WagonAllocationContainerItem, + WagonAllocationBulkLoad, + ]), + ], providers: [ TrainSchedulesRepository, TrainScheduleBookingsRepository, WagonBookingAllocationsRepository, + WagonAllocationContainerItemsRepository, + WagonAllocationBulkLoadsRepository, ], exports: [ TrainSchedulesRepository, TrainScheduleBookingsRepository, WagonBookingAllocationsRepository, + WagonAllocationContainerItemsRepository, + WagonAllocationBulkLoadsRepository, ], }) export class TrainSchedulesModule {} diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts index b6f18eaf2..8ec002d49 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts @@ -1,9 +1,9 @@ import { BaseRepository } from '@edr/api-common'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { EntityManager, Repository } from 'typeorm'; -import { TrainSchedule } from './entities/train-schedule.entity'; +import { TrainSchedule, TrainScheduleStatus } from './entities/train-schedule.entity'; @Injectable() export class TrainSchedulesRepository extends BaseRepository { @@ -13,4 +13,48 @@ export class TrainSchedulesRepository extends BaseRepository { ) { super(repository); } + + private repo(manager?: EntityManager) { + return manager ? manager.getRepository(TrainSchedule) : this.repository; + } + + findByIdWithFullGraph(id: string, manager?: EntityManager): Promise { + return this.repo(manager).findOne({ + where: { id }, + relations: { + route: true, + trainSet: { + locomotive: true, + wagons: { + wagonType: true, + physicalWagon: true, + allocations: { + booking: { company: true, bookingContainers: { containerType: true } }, + containerItems: true, + }, + }, + }, + originStation: true, + destinationStation: true, + scheduleBookings: { + booking: { + company: true, + originYard: true, + destinationYard: true, + bookingContainers: { containerType: true }, + cargoType: true, + }, + }, + }, + }); + } + + async updateStatus( + id: string, + status: TrainScheduleStatus, + extra?: Partial, + manager?: EntityManager, + ): Promise { + await this.repo(manager).update(id, { status, ...extra } as never); + } } diff --git a/apps/edr-freight-api/src/modules/train-schedules/wagon-allocation-bulk-loads.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/wagon-allocation-bulk-loads.repository.ts new file mode 100644 index 000000000..dfaf602fa --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/wagon-allocation-bulk-loads.repository.ts @@ -0,0 +1,36 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DeepPartial, EntityManager, In, Repository } from 'typeorm'; + +import { WagonAllocationBulkLoad } from './entities/wagon-allocation-bulk-load.entity'; + +@Injectable() +export class WagonAllocationBulkLoadsRepository extends BaseRepository { + constructor( + @InjectRepository(WagonAllocationBulkLoad) + repository: Repository, + ) { + super(repository); + } + + private repo(manager?: EntityManager) { + return manager + ? manager.getRepository(WagonAllocationBulkLoad) + : this.repository; + } + + async createMany( + items: DeepPartial[], + manager?: EntityManager, + ): Promise { + if (!items.length) return []; + const repo = this.repo(manager); + return repo.save(repo.create(items)); + } + + async deleteByAllocationIds(allocationIds: string[], manager?: EntityManager): Promise { + if (!allocationIds.length) return; + await this.repo(manager).delete({ wagonBookingAllocationId: In(allocationIds) }); + } +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/wagon-allocation-container-items.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/wagon-allocation-container-items.repository.ts new file mode 100644 index 000000000..0ff7a0548 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/wagon-allocation-container-items.repository.ts @@ -0,0 +1,36 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DeepPartial, EntityManager, In, Repository } from 'typeorm'; + +import { WagonAllocationContainerItem } from './entities/wagon-allocation-container-item.entity'; + +@Injectable() +export class WagonAllocationContainerItemsRepository extends BaseRepository { + constructor( + @InjectRepository(WagonAllocationContainerItem) + repository: Repository, + ) { + super(repository); + } + + private repo(manager?: EntityManager) { + return manager + ? manager.getRepository(WagonAllocationContainerItem) + : this.repository; + } + + async createMany( + items: DeepPartial[], + manager?: EntityManager, + ): Promise { + if (!items.length) return []; + const repo = this.repo(manager); + return repo.save(repo.create(items)); + } + + async deleteByAllocationIds(allocationIds: string[], manager?: EntityManager): Promise { + if (!allocationIds.length) return; + await this.repo(manager).delete({ wagonBookingAllocationId: In(allocationIds) }); + } +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/wagon-booking-allocations.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/wagon-booking-allocations.repository.ts index 067dddaf7..620fd1343 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/wagon-booking-allocations.repository.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/wagon-booking-allocations.repository.ts @@ -1,7 +1,7 @@ import { BaseRepository } from '@edr/api-common'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { DeepPartial, EntityManager, Repository } from 'typeorm'; import { WagonBookingAllocation } from './entities/wagon-booking-allocation.entity'; @@ -13,4 +13,43 @@ export class WagonBookingAllocationsRepository extends BaseRepository[], + manager?: EntityManager, + ): Promise { + if (!records.length) return []; + const repo = this.repo(manager); + return repo.save(repo.create(records)); + } + + findByScheduleId(trainScheduleId: string, manager?: EntityManager): Promise { + return this.repo(manager) + .createQueryBuilder('allocation') + .innerJoin('allocation.trainSetWagon', 'wagon') + .innerJoin('wagon.trainSet', 'trainSet') + .innerJoin('trainSet.trainSchedule', 'schedule') + .where('schedule.id = :trainScheduleId', { trainScheduleId }) + .leftJoinAndSelect('allocation.booking', 'booking') + .getMany(); + } + + async deleteByTrainSetId(trainSetId: string, manager?: EntityManager): Promise { + const allocations = await this.repo(manager) + .createQueryBuilder('allocation') + .innerJoin('allocation.trainSetWagon', 'wagon') + .where('wagon.train_set_id = :trainSetId', { trainSetId }) + .select(['allocation.id']) + .getMany(); + + const ids = allocations.map((a) => a.id); + if (ids.length) { + await this.repo(manager).delete(ids); + } + return ids; + } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/derive-schedule-direction.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/derive-schedule-direction.util.spec.ts new file mode 100644 index 000000000..330c4d4e9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/derive-schedule-direction.util.spec.ts @@ -0,0 +1,21 @@ +import { deriveScheduleDirection } from './derive-schedule-direction.util'; + +describe('deriveScheduleDirection', () => { + it('returns IMPORT when origin is Djibouti', () => { + expect( + deriveScheduleDirection({ country: 'Djibouti' }, { country: 'Ethiopia' }), + ).toBe('IMPORT'); + }); + + it('returns EXPORT when destination is Djibouti and origin is not', () => { + expect( + deriveScheduleDirection({ country: 'Ethiopia' }, { country: 'Djibouti' }), + ).toBe('EXPORT'); + }); + + it('returns DOMESTIC for intra-Ethiopia routes', () => { + expect( + deriveScheduleDirection({ country: 'Ethiopia' }, { country: 'Ethiopia' }), + ).toBe('DOMESTIC'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/derive-schedule-direction.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/derive-schedule-direction.util.ts new file mode 100644 index 000000000..f66a06c89 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/derive-schedule-direction.util.ts @@ -0,0 +1,19 @@ +import type { ScheduleTradeDirection } from '@edr/types'; + +type YardLike = { country?: string | null }; + +export function deriveScheduleDirection( + originYard: YardLike, + destinationYard: YardLike, +): ScheduleTradeDirection { + const originCountry = originYard.country?.trim(); + const destinationCountry = destinationYard.country?.trim(); + + if (originCountry === 'Djibouti') { + return 'IMPORT'; + } + if (destinationCountry === 'Djibouti' && originCountry !== 'Djibouti') { + return 'EXPORT'; + } + return 'DOMESTIC'; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/assign-bookings.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/assign-bookings.dto.ts new file mode 100644 index 000000000..b5e93f5da --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/assign-bookings.dto.ts @@ -0,0 +1,86 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + ArrayMinSize, + IsArray, + IsBoolean, + IsInt, + IsNumber, + IsOptional, + IsString, + IsUUID, + Min, + ValidateNested, +} from 'class-validator'; + +export class ContainerPlacementDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + bookingContainerId!: string; + + @ApiProperty({ minimum: 0 }) + @IsInt() + @Min(0) + unitIndex!: number; + + @ApiProperty({ minimum: 1 }) + @IsInt() + @Min(1) + sequenceNo!: number; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + containerId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + containerNumber?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + sealNumber?: string; +} + +export class AssignBookingsDto { + @ApiProperty({ type: [String] }) + @IsArray() + @ArrayMinSize(1) + @IsUUID('4', { each: true }) + bookingIds!: string[]; + + @ApiPropertyOptional({ description: 'Bypass soft hold and overweight warnings' }) + @IsOptional() + @IsBoolean() + forceAssign?: boolean; + + @ApiPropertyOptional({ type: [ContainerPlacementDto] }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ContainerPlacementDto) + containerPlacements?: ContainerPlacementDto[]; + + @ApiPropertyOptional({ description: 'Maximum total booking weight allowed on this train' }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + maxTrainWeightTons?: number; + + @ApiPropertyOptional({ description: 'Maximum total wagon length allowed on this train' }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + maxTrainLengthMeters?: number; + + @ApiPropertyOptional({ description: 'Maximum wagons allowed on this train' }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + maxWagonsPerTrain?: number; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts index 1b4fa29d8..5c2486fa3 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts @@ -1,5 +1,6 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsDateString, IsUUID } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsDateString, IsInt, IsNumber, IsOptional, IsUUID, Min } from 'class-validator'; export class CreateContainerTrainScheduleDto { @ApiProperty({ format: 'uuid' }) @@ -13,4 +14,25 @@ export class CreateContainerTrainScheduleDto { @ApiProperty({ format: 'uuid' }) @IsUUID() locomotiveId!: string; + + @ApiPropertyOptional({ description: 'Maximum total booking weight allowed on this train' }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + maxTrainWeightTons?: number; + + @ApiPropertyOptional({ description: 'Maximum total wagon length allowed on this train' }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + maxTrainLengthMeters?: number; + + @ApiPropertyOptional({ description: 'Maximum wagons allowed on this train' }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + maxWagonsPerTrain?: number; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-bookings.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-bookings.dto.ts new file mode 100644 index 000000000..3660426ed --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-bookings.dto.ts @@ -0,0 +1,23 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsOptional, IsUUID } from 'class-validator'; + +export class GetEligibleBookingsDto { + @ApiPropertyOptional({ enum: ['CONTAINER', 'BULK'] }) + @IsOptional() + @IsIn(['CONTAINER', 'BULK']) + freightType?: 'CONTAINER' | 'BULK'; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + originStationId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + destinationStationId?: string; + + @ApiPropertyOptional() + @IsOptional() + schedulingStatus?: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-bulk-bookings.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-bulk-bookings.dto.ts new file mode 100644 index 000000000..9a2cafa2f --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-bulk-bookings.dto.ts @@ -0,0 +1,18 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsUUID } from 'class-validator'; + +export class GetEligibleBulkBookingsDto { + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + originStationId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + destinationStationId?: string; + + @ApiPropertyOptional({ example: 'HOLDING' }) + @IsOptional() + schedulingStatus?: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-container-bookings.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-container-bookings.dto.ts index 2ca15dc0c..e33327f38 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-container-bookings.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-container-bookings.dto.ts @@ -1,5 +1,5 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsDateString, IsOptional, IsUUID } from 'class-validator'; +import { IsOptional, IsUUID } from 'class-validator'; export class GetEligibleContainerBookingsDto { @ApiPropertyOptional({ format: 'uuid' }) @@ -12,8 +12,7 @@ export class GetEligibleContainerBookingsDto { @IsUUID() destinationStationId?: string; - @ApiPropertyOptional({ example: '2026-06-20T08:00:00.000Z' }) + @ApiPropertyOptional({ example: 'HOLDING' }) @IsOptional() - @IsDateString() - scheduleDate?: string; + schedulingStatus?: string; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/pin-wagons.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/pin-wagons.dto.ts new file mode 100644 index 000000000..54f96e5c0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/pin-wagons.dto.ts @@ -0,0 +1,22 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ArrayMinSize, IsArray, IsUUID, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class PinWagonAssignmentDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + trainSetWagonId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + physicalWagonId!: string; +} + +export class PinWagonsDto { + @ApiProperty({ type: [PinWagonAssignmentDto] }) + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => PinWagonAssignmentDto) + assignments!: PinWagonAssignmentDto[]; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-bulk-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-bulk-train-schedule.dto.ts new file mode 100644 index 000000000..d2efe75e4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-bulk-train-schedule.dto.ts @@ -0,0 +1,3 @@ +import { PreviewTrainScheduleDto } from './preview-train-schedule.dto'; + +export class PreviewBulkTrainScheduleDto extends PreviewTrainScheduleDto {} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-container-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-container-train-schedule.dto.ts index e3e142a61..28ee62070 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-container-train-schedule.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-container-train-schedule.dto.ts @@ -1,22 +1,3 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { ArrayMinSize, IsArray, IsDateString, IsUUID } from 'class-validator'; +import { PreviewTrainScheduleDto } from './preview-train-schedule.dto'; -export class PreviewContainerTrainScheduleDto { - @ApiProperty({ type: [String] }) - @IsArray() - @ArrayMinSize(1) - @IsUUID('4', { each: true }) - bookingIds!: string[]; - - @ApiProperty({ example: '2026-06-20T08:00:00.000Z' }) - @IsDateString() - scheduleDate!: string; - - @ApiProperty({ format: 'uuid' }) - @IsUUID() - originStationId!: string; - - @ApiProperty({ format: 'uuid' }) - @IsUUID() - destinationStationId!: string; -} +export class PreviewContainerTrainScheduleDto extends PreviewTrainScheduleDto {} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-train-schedule.dto.ts new file mode 100644 index 000000000..56cd9592b --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-train-schedule.dto.ts @@ -0,0 +1,61 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + ArrayMinSize, + IsArray, + IsDateString, + IsInt, + IsNumber, + IsOptional, + IsUUID, + Min, +} from 'class-validator'; + +export class PreviewTrainScheduleDto { + @ApiProperty({ type: [String] }) + @IsArray() + @ArrayMinSize(1) + @IsUUID('4', { each: true }) + bookingIds!: string[]; + + @ApiProperty({ example: '2026-06-20T08:00:00.000Z' }) + @IsDateString() + scheduleDate!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + originStationId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + destinationStationId!: string; + + @ApiPropertyOptional({ + format: 'uuid', + description: 'Allow bookings already assigned to this schedule (re-assign / reschedule)', + }) + @IsOptional() + @IsUUID() + targetScheduleId?: string; + + @ApiPropertyOptional({ description: 'Maximum total booking weight allowed on this train' }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + maxTrainWeightTons?: number; + + @ApiPropertyOptional({ description: 'Maximum total wagon length allowed on this train' }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + maxTrainLengthMeters?: number; + + @ApiPropertyOptional({ description: 'Maximum wagons allowed on this train' }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + maxWagonsPerTrain?: number; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts new file mode 100644 index 000000000..d47195976 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts @@ -0,0 +1,40 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsInt, IsNumber, IsOptional, Min } from 'class-validator'; + +export class UpdateTrainSchedulingGlobalRulesDto { + @ApiPropertyOptional({ example: 760 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + maxTrainLengthMeters?: number; + + @ApiPropertyOptional({ example: 3500 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + maxTrainWeightTons?: number; + + @ApiPropertyOptional({ example: 53 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + maxWagonsPerTrain?: number; + + @ApiPropertyOptional({ example: 30 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0.001) + max20ftContainerWeightTons?: number; + + @ApiPropertyOptional({ example: 10 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0) + max20ftPairWeightDiffTons?: number; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts new file mode 100644 index 000000000..326915933 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts @@ -0,0 +1,44 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity } from 'typeorm'; + +@Entity({ schema: 'freight', name: 'train_scheduling_global_rules' }) +export class TrainSchedulingGlobalRules extends BaseEntity { + @Column({ + name: 'max_train_length_meters', + type: 'numeric', + precision: 10, + scale: 2, + default: 760, + }) + maxTrainLengthMeters!: number; + + @Column({ + name: 'max_train_weight_tons', + type: 'numeric', + precision: 10, + scale: 3, + default: 3500, + }) + maxTrainWeightTons!: number; + + @Column({ name: 'max_wagons_per_train', type: 'int', default: 53 }) + maxWagonsPerTrain!: number; + + @Column({ + name: 'max_20ft_container_weight_tons', + type: 'numeric', + precision: 8, + scale: 3, + default: 30, + }) + max20ftContainerWeightTons!: number; + + @Column({ + name: 'max_20ft_pair_weight_diff_tons', + type: 'numeric', + precision: 8, + scale: 3, + default: 10, + }) + max20ftPairWeightDiffTons!: number; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.spec.ts new file mode 100644 index 000000000..76ea00422 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.spec.ts @@ -0,0 +1,127 @@ +import { Booking } from '../bookings/entities/booking.entity'; +import { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { + computeFleetAvailability, + selectBookingsWithinFleetCap, + sortBookingsForScheduling, + summarizeFleetWarnings, + wagonsRequiredForBooking, +} from './fleet-plan.util'; +import { buildContainerWagonPlan, type WagonPlanSlot } from './wagon-plan.util'; + +const nw5: WagonType = { + id: 'wt-nw5', + code: 'NW5', + name: 'Flat Wagon', + capacityTons: 70, + lengthMeters: 14, + maxWagonsPerTrain: 53, + supportedLoadTypes: ['CONTAINER'], + isActive: true, + supportsContainer: true, +} as WagonType; + +const makeBooking = ( + id: string, + extra: Partial = {}, +): Booking => + ({ + id, + reference: id, + freightType: 'CONTAINER', + isGovernment: false, + priorityScore: 0, + scheduledDate: new Date('2026-06-20T08:00:00.000Z'), + cargoTotalWeightVgm: 50, + bookingContainers: [{ id: `${id}-line`, quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 25 }], + ...extra, + }) as Booking; + +describe('fleet-plan.util', () => { + it('sorts bookings government first, then priority, then date', () => { + const bookings = [ + makeBooking('late', { scheduledDate: new Date('2026-06-22T08:00:00.000Z') }), + makeBooking('gov', { isGovernment: true, priorityScore: 0 }), + makeBooking('prio', { priorityScore: 10 }), + ]; + + const sorted = sortBookingsForScheduling(bookings); + expect(sorted.map((b) => b.id)).toEqual(['gov', 'prio', 'late']); + }); + + it('computes fleet availability with shortfall', () => { + const plan: WagonPlanSlot[] = buildContainerWagonPlan( + [ + makeBooking('b1', { + bookingContainers: [ + { id: 'b1-line', quantity: 4, wagonsRequired: 2, vgmPerUnitTons: 25 } as never, + ], + }), + ], + nw5, + ); + const fleetByTypeId = new Map([[nw5.id, 1]]); + + const rows = computeFleetAvailability(plan, fleetByTypeId, new Map([[nw5.id, 'NW5']])); + const nw5Row = rows.find((r) => r.wagonTypeCode === 'NW5'); + + expect(nw5Row?.needed).toBe(2); + expect(nw5Row?.available).toBe(1); + expect(nw5Row?.shortfall).toBe(1); + }); + + it('defers lower-priority bookings when fleet is insufficient', () => { + const high = makeBooking('high', { + priorityScore: 100, + bookingContainers: [ + { id: 'high-line', quantity: 2, wagonsRequired: 2, vgmPerUnitTons: 25 } as never, + ], + }); + const low = makeBooking('low', { + priorityScore: 1, + bookingContainers: [ + { id: 'low-line', quantity: 2, wagonsRequired: 2, vgmPerUnitTons: 25 } as never, + ], + }); + const fleet = new Map([[nw5.id, 2]]); + + const { fitting, deferred } = selectBookingsWithinFleetCap( + [low, high], + fleet, + () => nw5.id, + ); + + expect(fitting.map((b) => b.id)).toEqual(['high']); + expect(deferred).toHaveLength(1); + expect(deferred[0]?.id).toBe('low'); + expect(deferred[0]?.reason).toContain('2'); + }); + + it('summarizes fleet shortage warnings', () => { + const warnings = summarizeFleetWarnings( + [ + { + wagonTypeId: nw5.id, + wagonTypeCode: 'NW5', + needed: 5, + available: 2, + shortfall: 3, + }, + ], + [{ id: 'b1', reference: 'BKG-1', reason: 'No wagons' }], + ); + + expect(warnings.some((w) => w.includes('Fleet shortage'))).toBe(true); + expect(warnings.some((w) => w.includes('deferred'))).toBe(true); + }); + + it('counts wagons required per booking from container lines', () => { + const booking = makeBooking('b1', { + bookingContainers: [ + { id: 'b1-line-0', quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 25 } as never, + { id: 'b1-line-1', quantity: 1, wagonsRequired: 1, vgmPerUnitTons: 25 } as never, + ], + }); + expect(wagonsRequiredForBooking(booking)).toBe(2); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts new file mode 100644 index 000000000..2e721825e --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts @@ -0,0 +1,168 @@ +import type { Booking } from '../bookings/entities/booking.entity'; +import type { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { + buildBulkWagonPlan, + buildContainerWagonPlan, + buildMixedWagonPlan, + roundTons, + type WagonPlanSlot, +} from './wagon-plan.util'; + +export type FleetAvailabilityRow = { + wagonTypeId: string; + wagonTypeCode: string; + needed: number; + available: number; + shortfall: number; +}; + +export type DeferredBookingRow = { + id: string; + reference: string; + reason: string; +}; + +export function sortBookingsForScheduling(bookings: Booking[]): Booking[] { + return [...bookings].sort((a, b) => { + const govDiff = Number(Boolean(b.isGovernment)) - Number(Boolean(a.isGovernment)); + if (govDiff !== 0) return govDiff; + + const priorityDiff = (b.priorityScore ?? 0) - (a.priorityScore ?? 0); + if (priorityDiff !== 0) return priorityDiff; + + return new Date(a.scheduledDate).getTime() - new Date(b.scheduledDate).getTime(); + }); +} + +export function wagonsRequiredForBooking(booking: Booking, bulkWagonCapacity?: number): number { + if (booking.freightType === 'BULK') { + const weight = Number(booking.cargoTotalWeightVgm ?? 0); + const capacity = bulkWagonCapacity && bulkWagonCapacity > 0 ? bulkWagonCapacity : 1; + return Math.max(1, Math.ceil(weight / capacity)); + } + + const lineSlots = (booking.bookingContainers ?? []).reduce( + (sum, line) => sum + Number(line.wagonsRequired ?? 0), + 0, + ); + return Math.max(1, lineSlots); +} + +export function countSlotsByType(wagonPlan: WagonPlanSlot[]): Map { + const map = new Map(); + for (const slot of wagonPlan) { + const existing = map.get(slot.wagonTypeId) ?? { code: slot.wagonTypeCode, count: 0 }; + existing.count += 1; + map.set(slot.wagonTypeId, existing); + } + return map; +} + +export function computeFleetAvailability( + demandPlan: WagonPlanSlot[], + fleetByTypeId: Map, + fleetTypeCodes: Map, +): FleetAvailabilityRow[] { + const neededByType = countSlotsByType(demandPlan); + const typeIds = new Set([...neededByType.keys(), ...fleetByTypeId.keys()]); + + return [...typeIds].map((wagonTypeId) => { + const needed = neededByType.get(wagonTypeId)?.count ?? 0; + const available = fleetByTypeId.get(wagonTypeId) ?? 0; + return { + wagonTypeId, + wagonTypeCode: + neededByType.get(wagonTypeId)?.code ?? + fleetTypeCodes.get(wagonTypeId) ?? + wagonTypeId, + needed, + available, + shortfall: Math.max(0, needed - available), + }; + }).filter((row) => row.needed > 0 || row.available > 0); +} + +export function selectBookingsWithinFleetCap( + bookings: Booking[], + fleetByTypeId: Map, + resolveWagonTypeId: (booking: Booking) => string, + bulkWagonCapacity?: number, +): { fitting: Booking[]; deferred: DeferredBookingRow[] } { + const remaining = new Map(fleetByTypeId); + const fitting: Booking[] = []; + const deferred: DeferredBookingRow[] = []; + + for (const booking of sortBookingsForScheduling(bookings)) { + const typeId = resolveWagonTypeId(booking); + const needed = wagonsRequiredForBooking(booking, bulkWagonCapacity); + const available = remaining.get(typeId) ?? 0; + + if (available >= needed) { + remaining.set(typeId, available - needed); + fitting.push(booking); + continue; + } + + deferred.push({ + id: booking.id, + reference: booking.reference, + reason: + available > 0 + ? `Needs ${needed} wagons but only ${available} available for this type` + : `No available wagons for required type (${needed} needed)`, + }); + } + + return { fitting, deferred }; +} + +export function buildCappedWagonPlan(params: { + bookings: Booking[]; + resolvedMode: 'CONTAINER' | 'BULK' | 'MIXED'; + containerWagonType: WagonType; + bulkWagonType: WagonType; +}): WagonPlanSlot[] { + const { bookings, resolvedMode, containerWagonType, bulkWagonType } = params; + + if (resolvedMode === 'MIXED') { + const containerBookings = bookings.filter((b) => b.freightType === 'CONTAINER'); + const bulkBookings = bookings.filter((b) => b.freightType === 'BULK'); + return buildMixedWagonPlan( + containerBookings, + bulkBookings, + containerWagonType, + bulkWagonType, + ); + } + + if (resolvedMode === 'BULK') { + return buildBulkWagonPlan(bookings, bulkWagonType); + } + + return buildContainerWagonPlan(bookings, containerWagonType); +} + +export function summarizeFleetWarnings( + fleetAvailability: FleetAvailabilityRow[], + deferred: DeferredBookingRow[], +): string[] { + const warnings: string[] = []; + + for (const row of fleetAvailability.filter((r) => r.shortfall > 0)) { + warnings.push( + `Fleet shortage: need ${row.needed} ${row.wagonTypeCode}, only ${row.available} available (short ${row.shortfall})`, + ); + } + + if (deferred.length) { + warnings.push( + `${deferred.length} booking(s) deferred to next train due to insufficient fleet wagons`, + ); + } + + return warnings; +} + +export function totalAssignedWeight(bookings: Booking[]): number { + return roundTons(bookings.reduce((sum, b) => sum + Number(b.cargoTotalWeightVgm ?? 0), 0)); +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 1dd01ffba..320efb859 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -1,17 +1,27 @@ import { Body, Controller, + Delete, Get, Param, ParseUUIDPipe, + Patch, Post, Query, } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking-guards'; +import { AssignBookingsDto } from './dto/assign-bookings.dto'; import { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto'; +import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto'; +import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto'; import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto'; +import { PinWagonsDto } from './dto/pin-wagons.dto'; +import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto'; import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto'; +import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto'; +import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto'; import { TrainSchedulingService } from './train-scheduling.service'; @ApiTags('train-scheduling') @@ -20,39 +30,176 @@ import { TrainSchedulingService } from './train-scheduling.service'; export class TrainSchedulingController { constructor(private readonly trainSchedulingService: TrainSchedulingService) {} + @Get('global-rules') + @TrainSchedulingView() + @ApiOperation({ summary: 'Get global train scheduling rules (singleton)' }) + getGlobalRules() { + return this.trainSchedulingService.getTrainSchedulingGlobalRules(); + } + + @Patch('global-rules') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Update global train scheduling rules (singleton)' }) + updateGlobalRules(@Body() dto: UpdateTrainSchedulingGlobalRulesDto) { + return this.trainSchedulingService.updateTrainSchedulingGlobalRules(dto); + } + + @Get('eligible-bookings') + @TrainSchedulingView() + @ApiOperation({ summary: 'List eligible bookings (container and/or bulk)' }) + getEligibleBookings(@Query() query: GetEligibleBookingsDto) { + return this.trainSchedulingService.getEligibleBookings(query); + } + @Get('container/eligible-bookings') + @TrainSchedulingView() @ApiOperation({ summary: 'List eligible container bookings' }) getEligibleContainerBookings(@Query() query: GetEligibleContainerBookingsDto) { return this.trainSchedulingService.getEligibleContainerBookings(query); } + @Get('bulk/eligible-bookings') + @TrainSchedulingView() + @ApiOperation({ summary: 'List eligible bulk bookings' }) + getEligibleBulkBookings(@Query() query: GetEligibleBulkBookingsDto) { + return this.trainSchedulingService.getEligibleBulkBookings(query); + } + + @Post('preview') + @TrainSchedulingView() + @ApiOperation({ summary: 'Preview a mixed-capable train schedule' }) + previewTrainSchedule(@Body() dto: PreviewTrainScheduleDto) { + return this.trainSchedulingService.previewTrainSchedule(dto); + } + @Post('container/preview') + @TrainSchedulingView() @ApiOperation({ summary: 'Preview a container train schedule' }) previewContainerTrainSchedule(@Body() dto: PreviewContainerTrainScheduleDto) { return this.trainSchedulingService.previewContainerTrainSchedule(dto); } + @Post('bulk/preview') + @TrainSchedulingView() + @ApiOperation({ summary: 'Preview a bulk train schedule' }) + previewBulkTrainSchedule(@Body() dto: PreviewBulkTrainScheduleDto) { + return this.trainSchedulingService.previewBulkTrainSchedule(dto); + } + @Post('container/schedules') + @TrainSchedulingManage() @ApiOperation({ summary: 'Create a container train schedule' }) createContainerTrainSchedule(@Body() dto: CreateContainerTrainScheduleDto) { return this.trainSchedulingService.createContainerTrainSchedule(dto); } + @Post('bulk/schedules') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Create a bulk train schedule' }) + createBulkTrainSchedule(@Body() dto: CreateContainerTrainScheduleDto) { + return this.trainSchedulingService.createContainerTrainSchedule(dto); + } + + @Post('schedules/:id/assign-bookings') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Assign bookings to a train schedule (mixed-capable)' }) + assignBookings( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: AssignBookingsDto, + ) { + return this.trainSchedulingService.assignBookingsToSchedule(id, dto); + } + + @Post('container/schedules/:id/assign-bookings') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Assign container bookings to a train schedule' }) + assignContainerBookings( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: AssignBookingsDto, + ) { + return this.trainSchedulingService.assignBookingsToSchedule(id, dto, 'CONTAINER'); + } + + @Post('bulk/schedules/:id/assign-bookings') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Assign bulk bookings to a train schedule' }) + assignBulkBookings( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: AssignBookingsDto, + ) { + return this.trainSchedulingService.assignBookingsToSchedule(id, dto, 'BULK'); + } + + @Delete('schedules/:id/bookings/:bookingId') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Unassign a booking from a train schedule' }) + unassignBooking( + @Param('id', ParseUUIDPipe) id: string, + @Param('bookingId', ParseUUIDPipe) bookingId: string, + ) { + return this.trainSchedulingService.unassignBooking(id, bookingId); + } + + @Post('schedules/:id/pin-wagons') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Pin physical wagons to train set slots' }) + pinWagons(@Param('id', ParseUUIDPipe) id: string, @Body() dto: PinWagonsDto) { + return this.trainSchedulingService.pinWagons(id, dto); + } + + @Post('schedules/:id/finalize') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Finalize a draft train schedule' }) + finalizeSchedule(@Param('id', ParseUUIDPipe) id: string) { + return this.trainSchedulingService.finalizeSchedule(id); + } + + @Post('schedules/:id/dispatch') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Dispatch a scheduled train' }) + dispatchSchedule(@Param('id', ParseUUIDPipe) id: string) { + return this.trainSchedulingService.dispatchSchedule(id); + } + @Get('container/schedules') + @TrainSchedulingView() @ApiOperation({ summary: 'List container train schedules' }) getContainerTrainSchedules() { return this.trainSchedulingService.getContainerTrainSchedules(); } + @Get('bulk/schedules') + @TrainSchedulingView() + @ApiOperation({ summary: 'List bulk train schedules' }) + getBulkTrainSchedules() { + return this.trainSchedulingService.getContainerTrainSchedules(); + } + @Get('container/schedules/:id') + @TrainSchedulingView() @ApiOperation({ summary: 'Get container train schedule detail' }) getContainerTrainScheduleById(@Param('id', ParseUUIDPipe) id: string) { return this.trainSchedulingService.getContainerTrainScheduleById(id); } + @Get('bulk/schedules/:id') + @TrainSchedulingView() + @ApiOperation({ summary: 'Get bulk train schedule detail' }) + getBulkTrainScheduleById(@Param('id', ParseUUIDPipe) id: string) { + return this.trainSchedulingService.getContainerTrainScheduleById(id); + } + @Post('container/schedules/:id/cancel') + @TrainSchedulingManage() @ApiOperation({ summary: 'Cancel container train schedule' }) cancelTrainSchedule(@Param('id', ParseUUIDPipe) id: string) { return this.trainSchedulingService.cancelTrainSchedule(id); } + + @Post('bulk/schedules/:id/cancel') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Cancel bulk train schedule' }) + cancelBulkTrainSchedule(@Param('id', ParseUUIDPipe) id: string) { + return this.trainSchedulingService.cancelTrainSchedule(id); + } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts index dbf79e403..d133cbc74 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -2,42 +2,40 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { BookingsModule } from '../bookings/bookings.module'; -import { Booking } from '../bookings/entities/booking.entity'; -import { BookingContainer } from '../bookings/entities/booking-container.entity'; +import { Container } from '../container-management/entities/container.entity'; import { LocomotivesModule } from '../locomotives/locomotives.module'; +import { RuleEngineModule } from '../rule-engine/rule-engine.module'; import { Locomotive } from '../locomotives/entities/locomotive.entity'; +import { Route } from '../routes/entities/route.entity'; +import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; +import { TrainSet } from '../train-sets/entities/train-set.entity'; +import { TrainSetsModule } from '../train-sets/train-sets.module'; +import { TrainSchedulesModule } from '../train-schedules/train-schedules.module'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { WagonTypesModule } from '../wagon-types/wagon-types.module'; -import { TrainSet } from '../train-sets/entities/train-set.entity'; -import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; -import { TrainSetsModule } from '../train-sets/train-sets.module'; -import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; -import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; -import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity'; -import { TrainSchedulesModule } from '../train-schedules/train-schedules.module'; -import { Yard } from '../rule-engine/entities/yard.entity'; +import { Wagon } from '../wagons/entities/wagon.entity'; +import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity'; import { TrainSchedulingController } from './train-scheduling.controller'; import { TrainSchedulingService } from './train-scheduling.service'; @Module({ imports: [ TypeOrmModule.forFeature([ - Booking, - BookingContainer, Locomotive, WagonType, TrainSet, TrainSetWagon, - TrainSchedule, - TrainScheduleBooking, - WagonBookingAllocation, - Yard, + Route, + Wagon, + Container, + TrainSchedulingGlobalRules, ]), BookingsModule, LocomotivesModule, WagonTypesModule, TrainSetsModule, TrainSchedulesModule, + RuleEngineModule, ], controllers: [TrainSchedulingController], providers: [TrainSchedulingService], diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index 8f2b77bb4..1b7e25ec6 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -1,5 +1,10 @@ import { ConflictException } from '@nestjs/common'; +import { WagonReadiness, WagonStatus } from '@edr/types'; +import { Wagon } from '../wagons/entities/wagon.entity'; +import { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; +import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity'; import { TrainSchedulingService } from './train-scheduling.service'; const nw5 = { @@ -11,6 +16,7 @@ const nw5 = { maxWagonsPerTrain: 53, supportedLoadTypes: ['CONTAINER'], isActive: true, + supportsContainer: true, }; const locomotive = { @@ -21,15 +27,29 @@ const locomotive = { status: 'AVAILABLE', }; +const cw3 = { + id: 'wagon-type-bulk', + code: 'CW3', + name: 'Covered Wagon', + capacityTons: 60, + lengthMeters: 14, + maxWagonsPerTrain: 53, + supportedLoadTypes: ['BULK'], + isActive: true, + supportsContainer: false, +}; + const makeBooking = ( id: string, reference: string, weight: number, quantity: number, containerCode: string, + wagonsRequired: number, scheduledDate = '2026-06-20T08:00:00.000Z', originYardId = 'yard-origin', destinationYardId = 'yard-destination', + extra: Record = {}, ) => ({ id, reference, @@ -39,75 +59,177 @@ const makeBooking = ( originYardId, destinationYardId, status: 'PAID', - customer: { companyName: 'Demo Customer' }, + schedulingStatus: 'HOLDING', + holdExpiresAt: new Date(Date.now() + 60 * 60 * 1000), + company: { companyName: 'Demo Customer' }, originYard: { label: 'Djibouti', code: 'DJIBOUTI' }, destinationYard: { label: 'Addis Ababa', code: 'ADDIS_ABABA' }, bookingContainers: [ { + id: `${id}-line`, + containerTypeId: 'ct-1', quantity, + wagonsRequired, + vgmPerUnitTons: weight / quantity, + isOverweight: false, containerType: { code: containerCode, label: containerCode }, }, ], + ...extra, }); describe('TrainSchedulingService', () => { let service: TrainSchedulingService; - let dataSource: { - getRepository: jest.Mock; - transaction: jest.Mock; - }; - let locomotivesRepository: { - findById: jest.Mock; - }; - let wagonTypesRepository: { - findAll: jest.Mock; - }; + let dataSource: { getRepository: jest.Mock; transaction: jest.Mock }; + let bookingsRepository: Record; + let locomotivesRepository: { findById: jest.Mock; findAll: jest.Mock }; + let wagonTypesRepository: { findAll: jest.Mock }; + let trainSchedulesRepository: Record; + let trainScheduleBookingsRepository: Record; + let wagonBookingAllocationsRepository: Record; + let wagonAllocationContainerItemsRepository: Record; + let wagonAllocationBulkLoadsRepository: Record; beforeEach(() => { - dataSource = { - getRepository: jest.fn(), - transaction: jest.fn(), + dataSource = { getRepository: jest.fn(), transaction: jest.fn() }; + bookingsRepository = { + findEligibleForScheduling: jest.fn(), + findByIdsForScheduling: jest.fn(), + updateSchedulingFields: jest.fn(), }; - locomotivesRepository = { + locomotivesRepository = { findById: jest.fn(), findAll: jest.fn() }; + wagonTypesRepository = { findAll: jest.fn() }; + trainSchedulesRepository = { findById: jest.fn(), - }; - wagonTypesRepository = { + findByIdWithFullGraph: jest.fn(), findAll: jest.fn(), + updateStatus: jest.fn(), + }; + trainScheduleBookingsRepository = { + findByBookingIds: jest.fn(), + createMany: jest.fn(), + deleteByScheduleAndBooking: jest.fn(), + }; + wagonBookingAllocationsRepository = { + deleteByTrainSetId: jest.fn().mockResolvedValue([]), + createMany: jest.fn(), + }; + wagonAllocationContainerItemsRepository = { + createMany: jest.fn(), + deleteByAllocationIds: jest.fn(), + findAll: jest.fn().mockResolvedValue([]), + }; + wagonAllocationBulkLoadsRepository = { + createMany: jest.fn(), + deleteByAllocationIds: jest.fn(), + findAll: jest.fn().mockResolvedValue([]), }; service = new TrainSchedulingService( dataSource as never, + bookingsRepository as never, locomotivesRepository as never, wagonTypesRepository as never, + trainSchedulesRepository as never, + trainScheduleBookingsRepository as never, + wagonBookingAllocationsRepository as never, + wagonAllocationContainerItemsRepository as never, + wagonAllocationBulkLoadsRepository as never, ); + + const defaultFleetWagons = [ + ...Array.from({ length: 100 }, (_, index) => ({ + id: `wagon-nw5-${index}`, + wagonTypeId: nw5.id, + status: WagonStatus.Available, + readiness: WagonReadiness.ImportReady, + currentTrainScheduleId: null, + })), + ...Array.from({ length: 50 }, (_, index) => ({ + id: `wagon-cw3-${index}`, + wagonTypeId: cw3.id, + status: WagonStatus.Available, + readiness: WagonReadiness.ImportReady, + currentTrainScheduleId: null, + })), + ]; + + dataSource.getRepository.mockImplementation((entity: unknown) => { + if (entity === TrainSchedulingGlobalRules) { + return { find: jest.fn().mockResolvedValue([]) }; + } + if (entity === Wagon) { + return { find: jest.fn().mockResolvedValue(defaultFleetWagons) }; + } + if (entity === WagonType) { + return { find: jest.fn().mockResolvedValue([nw5, cw3]) }; + } + return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) }; + }); }); - it('computes the expected valid preview for Group A', async () => { + it('returns fleet availability and defers bookings when fleet is insufficient', async () => { const bookings = [ - makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT'), - makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT'), - makeBooking('b3', 'BKG-CONT-003', 450, 15, '40FT'), + makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20), + makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT', 10), ]; wagonTypesRepository.findAll.mockResolvedValue([nw5]); - dataSource.getRepository.mockImplementation((entity: { name?: string }) => { - if (entity?.name === 'Booking') { - return { find: jest.fn().mockResolvedValue(bookings) }; - } - if (entity?.name === 'TrainScheduleBooking') { + bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings); + trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]); + locomotivesRepository.findAll.mockResolvedValue([locomotive]); + + const availableWagons = Array.from({ length: 15 }, (_, index) => ({ + id: `wagon-${index}`, + wagonTypeId: nw5.id, + status: WagonStatus.Available, + readiness: WagonReadiness.ImportReady, + currentTrainScheduleId: null, + })); + + dataSource.getRepository.mockImplementation((entity: unknown) => { + if (entity === TrainSchedulingGlobalRules) { return { find: jest.fn().mockResolvedValue([]) }; } - if (entity?.name === 'Locomotive') { - return { - count: jest.fn().mockResolvedValue(2), - find: jest.fn().mockResolvedValue([locomotive]), - }; + if (entity === Wagon) { + return { find: jest.fn().mockResolvedValue(availableWagons) }; } - throw new Error(`Unexpected repository ${entity?.name}`); + if (entity === WagonType) { + return { find: jest.fn().mockResolvedValue([nw5]) }; + } + return { find: jest.fn().mockResolvedValue([]), findOne: jest.fn().mockResolvedValue(null) }; }); const result = await service.previewContainerTrainSchedule({ - bookingIds: bookings.map((booking) => booking.id), + bookingIds: bookings.map((b) => b.id), + scheduleDate: '2026-06-20T08:00:00.000Z', + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + }); + + expect(result.fleetAvailability?.length).toBeGreaterThan(0); + expect(result.fleetAvailability?.[0]?.shortfall).toBeGreaterThan(0); + expect(result.deferredBookings?.length).toBeGreaterThan(0); + expect(result.summary.wagonsNeeded).toBeLessThan(30); + expect(result.warnings.some((w) => w.includes('Fleet shortage') || w.includes('deferred'))).toBe( + true, + ); + }); + + it('computes slot-based preview for Group A', async () => { + const bookings = [ + makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20), + makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT', 10), + makeBooking('b3', 'BKG-CONT-003', 450, 15, '40FT', 15), + ]; + + wagonTypesRepository.findAll.mockResolvedValue([nw5]); + bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings); + trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]); + locomotivesRepository.findAll.mockResolvedValue([locomotive]); + + const result = await service.previewContainerTrainSchedule({ + bookingIds: bookings.map((b) => b.id), scheduleDate: '2026-06-20T08:00:00.000Z', originStationId: 'yard-origin', destinationStationId: 'yard-destination', @@ -115,40 +237,50 @@ describe('TrainSchedulingService', () => { expect(result.valid).toBe(true); expect(result.violations).toEqual([]); - expect(result.summary).toEqual({ - totalBookings: 3, - totalWeightTons: 1250, - wagonType: 'NW5', - wagonsNeeded: 18, - totalLengthMeters: 252, - }); - expect(result.wagonPlan).toHaveLength(18); - expect(result.wagonPlan[0]?.allocations[0]).toEqual({ - bookingId: 'b1', - bookingReference: 'BKG-CONT-001', - allocatedWeightTons: 70, + expect(result.summary.wagonsNeeded).toBe(45); + expect(result.wagonPlan).toHaveLength(45); + }); + + it('returns soft hold warnings without forceAssign', async () => { + const bookings = [makeBooking('b7', 'BKG-CONT-007', 120, 2, '40FT', 2)]; + + wagonTypesRepository.findAll.mockResolvedValue([nw5]); + bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings); + trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]); + locomotivesRepository.findAll.mockResolvedValue([locomotive]); + + const result = await service.previewContainerTrainSchedule({ + bookingIds: ['b7'], + scheduleDate: '2026-06-20T08:00:00.000Z', + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', }); + + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0]).toContain('soft hold window'); }); it('flags the overweight booking as invalid', async () => { - const bookings = [makeBooking('b6', 'BKG-CONT-006', 3600, 80, '40FT')]; + const bookings = [ + makeBooking('b6', 'BKG-CONT-006', 3600, 80, '40FT', 80, undefined, undefined, undefined, { + bookingContainers: [ + { + id: 'b6-line', + containerTypeId: 'ct-1', + quantity: 80, + wagonsRequired: 80, + vgmPerUnitTons: 45, + isOverweight: true, + containerType: { code: '40FT', label: '40FT' }, + }, + ], + }), + ]; wagonTypesRepository.findAll.mockResolvedValue([nw5]); - dataSource.getRepository.mockImplementation((entity: { name?: string }) => { - if (entity?.name === 'Booking') { - return { find: jest.fn().mockResolvedValue(bookings) }; - } - if (entity?.name === 'TrainScheduleBooking') { - return { find: jest.fn().mockResolvedValue([]) }; - } - if (entity?.name === 'Locomotive') { - return { - count: jest.fn().mockResolvedValue(1), - find: jest.fn().mockResolvedValue([locomotive]), - }; - } - throw new Error(`Unexpected repository ${entity?.name}`); - }); + bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings); + trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]); + locomotivesRepository.findAll.mockResolvedValue([locomotive]); const result = await service.previewContainerTrainSchedule({ bookingIds: ['b6'], @@ -158,36 +290,70 @@ describe('TrainSchedulingService', () => { }); expect(result.valid).toBe(false); - expect(result.summary.totalWeightTons).toBe(3600); - expect(result.violations).toContain( - 'Total booking weight 3600T exceeds max train weight 3500T', + expect(result.violations.some((v) => v.includes('overweight'))).toBe(true); + }); + + it('allows preview when bookings are already on the target schedule', async () => { + const bookings = [makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20)]; + + wagonTypesRepository.findAll.mockResolvedValue([nw5]); + bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings); + trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([ + { bookingId: 'b1', trainScheduleId: 'sched-target' }, + ]); + trainSchedulesRepository.findById.mockResolvedValue({ + id: 'sched-target', + direction: 'IMPORT', + }); + locomotivesRepository.findAll.mockResolvedValue([locomotive]); + + const result = await service.previewContainerTrainSchedule({ + bookingIds: ['b1'], + scheduleDate: '2026-06-20T08:00:00.000Z', + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + targetScheduleId: 'sched-target', + }); + + expect(result.violations).not.toContain( + 'One or more selected bookings are already assigned to a train schedule', ); + expect(result.valid).toBe(true); + }); + + it('allows preview when selected bookings are on different schedule dates', async () => { + const bookings = [ + makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20, '2026-06-20T08:00:00.000Z'), + makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT', 10, '2026-06-21T14:00:00.000Z'), + ]; + + wagonTypesRepository.findAll.mockResolvedValue([nw5]); + bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings); + trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]); + locomotivesRepository.findAll.mockResolvedValue([locomotive]); + + const result = await service.previewContainerTrainSchedule({ + bookingIds: bookings.map((b) => b.id), + scheduleDate: '2026-06-20T08:00:00.000Z', + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + }); + + expect(result.violations).not.toContain( + 'Selected bookings must share the same schedule date', + ); + expect(result.valid).toBe(true); }); it('rejects bookings that are not in schedulable status', async () => { const bookings = [ - { - ...makeBooking('b7', 'BKG-CONT-007', 120, 2, '40FT'), - status: 'APPROVED', - }, + { ...makeBooking('b7', 'BKG-CONT-007', 120, 2, '40FT', 2), status: 'APPROVED' }, ]; wagonTypesRepository.findAll.mockResolvedValue([nw5]); - dataSource.getRepository.mockImplementation((entity: { name?: string }) => { - if (entity?.name === 'Booking') { - return { find: jest.fn().mockResolvedValue(bookings) }; - } - if (entity?.name === 'TrainScheduleBooking') { - return { find: jest.fn().mockResolvedValue([]) }; - } - if (entity?.name === 'Locomotive') { - return { - count: jest.fn().mockResolvedValue(1), - find: jest.fn().mockResolvedValue([locomotive]), - }; - } - throw new Error(`Unexpected repository ${entity?.name}`); - }); + bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings); + trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]); + locomotivesRepository.findAll.mockResolvedValue([locomotive]); const result = await service.previewContainerTrainSchedule({ bookingIds: ['b7'], @@ -239,13 +405,16 @@ describe('TrainSchedulingService', () => { }; jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never); - dataSource.getRepository.mockImplementation((entity: { name?: string }) => { - if (entity?.name === 'Route') { + dataSource.getRepository.mockImplementation((entity: unknown) => { + if ((entity as { name?: string })?.name === 'Route') { return { findOne: jest.fn().mockResolvedValue(route) }; } - throw new Error(`Unexpected repository ${entity?.name}`); + if (entity === TrainSchedulingGlobalRules) { + return { find: jest.fn().mockResolvedValue([]) }; + } + throw new Error(`Unexpected repository ${(entity as { name?: string })?.name}`); }); - jest.spyOn(service, 'getContainerTrainScheduleById').mockResolvedValue({ id: 'schedule-1' } as never); + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({ id: 'schedule-1' }); dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise) => callback(manager), ); @@ -259,7 +428,62 @@ describe('TrainSchedulingService', () => { expect(trainSetRepo.save).toHaveBeenCalled(); expect(trainScheduleRepo.save).toHaveBeenCalled(); expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith('loc-1', { status: 'ASSIGNED' }); - expect(result).toEqual({ id: 'schedule-1' }); + expect(result.id).toBe('schedule-1'); + }); + + it('previews mixed container and bulk bookings', async () => { + const containerBooking = makeBooking('c1', 'BKG-CONT', 100, 2, '40FT', 2); + const bulkBooking = { + id: 'b1', + reference: 'BKG-BULK', + freightType: 'BULK', + cargoTotalWeightVgm: 120, + scheduledDate: new Date('2026-06-20T08:00:00.000Z'), + originYardId: 'yard-origin', + destinationYardId: 'yard-destination', + status: 'PAID', + bookingContainers: [], + cargoType: { code: 'COFFEE' }, + }; + + wagonTypesRepository.findAll.mockImplementation(async ({ where }: { where?: { code?: string } }) => { + if (where?.code === 'NW5') return [nw5]; + return [nw5, cw3]; + }); + bookingsRepository.findByIdsForScheduling.mockResolvedValue([containerBooking, bulkBooking]); + trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]); + locomotivesRepository.findAll.mockResolvedValue([locomotive]); + + const result = await service.previewTrainSchedule({ + bookingIds: ['c1', 'b1'], + scheduleDate: '2026-06-20T08:00:00.000Z', + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + }); + + expect(result.valid).toBe(true); + expect(result.summary.wagonType).toBe('MIXED'); + expect(result.wagonPlan.length).toBeGreaterThan(2); + expect(result.containerUnits).toHaveLength(2); + }); + + it('previews container bookings without requiring placements', async () => { + const bookings = [makeBooking('c2', 'BKG-CONT-2', 50, 1, '40FT', 1)]; + + wagonTypesRepository.findAll.mockResolvedValue([nw5]); + bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings); + trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]); + locomotivesRepository.findAll.mockResolvedValue([locomotive]); + + const result = await service.previewTrainSchedule({ + bookingIds: ['c2'], + scheduleDate: '2026-06-20T08:00:00.000Z', + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + }); + + expect(result.valid).toBe(true); + expect(result.containerUnits).toHaveLength(1); }); it('rejects create when the locked locomotive is no longer available', async () => { @@ -296,4 +520,48 @@ describe('TrainSchedulingService', () => { }), ).rejects.toBeInstanceOf(ConflictException); }); + + it('rejects pin when wagon readiness does not match schedule direction', async () => { + const scheduleId = 'sched-1'; + const slotId = 'slot-1'; + + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({ + id: scheduleId, + status: 'DRAFT', + direction: 'IMPORT', + trainSet: { + wagons: [{ id: slotId, physicalWagonId: null }], + }, + }); + + const manager = { + getRepository: jest.fn((entity: { name?: string }) => { + if (entity === Wagon) { + return { + findOne: jest.fn().mockResolvedValue({ + id: 'wagon-1', + wagonNumber: 'WGN-001', + status: WagonStatus.Available, + readiness: WagonReadiness.ExportReady, + currentTrainScheduleId: null, + }), + update: jest.fn(), + }; + } + if (entity === TrainSetWagon) { + return { update: jest.fn() }; + } + throw new Error(`Unexpected repository ${entity?.name}`); + }), + }; + dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise) => + callback(manager), + ); + + await expect( + service.pinWagons(scheduleId, { + assignments: [{ trainSetWagonId: slotId, physicalWagonId: 'wagon-1' }], + }), + ).rejects.toBeInstanceOf(ConflictException); + }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 64147940d..4a906eaa2 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -1,74 +1,88 @@ +import { + AllocationLoadType, + SchedulingStatus, + TrainScheduleStatus as TrainScheduleStatusEnum, + WagonStatus, +} from '@edr/types'; import { BadRequestException, ConflictException, Injectable, NotFoundException, -} from "@nestjs/common"; -import { InjectDataSource } from "@nestjs/typeorm"; -import { DataSource, EntityManager, In } from "typeorm"; +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource, EntityManager, In } from 'typeorm'; -import { Booking } from "../bookings/entities/booking.entity"; +import { BookingsRepository } from '../bookings/bookings.repository'; +import { Booking } from '../bookings/entities/booking.entity'; +import { BookingContainer } from '../bookings/entities/booking-container.entity'; +import { Container } from '../container-management/entities/container.entity'; +import { Locomotive } from '../locomotives/entities/locomotive.entity'; +import { LocomotivesRepository } from '../locomotives/locomotives.repository'; +import { Route } from '../routes/entities/route.entity'; +import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; +import { TrainSet } from '../train-sets/entities/train-set.entity'; +import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; +import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; +import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity'; +import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository'; +import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; +import { WagonAllocationBulkLoadsRepository } from '../train-schedules/wagon-allocation-bulk-loads.repository'; +import { WagonAllocationContainerItemsRepository } from '../train-schedules/wagon-allocation-container-items.repository'; +import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-booking-allocations.repository'; +import { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { WagonTypesRepository } from '../wagon-types/wagon-types.repository'; +import { Wagon } from '../wagons/entities/wagon.entity'; +import { AssignBookingsDto } from './dto/assign-bookings.dto'; +import { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto'; +import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto'; +import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto'; +import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto'; +import { PinWagonsDto } from './dto/pin-wagons.dto'; +import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto'; +import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto'; +import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto'; +import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity'; +import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto'; import { - Locomotive, - type LocomotiveStatus, -} from "../locomotives/entities/locomotive.entity"; -import { LocomotivesRepository } from "../locomotives/locomotives.repository"; -import { TrainSetWagon } from "../train-sets/entities/train-set-wagon.entity"; -import { TrainSet } from "../train-sets/entities/train-set.entity"; -import { Route } from "../routes/entities/route.entity"; -import { TrainScheduleBooking } from "../train-schedules/entities/train-schedule-booking.entity"; -import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity"; -import { WagonType } from "../wagon-types/entities/wagon-type.entity"; -import { WagonTypesRepository } from "../wagon-types/wagon-types.repository"; -import { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.dto"; -import { GetEligibleContainerBookingsDto } from "./dto/get-eligible-container-bookings.dto"; -import { PreviewContainerTrainScheduleDto } from "./dto/preview-container-train-schedule.dto"; + buildCappedWagonPlan, + computeFleetAvailability, + selectBookingsWithinFleetCap, + summarizeFleetWarnings, + totalAssignedWeight, + type DeferredBookingRow, + type FleetAvailabilityRow, +} from './fleet-plan.util'; +import { + buildBulkWagonPlan, + buildContainerWagonPlan, + buildMixedWagonPlan, + expandBookingContainerUnits, + getContainerSlotSequenceNos, + roundTons, + sumWagonsRequired, + type TrainLimitConfig, + validateContainerPlacements, + validateMixedTrainLimits, + validateTrainLimits, + type ContainerPlacementInput, + type WagonPlanSlot, +} from './wagon-plan.util'; +import { + getDefaultContainerWagonTypeCode, + pickBulkWagonType, +} from './wagon-type-resolver.util'; +import { deriveScheduleDirection } from './derive-schedule-direction.util'; +import { wagonReadinessMatchesSchedule } from './wagon-readiness.util'; -const DEFAULT_WAGON_TYPE_CODE = "NW5"; -const MAX_TRAIN_WEIGHT_TONS = 3500; -const MAX_TRAIN_LENGTH_METERS = 760; -const SCHEDULABLE_BOOKING_STATUSES = ["PAID"] as const; - -type EligibleBookingItem = { - id: string; - reference: string; - customer: string; - containerType: string; - quantity: number; - weightTons: number; - origin: string; - destination: string; - preferredDepartureDate: string; - status: string; -}; - -type WagonAllocationRecord = { - bookingId: string; - bookingReference: string; - allocatedWeightTons: number; -}; - -type WagonPlanRecord = { - sequenceNo: number; - capacityTons: number; - lengthMeters: number; - assignedWeightTons: number; - allocations: WagonAllocationRecord[]; -}; - -type ValidationResult = { - valid: boolean; - violations: string[]; - bookings: Booking[]; - wagonType: WagonType; - summary: { - totalBookings: number; - totalWeightTons: number; - wagonType: string; - wagonsNeeded: number; - totalLengthMeters: number; - }; - wagonPlan: WagonPlanRecord[]; +const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const; +const DEFAULT_TRAIN_LIMITS: Required = { + maxWeightTons: 3500, + maxLengthMeters: 760, + maxWagonsPerTrain: 53, + max20ftContainerWeightTons: 30, + max20ftPairWeightDiffTons: 10, }; @Injectable() @@ -76,370 +90,1157 @@ export class TrainSchedulingService { constructor( @InjectDataSource() private readonly dataSource: DataSource, + private readonly bookingsRepository: BookingsRepository, private readonly locomotivesRepository: LocomotivesRepository, private readonly wagonTypesRepository: WagonTypesRepository, - ) { } + private readonly trainSchedulesRepository: TrainSchedulesRepository, + private readonly trainScheduleBookingsRepository: TrainScheduleBookingsRepository, + private readonly wagonBookingAllocationsRepository: WagonBookingAllocationsRepository, + private readonly wagonAllocationContainerItemsRepository: WagonAllocationContainerItemsRepository, + private readonly wagonAllocationBulkLoadsRepository: WagonAllocationBulkLoadsRepository, + private readonly configService?: ConfigService, + ) {} + + async getEligibleBookings(query: GetEligibleBookingsDto) { + const bookings = await this.bookingsRepository.findEligibleForScheduling({ + freightType: query.freightType, + originStationId: query.originStationId, + destinationStationId: query.destinationStationId, + schedulingStatus: query.schedulingStatus, + }); + return { count: bookings.length, items: bookings.map((b) => this.mapEligibleBooking(b)) }; + } async getEligibleContainerBookings(query: GetEligibleContainerBookingsDto) { - const bookingRepository = this.dataSource.getRepository(Booking); - const queryBuilder = bookingRepository - .createQueryBuilder("booking") - .leftJoinAndSelect("booking.company", "company") - .leftJoinAndSelect("booking.originYard", "originYard") - .leftJoinAndSelect("booking.destinationYard", "destinationYard") - .leftJoinAndSelect("booking.bookingContainers", "bookingContainer") - .leftJoinAndSelect("bookingContainer.containerType", "containerType") - .leftJoin( - TrainScheduleBooking, - "scheduleBooking", - "scheduleBooking.booking_id = booking.id", - ) - .where("booking.freightType = :freightType", { freightType: "CONTAINER" }) - .andWhere("scheduleBooking.id IS NULL"); + return this.getEligibleBookings({ ...query, freightType: 'CONTAINER' }); + } - queryBuilder.andWhere("booking.status IN (:...schedulableStatuses)", { - schedulableStatuses: SCHEDULABLE_BOOKING_STATUSES, - }); + async getEligibleBulkBookings(query: GetEligibleBulkBookingsDto) { + return this.getEligibleBookings({ ...query, freightType: 'BULK' }); + } - if (query.originStationId) { - queryBuilder.andWhere("booking.originYardId = :originStationId", { - originStationId: query.originStationId, - }); + async getTrainSchedulingGlobalRules() { + return this.loadGlobalRulesRow(); + } + + async updateTrainSchedulingGlobalRules(dto: UpdateTrainSchedulingGlobalRulesDto) { + const row = await this.loadGlobalRulesRow(); + if (!row) { + throw new NotFoundException('Train scheduling global rules not configured'); } - - if (query.destinationStationId) { - queryBuilder.andWhere( - "booking.destinationYardId = :destinationStationId", - { - destinationStationId: query.destinationStationId, - }, - ); + if (dto.maxTrainLengthMeters != null) row.maxTrainLengthMeters = dto.maxTrainLengthMeters; + if (dto.maxTrainWeightTons != null) row.maxTrainWeightTons = dto.maxTrainWeightTons; + if (dto.maxWagonsPerTrain != null) row.maxWagonsPerTrain = dto.maxWagonsPerTrain; + if (dto.max20ftContainerWeightTons != null) { + row.max20ftContainerWeightTons = dto.max20ftContainerWeightTons; } - - if (query.scheduleDate) { - queryBuilder.andWhere( - `DATE(booking.scheduled_date AT TIME ZONE 'UTC') = :scheduleDate`, - { scheduleDate: this.toUtcDateKey(query.scheduleDate) }, - ); + if (dto.max20ftPairWeightDiffTons != null) { + row.max20ftPairWeightDiffTons = dto.max20ftPairWeightDiffTons; } + return this.dataSource.getRepository(TrainSchedulingGlobalRules).save(row); + } - const bookings = await queryBuilder - .orderBy("booking.scheduled_date", "ASC") - .addOrderBy("booking.created_at", "ASC") - .getMany(); - - const items: EligibleBookingItem[] = bookings.map((booking) => ({ - id: booking.id, - reference: booking.reference, - customer: - booking.company?.name ?? booking.company?.email ?? "Unknown customer", - containerType: - booking.bookingContainers - ?.map( - (container) => - container.containerType?.label ?? - container.containerType?.code ?? - "Container", - ) - .join(", ") ?? "Container", - quantity: - booking.bookingContainers?.reduce( - (sum, container) => sum + Number(container.quantity ?? 0), - 0, - ) ?? 0, - weightTons: this.roundTons(booking.cargoTotalWeightVgm), - origin: - booking.originYard?.label ?? - booking.originYard?.code ?? - "Unknown origin", - destination: - booking.destinationYard?.label ?? - booking.destinationYard?.code ?? - "Unknown destination", - preferredDepartureDate: booking.scheduledDate.toISOString(), - status: booking.status, - })); - - return { - count: items.length, - items, - }; + async previewTrainSchedule(dto: PreviewTrainScheduleDto) { + const limits = await this.resolveTrainLimitConfig(dto); + return this.buildPreviewResponse( + await this.validateBookingsForScheduling( + dto, + null, + false, + [], + false, + limits, + dto.targetScheduleId, + ), + ); } async previewContainerTrainSchedule(dto: PreviewContainerTrainScheduleDto) { - const validation = await this.validateContainerBookingsForScheduling(dto); + const limits = await this.resolveTrainLimitConfig(dto); + return this.buildPreviewResponse( + await this.validateBookingsForScheduling( + dto, + 'CONTAINER', + false, + [], + false, + limits, + dto.targetScheduleId, + ), + ); + } + async previewBulkTrainSchedule(dto: PreviewBulkTrainScheduleDto) { + const limits = await this.resolveTrainLimitConfig(dto); + return this.buildPreviewResponse( + await this.validateBookingsForScheduling( + dto, + 'BULK', + false, + [], + false, + limits, + dto.targetScheduleId, + ), + ); + } + + private buildPreviewResponse(validation: Awaited>) { + const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER'); return { valid: validation.valid, violations: validation.violations, + warnings: validation.warnings, summary: validation.summary, - bookingIds: validation.bookings.map((booking) => booking.id), + fleetAvailability: validation.fleetAvailability, + deferredBookings: validation.deferredBookings, + bookingIds: validation.bookings.map((b) => b.id), wagonPlan: validation.wagonPlan, + containerUnits: containerBookings.length + ? expandBookingContainerUnits(containerBookings) + : [], + containerSlotSequenceNos: getContainerSlotSequenceNos(validation.wagonPlan), }; } async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) { const route = await this.getActiveRoute(dto.routeId); + const locomotive = await this.selectOrValidateLocomotive(dto.locomotiveId, 0, 0); - const locomotive = await this.selectOrValidateLocomotive( - dto.locomotiveId, - 0, - 0, + const createdScheduleId = await this.dataSource.transaction(async (manager) => { + const lockedLocomotive = await manager.getRepository(Locomotive).findOne({ + where: { id: locomotive.id }, + lock: { mode: 'pessimistic_write' }, + }); + if (!lockedLocomotive) { + throw new NotFoundException(`Locomotive ${locomotive.id} not found`); + } + if (lockedLocomotive.status !== 'AVAILABLE') { + throw new ConflictException(`Locomotive ${lockedLocomotive.code} is not available`); + } + + const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotive); + const direction = deriveScheduleDirection( + route.originYard ?? { country: null }, + route.destinationYard ?? { country: null }, + ); + const schedule = manager.getRepository(TrainSchedule).create({ + trainSetId: trainSet.id, + routeId: route.id, + originStationId: route.originYardId, + destinationStationId: route.destinationYardId, + scheduledDepartureDate: new Date(dto.scheduleDate), + status: TrainScheduleStatusEnum.Draft, + direction, + maxWagons: (await this.resolveTrainLimitConfig(dto)).maxWagonsPerTrain, + }); + const saved = await manager.getRepository(TrainSchedule).save(schedule); + await manager.getRepository(Locomotive).update(lockedLocomotive.id, { status: 'ASSIGNED' }); + return saved.id; + }); + + return this.getTrainScheduleById(createdScheduleId); + } + + async assignBookingsToSchedule( + scheduleId: string, + dto: AssignBookingsDto, + freightType?: 'CONTAINER' | 'BULK', + ) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) { + throw new BadRequestException( + `Cannot assign bookings to schedule in status ${schedule.status}`, + ); + } + if (!schedule.trainSet) { + throw new BadRequestException('Schedule has no train set'); + } + + const previewDto = { + bookingIds: dto.bookingIds, + scheduleDate: schedule.scheduledDepartureDate.toISOString(), + originStationId: schedule.originStationId, + destinationStationId: schedule.destinationStationId, + maxTrainWeightTons: dto.maxTrainWeightTons, + maxTrainLengthMeters: dto.maxTrainLengthMeters, + maxWagonsPerTrain: dto.maxWagonsPerTrain ?? schedule.maxWagons, + }; + + const limits = await this.resolveTrainLimitConfig(previewDto); + const validation = await this.validateBookingsForScheduling( + previewDto, + freightType ?? null, + dto.forceAssign, + dto.containerPlacements, + true, + limits, + scheduleId, ); - const createdSchedule = await this.dataSource.transaction( - async (manager) => { - const locomotiveRepository = manager.getRepository(Locomotive); - const lockedLocomotive = await locomotiveRepository.findOne({ - where: { id: locomotive.id }, - lock: { mode: "pessimistic_write" }, + if (!validation.valid) { + throw new BadRequestException({ + message: 'Booking validation failed', + violations: validation.violations, + warnings: validation.warnings, + }); + } + + if (!validation.bookings.length) { + throw new BadRequestException({ + message: 'No bookings fit on available fleet wagons', + violations: ['Insufficient fleet wagons for the selected bookings'], + warnings: validation.warnings, + deferredBookings: validation.deferredBookings, + }); + } + + const { bookings, wagonType, wagonPlan, warnings, deferredBookings } = validation; + const totalWeightTons = validation.summary.totalWeightTons; + const totalLengthMeters = validation.summary.totalLengthMeters; + + const locomotive = schedule.trainSet.locomotive; + if (!locomotive) { + throw new BadRequestException('Schedule train set has no locomotive'); + } + if (Number(locomotive.maxPullWeightTons) < totalWeightTons) { + throw new BadRequestException( + `Locomotive ${locomotive.code} cannot pull ${totalWeightTons}T`, + ); + } + if (Number(locomotive.maxTrainLengthMeters) < totalLengthMeters) { + throw new BadRequestException( + `Locomotive ${locomotive.code} cannot support ${totalLengthMeters}m`, + ); + } + + await this.dataSource.transaction(async (manager) => { + const trainSetId = schedule.trainSetId; + + await this.releasePinnedWagonsForTrainSet(manager, trainSetId); + + const deletedAllocationIds = + await this.wagonBookingAllocationsRepository.deleteByTrainSetId(trainSetId, manager); + + if (deletedAllocationIds.length) { + await this.wagonAllocationContainerItemsRepository.deleteByAllocationIds( + deletedAllocationIds, + manager, + ); + await this.wagonAllocationBulkLoadsRepository.deleteByAllocationIds( + deletedAllocationIds, + manager, + ); + } + + await manager.getRepository(TrainSetWagon).delete({ trainSetId }); + await manager.getRepository(TrainScheduleBooking).delete({ trainScheduleId: scheduleId }); + + await manager.getRepository(TrainSet).update(trainSetId, { + totalWeightTons, + totalLengthMeters, + wagonCount: wagonPlan.length, + status: 'ASSIGNED', + }); + + const savedWagons = await this.persistTrainSetWagons( + manager, + trainSetId, + wagonType, + wagonPlan, + ); + + const scheduleBookingRecords = bookings.map((booking) => ({ + trainScheduleId: scheduleId, + bookingId: booking.id, + })); + await this.trainScheduleBookingsRepository.createMany(scheduleBookingRecords, manager); + + await this.persistAllocationsAndLoads( + manager, + savedWagons, + wagonPlan, + bookings, + dto.containerPlacements ?? [], + ); + + for (const booking of bookings) { + await this.bookingsRepository.updateSchedulingFields( + booking.id, + { + schedulingStatus: SchedulingStatus.Eligible, + wagonsRequired: sumWagonsRequired(booking), + }, + manager, + ); + } + + if (schedule.status === TrainScheduleStatusEnum.Draft && bookings.length > 0) { + await this.trainSchedulesRepository.updateStatus( + scheduleId, + TrainScheduleStatusEnum.Draft, + {}, + manager, + ); + } + + await this.autoPinWagonsForSchedule( + manager, + scheduleId, + schedule.direction ?? null, + savedWagons, + ); + }); + + const detail = await this.getTrainScheduleById(scheduleId); + return { ...detail, warnings, deferredBookings }; + } + + async unassignBooking(scheduleId: string, bookingId: string) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) { + throw new BadRequestException('Cannot unassign from a finalized or dispatched schedule'); + } + + const link = schedule.scheduleBookings?.find((sb) => sb.bookingId === bookingId); + if (!link) { + throw new NotFoundException(`Booking ${bookingId} is not assigned to this schedule`); + } + + await this.dataSource.transaction(async (manager) => { + const allocationIds = (schedule.trainSet?.wagons ?? []) + .flatMap((w) => w.allocations ?? []) + .filter((a) => a.bookingId === bookingId) + .map((a) => a.id); + + if (allocationIds.length) { + await this.wagonAllocationContainerItemsRepository.deleteByAllocationIds( + allocationIds, + manager, + ); + await this.wagonAllocationBulkLoadsRepository.deleteByAllocationIds(allocationIds, manager); + await manager.getRepository(WagonBookingAllocation).delete(allocationIds); + } + + await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking( + scheduleId, + bookingId, + manager, + ); + + const booking = await this.bookingsRepository.findById(bookingId); + const schedulingStatus = this.resolvePostUnassignStatus(booking); + await this.bookingsRepository.updateSchedulingFields( + bookingId, + { schedulingStatus, wagonsRequired: null }, + manager, + ); + + const remainingBookings = (schedule.scheduleBookings ?? []).filter( + (sb) => sb.bookingId !== bookingId, + ); + if (remainingBookings.length === 0) { + await this.wagonBookingAllocationsRepository.deleteByTrainSetId( + schedule.trainSetId, + manager, + ); + await manager.getRepository(TrainSetWagon).delete({ trainSetId: schedule.trainSetId }); + await manager.getRepository(TrainSet).update(schedule.trainSetId, { + totalWeightTons: 0, + totalLengthMeters: 0, + wagonCount: 0, + status: 'DRAFT', }); + } + }); - if (!lockedLocomotive) { - throw new NotFoundException(`Locomotive ${locomotive.id} not found`); - } + return this.getTrainScheduleById(scheduleId); + } - if (lockedLocomotive.status !== "AVAILABLE") { - throw new ConflictException( - `Locomotive ${lockedLocomotive.code} is not available`, + async pinWagons(scheduleId: string, dto: PinWagonsDto) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) { + throw new BadRequestException('Cannot pin wagons on a dispatched or cancelled schedule'); + } + + const slotIds = new Set((schedule.trainSet?.wagons ?? []).map((w) => w.id)); + + await this.dataSource.transaction(async (manager) => { + for (const assignment of dto.assignments) { + if (!slotIds.has(assignment.trainSetWagonId)) { + throw new BadRequestException( + `Train set wagon ${assignment.trainSetWagonId} does not belong to this schedule`, ); } - const trainSet = await this.buildEmptyTrainSet( - manager, - lockedLocomotive, - ); - - const schedule = manager.getRepository(TrainSchedule).create({ - trainSetId: trainSet.id, - routeId: route.id, - originStationId: route.originYardId, - destinationStationId: route.destinationYardId, - scheduledDepartureDate: new Date(dto.scheduleDate), - status: "DRAFT", + const physicalWagon = await manager.getRepository(Wagon).findOne({ + where: { id: assignment.physicalWagonId }, }); + if (!physicalWagon) { + throw new NotFoundException(`Wagon ${assignment.physicalWagonId} not found`); + } + if ( + physicalWagon.status !== WagonStatus.Available && + physicalWagon.currentTrainScheduleId !== scheduleId + ) { + throw new ConflictException( + `Wagon ${physicalWagon.wagonNumber} is not available`, + ); + } + if (!wagonReadinessMatchesSchedule(physicalWagon.readiness, schedule.direction)) { + throw new ConflictException( + `Wagon ${physicalWagon.wagonNumber} is ${physicalWagon.readiness} but schedule is ${schedule.direction ?? 'unknown'}`, + ); + } - const savedSchedule = await manager - .getRepository(TrainSchedule) - .save(schedule); - - await locomotiveRepository.update(lockedLocomotive.id, { - status: "ASSIGNED", + await manager.getRepository(TrainSetWagon).update(assignment.trainSetWagonId, { + physicalWagonId: assignment.physicalWagonId, + status: 'RESERVED', }); - - return savedSchedule.id; - }, - ); - - return this.getContainerTrainScheduleById(createdSchedule); - } - - async validateContainerBookingsForScheduling( - dto: PreviewContainerTrainScheduleDto, - ): Promise { - const bookingIds = [...new Set(dto.bookingIds)]; - - if (!bookingIds.length) { - throw new BadRequestException("At least one booking is required"); - } - - const [wagonType] = await this.wagonTypesRepository.findAll({ - where: { code: DEFAULT_WAGON_TYPE_CODE, isActive: true }, + await manager.getRepository(Wagon).update(assignment.physicalWagonId, { + trainSetWagonId: assignment.trainSetWagonId, + currentTrainScheduleId: scheduleId, + status: WagonStatus.Assigned, + }); + } }); - if (!wagonType) { - throw new NotFoundException( - `Wagon type ${DEFAULT_WAGON_TYPE_CODE} not found`, - ); + return this.getTrainScheduleById(scheduleId); + } + + async finalizeSchedule(scheduleId: string) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (schedule.status !== TrainScheduleStatusEnum.Draft) { + throw new BadRequestException('Only DRAFT schedules can be finalized'); + } + if (!schedule.scheduleBookings?.length) { + throw new BadRequestException('Cannot finalize a schedule with no bookings'); } - const bookings = await this.loadBookingsForScheduling(bookingIds); + const now = new Date(); + await this.dataSource.transaction(async (manager) => { + await this.trainSchedulesRepository.updateStatus( + scheduleId, + TrainScheduleStatusEnum.Scheduled, + {}, + manager, + ); + for (const sb of schedule.scheduleBookings ?? []) { + await this.bookingsRepository.updateSchedulingFields( + sb.bookingId, + { schedulingStatus: SchedulingStatus.Scheduled, scheduledAt: now }, + manager, + ); + } + }); + + return this.getTrainScheduleById(scheduleId); + } + + async dispatchSchedule(scheduleId: string) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (schedule.status !== TrainScheduleStatusEnum.Scheduled) { + throw new BadRequestException('Only SCHEDULED trains can be dispatched'); + } + + const now = new Date(); + await this.dataSource.transaction(async (manager) => { + await this.trainSchedulesRepository.updateStatus( + scheduleId, + TrainScheduleStatusEnum.Dispatched, + { actualDepartureAt: now }, + manager, + ); + if (schedule.trainSetId) { + await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'DISPATCHED' }); + } + for (const sb of schedule.scheduleBookings ?? []) { + await this.bookingsRepository.updateSchedulingFields( + sb.bookingId, + { schedulingStatus: SchedulingStatus.Dispatched }, + manager, + ); + } + }); + + return this.getTrainScheduleById(scheduleId); + } + + async getContainerTrainSchedules() { + const schedules = await this.trainSchedulesRepository.findAll({ + relations: { + trainSet: { locomotive: true }, + route: true, + originStation: true, + destinationStation: true, + scheduleBookings: { booking: true }, + }, + order: { scheduledDepartureDate: 'DESC', createdAt: 'DESC' }, + }); + return schedules.map((s) => this.mapScheduleListItem(s)); + } + + async getContainerTrainScheduleById(id: string) { + return this.getTrainScheduleById(id); + } + + async cancelTrainSchedule(id: string) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id); + if (!schedule) { + throw new NotFoundException(`Train schedule ${id} not found`); + } + + await this.dataSource.transaction(async (manager) => { + await this.trainSchedulesRepository.updateStatus( + id, + TrainScheduleStatusEnum.Cancelled, + {}, + manager, + ); + if (schedule.trainSetId) { + await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'CANCELLED' }); + } + if (schedule.trainSet?.locomotiveId) { + await manager.getRepository(Locomotive).update(schedule.trainSet.locomotiveId, { + status: 'AVAILABLE', + }); + } + for (const wagon of schedule.trainSet?.wagons ?? []) { + if (wagon.physicalWagonId) { + await manager.getRepository(Wagon).update(wagon.physicalWagonId, { + currentTrainScheduleId: null, + trainSetWagonId: null, + status: WagonStatus.Available, + }); + } + } + for (const sb of schedule.scheduleBookings ?? []) { + const booking = await this.bookingsRepository.findById(sb.bookingId); + await this.bookingsRepository.updateSchedulingFields( + sb.bookingId, + { schedulingStatus: this.resolvePostUnassignStatus(booking) }, + manager, + ); + } + }); + + return this.getTrainScheduleById(id); + } + + private async getTrainScheduleById(id: string) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id); + if (!schedule) { + throw new NotFoundException(`Train schedule ${id} not found`); + } + return this.mapScheduleDetail(schedule); + } + + private async validateBookingsForScheduling( + dto: PreviewContainerTrainScheduleDto | PreviewBulkTrainScheduleDto | PreviewTrainScheduleDto, + freightType: 'CONTAINER' | 'BULK' | null, + forceAssign = false, + containerPlacements: ContainerPlacementInput[] = [], + requireContainerPlacements = false, + trainLimits: Required, + targetScheduleId?: string, + ) { + const bookingIds = [...new Set(dto.bookingIds)]; + if (!bookingIds.length) { + throw new BadRequestException('At least one booking is required'); + } + + const bookings = await this.bookingsRepository.findByIdsForScheduling(bookingIds); const violations: string[] = []; + const warnings: string[] = []; if (bookings.length !== bookingIds.length) { - const foundIds = new Set(bookings.map((booking) => booking.id)); - const missing = bookingIds.filter((id) => !foundIds.has(id)); - violations.push(`Bookings not found: ${missing.join(", ")}`); + const foundIds = new Set(bookings.map((b) => b.id)); + violations.push(`Bookings not found: ${bookingIds.filter((id) => !foundIds.has(id)).join(', ')}`); } - const scheduledLinks = await this.dataSource - .getRepository(TrainScheduleBooking) - .find({ - where: { bookingId: In(bookingIds) }, - select: { bookingId: true }, - }); - - if (scheduledLinks.length > 0) { - violations.push( - "One or more selected bookings are already assigned to a train schedule", - ); + const scheduledLinks = await this.trainScheduleBookingsRepository.findByBookingIds(bookingIds); + const conflictingLinks = targetScheduleId + ? scheduledLinks.filter((link) => link.trainScheduleId !== targetScheduleId) + : scheduledLinks; + if (conflictingLinks.length > 0) { + violations.push('One or more selected bookings are already assigned to a train schedule'); } - const nonContainerBookings = bookings.filter( - (booking) => booking.freightType !== "CONTAINER", + const bookingTypes = new Set(bookings.map((b) => b.freightType)); + const isMixed = bookingTypes.size > 1; + const resolvedMode: 'CONTAINER' | 'BULK' | 'MIXED' = + freightType ?? (isMixed ? 'MIXED' : ([...bookingTypes][0] as 'CONTAINER' | 'BULK')); + + if (freightType === 'CONTAINER' || freightType === 'BULK') { + const wrongType = bookings.filter((b) => b.freightType !== freightType); + if (wrongType.length) { + violations.push(`Only ${freightType} bookings are supported`); + } + } + + const invalidStatus = bookings.filter( + (b) => !SCHEDULABLE_BOOKING_STATUSES.includes(b.status as 'PAID'), ); - if (nonContainerBookings.length > 0) { + if (invalidStatus.length) { + const statuses = [...new Set(invalidStatus.map((b) => b.status))]; violations.push( - "Only CONTAINER bookings are supported for train scheduling", - ); - } - - const invalidStatusBookings = bookings.filter( - (booking) => !SCHEDULABLE_BOOKING_STATUSES.includes(booking.status as "PAID"), - ); - if (invalidStatusBookings.length > 0) { - const invalidStatuses = [...new Set(invalidStatusBookings.map((booking) => booking.status))]; - violations.push( - `Only ${SCHEDULABLE_BOOKING_STATUSES.join(", ")} bookings can be scheduled; received: ${invalidStatuses.join(", ")}`, - ); - } - - const scheduleDateKey = this.toUtcDateKey(dto.scheduleDate); - const routeMismatch = bookings.some( - (booking) => - booking.originYardId !== dto.originStationId || - booking.destinationYardId !== dto.destinationStationId, - ); - if (routeMismatch) { - violations.push( - "Selected bookings must share the same origin and destination as the schedule", - ); - } - - const dateMismatch = bookings.some( - (booking) => this.toUtcDateKey(booking.scheduledDate) !== scheduleDateKey, - ); - if (dateMismatch) { - violations.push("Selected bookings must share the same schedule date"); - } - - const uniqueOriginCount = new Set( - bookings.map((booking) => booking.originYardId), - ).size; - if (uniqueOriginCount > 1) { - violations.push("Selected bookings must share the same origin station"); - } - - const uniqueDestinationCount = new Set( - bookings.map((booking) => booking.destinationYardId), - ).size; - if (uniqueDestinationCount > 1) { - violations.push( - "Selected bookings must share the same destination station", - ); - } - - const uniqueDateCount = new Set( - bookings.map((booking) => this.toUtcDateKey(booking.scheduledDate)), - ).size; - if (uniqueDateCount > 1) { - violations.push( - "Selected bookings must share the same preferred departure date", - ); - } - - const totalWeightTons = this.roundTons( - bookings.reduce( - (sum, booking) => sum + Number(booking.cargoTotalWeightVgm ?? 0), - 0, - ), - ); - - const wagonPlan = this.allocateBookingsToWagons( - bookings, - this.calculateNW5WagonPlan(totalWeightTons, wagonType), - ); - const totalLengthMeters = this.roundTons( - wagonPlan.reduce((sum, wagon) => sum + wagon.lengthMeters, 0), - ); - - if (totalWeightTons > MAX_TRAIN_WEIGHT_TONS) { - violations.push( - `Total booking weight ${totalWeightTons}T exceeds max train weight ${MAX_TRAIN_WEIGHT_TONS}T`, - ); - } - - if (totalLengthMeters > MAX_TRAIN_LENGTH_METERS) { - violations.push( - `Total wagon length ${totalLengthMeters}m exceeds max train length ${MAX_TRAIN_LENGTH_METERS}m`, + `Only ${SCHEDULABLE_BOOKING_STATUSES.join(', ')} bookings can be scheduled; received: ${statuses.join(', ')}`, ); } if ( - wagonType.maxWagonsPerTrain != null && - wagonPlan.length > Number(wagonType.maxWagonsPerTrain) + bookings.some( + (b) => + b.originYardId !== dto.originStationId || + b.destinationYardId !== dto.destinationStationId, + ) ) { - violations.push( - `Wagon count ${wagonPlan.length} exceeds wagon marshalling limit ${wagonType.maxWagonsPerTrain}`, - ); + violations.push('Selected bookings must share the same origin and destination as the schedule'); } - const availableLocomotiveCount = await this.dataSource - .getRepository(Locomotive) - .count({ - where: { status: "AVAILABLE" as LocomotiveStatus }, - }); + if (!forceAssign) { + for (const booking of bookings) { + if (this.isHoldActive(booking)) { + warnings.push( + `Booking ${booking.reference} is within the soft hold window (expires ${booking.holdExpiresAt?.toISOString()})`, + ); + } + const overweightLines = (booking.bookingContainers ?? []).filter((c) => c.isOverweight); + if (overweightLines.length) { + violations.push( + `Booking ${booking.reference} has overweight container lines; use forceAssign to override`, + ); + } + } + } - if (availableLocomotiveCount === 0) { - violations.push("No available locomotive exists for scheduling"); - } else { - const capableLocomotives = await this.dataSource - .getRepository(Locomotive) - .find({ - where: { status: "AVAILABLE" }, - }); - const canPull = capableLocomotives.some( - (locomotive) => - Number(locomotive.maxPullWeightTons) >= totalWeightTons && - Number(locomotive.maxTrainLengthMeters) >= totalLengthMeters, + let wagonType: WagonType; + let containerWagonType: WagonType; + let bulkWagonType: WagonType; + let demandPlan: WagonPlanSlot[]; + let fittingBookings = bookings; + let deferredBookings: DeferredBookingRow[] = []; + let fleetAvailability: FleetAvailabilityRow[] = []; + + if (resolvedMode === 'MIXED') { + const containerBookings = bookings.filter((b) => b.freightType === 'CONTAINER'); + const bulkBookings = bookings.filter((b) => b.freightType === 'BULK'); + containerWagonType = await this.resolveWagonType('CONTAINER', bookingIds); + bulkWagonType = await this.resolveWagonType('BULK', bookingIds); + wagonType = containerWagonType; + demandPlan = buildMixedWagonPlan( + containerBookings, + bulkBookings, + containerWagonType, + bulkWagonType, ); - if (!canPull) { + } else { + wagonType = await this.resolveWagonType(resolvedMode, bookingIds); + containerWagonType = wagonType; + bulkWagonType = wagonType; + demandPlan = + resolvedMode === 'CONTAINER' + ? buildContainerWagonPlan(bookings, wagonType) + : buildBulkWagonPlan(bookings, wagonType); + } + + const scheduleDirection = await this.resolveScheduleDirection(targetScheduleId, bookings); + const fleetCounts = await this.countFleetAvailability(scheduleDirection, targetScheduleId); + const fleetByTypeId = new Map(fleetCounts.map((row) => [row.wagonTypeId, row.available])); + fleetAvailability = computeFleetAvailability( + demandPlan, + fleetByTypeId, + new Map(fleetCounts.map((row) => [row.wagonTypeId, row.wagonTypeCode])), + ); + + const selection = selectBookingsWithinFleetCap( + bookings, + fleetByTypeId, + (booking) => + booking.freightType === 'BULK' ? bulkWagonType.id : containerWagonType.id, + Number(bulkWagonType.capacityTons), + ); + fittingBookings = selection.fitting; + deferredBookings = selection.deferred; + warnings.push(...summarizeFleetWarnings(fleetAvailability, deferredBookings)); + + const wagonPlan = buildCappedWagonPlan({ + bookings: fittingBookings, + resolvedMode, + containerWagonType, + bulkWagonType, + }); + + const placementRules = { + max20ftContainerWeightTons: trainLimits.max20ftContainerWeightTons, + max20ftPairWeightDiffTons: trainLimits.max20ftPairWeightDiffTons, + }; + + if (resolvedMode === 'MIXED') { + violations.push( + ...validateMixedTrainLimits(wagonPlan, [containerWagonType, bulkWagonType], trainLimits), + ); + if (requireContainerPlacements) { + const containerBookings = fittingBookings.filter((b) => b.freightType === 'CONTAINER'); violations.push( - 'No available locomotive can support the total train weight and length', + ...validateContainerPlacements( + containerBookings, + wagonPlan, + containerPlacements, + placementRules, + ), + ); + violations.push( + ...(await this.validateFleetContainers(containerPlacements, containerBookings)), ); } + } else { + violations.push(...validateTrainLimits(wagonPlan, wagonType, trainLimits)); + + if (requireContainerPlacements && resolvedMode === 'CONTAINER') { + violations.push( + ...validateContainerPlacements( + fittingBookings, + wagonPlan, + containerPlacements, + placementRules, + ), + ); + violations.push( + ...(await this.validateFleetContainers(containerPlacements, fittingBookings)), + ); + } + } + + const totalWeightTons = totalAssignedWeight(fittingBookings); + const totalLengthMeters = roundTons( + wagonPlan.reduce((sum, w) => sum + w.lengthMeters, 0), + ); + if (totalWeightTons > trainLimits.maxWeightTons) { + const message = `Total booking weight ${totalWeightTons}T exceeds max train weight ${trainLimits.maxWeightTons}T`; + if (!violations.includes(message)) { + violations.push(message); + } + } + + const availableLocomotives = await this.locomotivesRepository.findAll({ + where: { status: 'AVAILABLE' }, + }); + if (!availableLocomotives.length) { + violations.push('No available locomotive exists for scheduling'); + } else if ( + !availableLocomotives.some( + (l) => + Number(l.maxPullWeightTons) >= totalWeightTons && + Number(l.maxTrainLengthMeters) >= totalLengthMeters, + ) + ) { + violations.push('No available locomotive can support the total train weight and length'); } return { valid: violations.length === 0, violations, - bookings, + warnings, + bookings: fittingBookings, wagonType, + wagonPlan, + fleetAvailability, + deferredBookings, summary: { - totalBookings: bookings.length, + totalBookings: fittingBookings.length, totalWeightTons, - wagonType: wagonType.code, + wagonType: + resolvedMode === 'MIXED' ? 'MIXED' : wagonType.code, wagonsNeeded: wagonPlan.length, totalLengthMeters, + freightMode: resolvedMode, }, - wagonPlan, }; } - calculateNW5WagonPlan( - totalBookingWeightTons: number, - wagonType: WagonType, - ): WagonPlanRecord[] { - const wagonCapacityTons = Number(wagonType.capacityTons); - const wagonsNeeded = Math.ceil(totalBookingWeightTons / wagonCapacityTons); - let remainingWeight = this.roundTons(totalBookingWeightTons); + private async loadGlobalRulesRow(): Promise { + try { + const rows = await this.dataSource.getRepository(TrainSchedulingGlobalRules).find({ + order: { createdAt: 'ASC' }, + take: 1, + }); + return rows[0] ?? null; + } catch { + return null; + } + } - return Array.from({ length: wagonsNeeded }, (_, index) => { - const assignedWeightTons = this.roundTons( - Math.min(wagonCapacityTons, remainingWeight), - ); - remainingWeight = this.roundTons( - Math.max(0, remainingWeight - assignedWeightTons), - ); + private async resolveTrainLimitConfig(dto?: { + maxTrainWeightTons?: number; + maxTrainLengthMeters?: number; + maxWagonsPerTrain?: number; + }): Promise> { + const row = await this.loadGlobalRulesRow(); + const configured = this.configService?.get<{ + maxTrainWeightTons?: number; + maxTrainLengthMeters?: number; + maxWagonsPerTrain?: number; + }>('app.trainScheduling'); - return { - sequenceNo: index + 1, - capacityTons: wagonCapacityTons, - lengthMeters: this.roundTons(Number(wagonType.lengthMeters)), - assignedWeightTons, - allocations: [], - }; + return { + maxWeightTons: this.positiveNumber( + dto?.maxTrainWeightTons, + Number(row?.maxTrainWeightTons) || + configured?.maxTrainWeightTons || + DEFAULT_TRAIN_LIMITS.maxWeightTons, + ), + maxLengthMeters: this.positiveNumber( + dto?.maxTrainLengthMeters, + Number(row?.maxTrainLengthMeters) || + configured?.maxTrainLengthMeters || + DEFAULT_TRAIN_LIMITS.maxLengthMeters, + ), + maxWagonsPerTrain: Math.floor( + this.positiveNumber( + dto?.maxWagonsPerTrain, + Number(row?.maxWagonsPerTrain) || + configured?.maxWagonsPerTrain || + DEFAULT_TRAIN_LIMITS.maxWagonsPerTrain, + ), + ), + max20ftContainerWeightTons: this.positiveNumber( + undefined, + Number(row?.max20ftContainerWeightTons) || DEFAULT_TRAIN_LIMITS.max20ftContainerWeightTons, + ), + max20ftPairWeightDiffTons: this.positiveNumber( + undefined, + Number(row?.max20ftPairWeightDiffTons) || + DEFAULT_TRAIN_LIMITS.max20ftPairWeightDiffTons, + ), + }; + } + + private async resolveScheduleDirection( + targetScheduleId: string | undefined, + bookings: Booking[], + ): Promise { + if (targetScheduleId) { + const schedule = await this.trainSchedulesRepository.findById(targetScheduleId); + if (schedule?.direction) return schedule.direction; + } + + const booking = bookings[0]; + if (!booking) return null; + + return deriveScheduleDirection( + booking.originYard ?? { country: null }, + booking.destinationYard ?? { country: null }, + ); + } + + private async countFleetAvailability( + scheduleDirection: string | null, + targetScheduleId?: string, + ): Promise> { + const [wagons, wagonTypes] = await Promise.all([ + this.dataSource.getRepository(Wagon).find(), + this.dataSource.getRepository(WagonType).find(), + ]); + const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code])); + const counts = new Map(); + + for (const wagon of wagons) { + const pinnedOnTarget = targetScheduleId + ? wagon.currentTrainScheduleId === targetScheduleId + : false; + if (wagon.status !== WagonStatus.Available && !pinnedOnTarget) continue; + if (!wagonReadinessMatchesSchedule(wagon.readiness, scheduleDirection)) continue; + + const typeId = wagon.wagonTypeId; + const code = typeCodeById.get(typeId) ?? typeId; + const existing = counts.get(typeId) ?? { code, available: 0 }; + existing.available += 1; + counts.set(typeId, existing); + } + + return [...counts.entries()].map(([wagonTypeId, value]) => ({ + wagonTypeId, + wagonTypeCode: value.code, + available: value.available, + })); + } + + private async releasePinnedWagonsForTrainSet(manager: EntityManager, trainSetId: string) { + const slots = await manager.getRepository(TrainSetWagon).find({ where: { trainSetId } }); + for (const slot of slots) { + if (!slot.physicalWagonId) continue; + await manager.getRepository(Wagon).update(slot.physicalWagonId, { + status: WagonStatus.Available, + trainSetWagonId: null, + currentTrainScheduleId: null, + }); + } + } + + private async autoPinWagonsForSchedule( + manager: EntityManager, + scheduleId: string, + scheduleDirection: string | null, + slots: TrainSetWagon[], + ) { + const wagons = await manager.getRepository(Wagon).find(); + const assignedPhysicalIds = new Set(); + + for (const slot of [...slots].sort((a, b) => a.sequenceNo - b.sequenceNo)) { + const candidates = wagons.filter((wagon) => { + if (wagon.wagonTypeId !== slot.wagonTypeId) return false; + if (assignedPhysicalIds.has(wagon.id)) return false; + const pinnedOnSchedule = wagon.currentTrainScheduleId === scheduleId; + if (wagon.status !== WagonStatus.Available && !pinnedOnSchedule) return false; + return wagonReadinessMatchesSchedule(wagon.readiness, scheduleDirection); + }); + + const physical = candidates[0]; + if (!physical) continue; + + await manager.getRepository(TrainSetWagon).update(slot.id, { + physicalWagonId: physical.id, + status: 'RESERVED', + }); + await manager.getRepository(Wagon).update(physical.id, { + trainSetWagonId: slot.id, + currentTrainScheduleId: scheduleId, + status: WagonStatus.Assigned, + }); + assignedPhysicalIds.add(physical.id); + } + } + + private positiveNumber(value: number | undefined, fallback: number): number { + const numeric = Number(value); + return Number.isFinite(numeric) && numeric > 0 ? numeric : fallback; + } + + private async validateFleetContainers( + placements: ContainerPlacementInput[], + containerBookings: Booking[], + ): Promise { + const violations: string[] = []; + const inventoryIds = [ + ...new Set(placements.map((p) => p.containerId).filter((id): id is string => Boolean(id))), + ]; + if (!inventoryIds.length) return violations; + + const lineById = new Map( + containerBookings.flatMap((b) => + (b.bookingContainers ?? []).map((line) => [line.id, line] as const), + ), + ); + + const containers = await this.dataSource.getRepository(Container).find({ + where: { id: In(inventoryIds) }, }); + const containerById = new Map(containers.map((c) => [c.id, c])); + + for (const placement of placements) { + if (!placement.containerId) continue; + const fleet = containerById.get(placement.containerId); + if (!fleet) { + violations.push(`Fleet container ${placement.containerId} not found`); + continue; + } + if (fleet.status !== 'AVAILABLE') { + violations.push(`Container ${fleet.containerNumber} is not available`); + } + const line = lineById.get(placement.bookingContainerId); + if (line && fleet.containerTypeId !== line.containerTypeId) { + violations.push( + `Container ${fleet.containerNumber} type does not match booking line`, + ); + } + if ( + placement.containerNumber && + fleet.containerNumber.toUpperCase() !== placement.containerNumber.trim().toUpperCase() + ) { + violations.push( + `Container number ${placement.containerNumber} does not match fleet record ${fleet.containerNumber}`, + ); + } + } + + return violations; + } + + private async resolveWagonType( + freightType: 'CONTAINER' | 'BULK', + bookingIds: string[], + ): Promise { + if (freightType === 'CONTAINER') { + const [wagonType] = await this.wagonTypesRepository.findAll({ + where: { code: getDefaultContainerWagonTypeCode(), isActive: true }, + }); + if (!wagonType) { + throw new NotFoundException(`Wagon type ${getDefaultContainerWagonTypeCode()} not found`); + } + return wagonType; + } + + const bookings = await this.bookingsRepository.findByIdsForScheduling(bookingIds); + const cargoCode = bookings[0]?.cargoType?.code ?? null; + const wagonTypes = await this.wagonTypesRepository.findAll({ where: { isActive: true } }); + const picked = pickBulkWagonType(wagonTypes, cargoCode); + if (!picked) { + throw new NotFoundException('No suitable bulk wagon type found'); + } + return picked; + } + + private async persistTrainSetWagons( + manager: EntityManager, + trainSetId: string, + wagonType: WagonType, + wagonPlan: WagonPlanSlot[], + ) { + const wagons = wagonPlan.map((slot) => + manager.getRepository(TrainSetWagon).create({ + trainSetId, + wagonTypeId: slot.wagonTypeId ?? wagonType.id, + sequenceNo: slot.sequenceNo, + capacityTons: slot.capacityTons, + lengthMeters: slot.lengthMeters, + assignedWeightTons: slot.assignedWeightTons, + status: 'PLANNED', + }), + ); + return manager.getRepository(TrainSetWagon).save(wagons); + } + + private async persistAllocationsAndLoads( + manager: EntityManager, + savedWagons: TrainSetWagon[], + wagonPlan: WagonPlanSlot[], + bookings: Booking[], + containerPlacements: ContainerPlacementInput[] = [], + ) { + const bookingById = new Map(bookings.map((b) => [b.id, b])); + const lineById = new Map( + bookings.flatMap((b) => + (b.bookingContainers ?? []).map((line) => [line.id, { line, bookingId: b.id }] as const), + ), + ); + const allocationBySlotBooking = new Map(); + + const containerItems: Array<{ + wagonBookingAllocationId: string; + bookingContainerId: string; + containerTypeId: string | null; + grossWeightTons: number; + positionOnWagon: number | null; + containerId?: string | null; + containerNumber?: string | null; + sealNumber?: string | null; + }> = []; + const bulkLoads: Array<{ + wagonBookingAllocationId: string; + bookingId: string; + cargoTypeId: string | null; + cargoDescription: string | null; + weightTons: number; + quantity: number; + }> = []; + + for (let i = 0; i < savedWagons.length; i += 1) { + const slot = wagonPlan[i]; + const trainSetWagon = savedWagons[i]; + if (!slot || !trainSetWagon) continue; + + for (const alloc of slot.allocations) { + const savedAllocation = await manager.getRepository(WagonBookingAllocation).save( + manager.getRepository(WagonBookingAllocation).create({ + trainSetWagonId: trainSetWagon.id, + bookingId: alloc.bookingId, + allocatedWeightTons: alloc.allocatedWeightTons, + loadType: alloc.loadType, + status: 'PLANNED', + }), + ); + + allocationBySlotBooking.set( + `${slot.sequenceNo}:${alloc.bookingId}`, + savedAllocation.id, + ); + + const booking = bookingById.get(alloc.bookingId); + if (!booking) continue; + + if (alloc.loadType === AllocationLoadType.Bulk) { + bulkLoads.push({ + wagonBookingAllocationId: savedAllocation.id, + bookingId: booking.id, + cargoTypeId: booking.cargoTypeId ?? null, + cargoDescription: booking.cargoFreeText ?? null, + weightTons: alloc.allocatedWeightTons, + quantity: 1, + }); + } + } + } + + for (const placement of containerPlacements) { + const lineEntry = lineById.get(placement.bookingContainerId); + if (!lineEntry) continue; + + const allocationId = allocationBySlotBooking.get( + `${placement.sequenceNo}:${lineEntry.bookingId}`, + ); + if (!allocationId) continue; + + const { line } = lineEntry; + containerItems.push({ + wagonBookingAllocationId: allocationId, + bookingContainerId: placement.bookingContainerId, + containerTypeId: line.containerTypeId ?? null, + grossWeightTons: Number(line.vgmPerUnitTons), + positionOnWagon: placement.unitIndex + 1, + containerId: placement.containerId ?? null, + containerNumber: placement.containerNumber?.trim() ?? null, + sealNumber: placement.sealNumber ?? null, + }); + + if (placement.containerId) { + await manager.getRepository(Container).update(placement.containerId, { + status: 'LOADED', + bookingId: lineEntry.bookingId, + wagonBookingAllocationId: allocationId, + bookingContainerId: placement.bookingContainerId, + }); + } + + // Save container number to booking_container when staff enters a new container number + if (placement.containerNumber && placement.containerNumber.trim()) { + await manager.getRepository(BookingContainer).update(placement.bookingContainerId, { + containerNumber: placement.containerNumber.trim(), + }); + } + } + + if (containerItems.length) { + await this.wagonAllocationContainerItemsRepository.createMany(containerItems, manager); + } + if (bulkLoads.length) { + await this.wagonAllocationBulkLoadsRepository.createMany(bulkLoads, manager); + } } async selectOrValidateLocomotive( @@ -448,69 +1249,24 @@ export class TrainSchedulingService { totalLengthMeters: number, ) { const locomotive = await this.locomotivesRepository.findById(locomotiveId); - if (!locomotive) { throw new NotFoundException(`Locomotive ${locomotiveId} not found`); } - - if (locomotive.status !== "AVAILABLE") { - throw new BadRequestException( - `Locomotive ${locomotive.code} is not available`, - ); + if (locomotive.status !== 'AVAILABLE') { + throw new BadRequestException(`Locomotive ${locomotive.code} is not available`); } - if (Number(locomotive.maxPullWeightTons) < totalWeightTons) { - throw new BadRequestException( - `Locomotive ${locomotive.code} cannot pull ${totalWeightTons}T`, - ); + throw new BadRequestException(`Locomotive ${locomotive.code} cannot pull ${totalWeightTons}T`); } - if (Number(locomotive.maxTrainLengthMeters) < totalLengthMeters) { throw new BadRequestException( `Locomotive ${locomotive.code} cannot support ${totalLengthMeters}m`, ); } - return locomotive; } - async buildTrainSet( - manager: EntityManager, - locomotive: Locomotive, - wagonType: WagonType, - totalWeightTons: number, - totalLengthMeters: number, - wagonPlan: WagonPlanRecord[], - ) { - const trainSet = manager.getRepository(TrainSet).create({ - locomotiveId: locomotive.id, - totalWeightTons, - totalLengthMeters, - wagonCount: wagonPlan.length, - status: "ASSIGNED", - }); - const savedTrainSet = await manager.getRepository(TrainSet).save(trainSet); - - const wagons = wagonPlan.map((wagon) => - manager.getRepository(TrainSetWagon).create({ - trainSetId: savedTrainSet.id, - wagonTypeId: wagonType.id, - sequenceNo: wagon.sequenceNo, - capacityTons: wagon.capacityTons, - lengthMeters: wagon.lengthMeters, - assignedWeightTons: wagon.assignedWeightTons, - }), - ); - - await manager.getRepository(TrainSetWagon).save(wagons); - - return savedTrainSet; - } - - async buildEmptyTrainSet( - manager: EntityManager, - locomotive: Locomotive, - ) { + private async buildEmptyTrainSet(manager: EntityManager, locomotive: Locomotive) { const trainSet = manager.getRepository(TrainSet).create({ locomotiveId: locomotive.id, totalWeightTons: 0, @@ -518,288 +1274,212 @@ export class TrainSchedulingService { wagonCount: 0, status: 'DRAFT', }); - return manager.getRepository(TrainSet).save(trainSet); } - allocateBookingsToWagons( - bookings: Booking[], - baseWagonPlan: WagonPlanRecord[], - ): WagonPlanRecord[] { - const remaining = bookings.map((booking) => ({ - bookingId: booking.id, - bookingReference: booking.reference, - remainingWeightTons: this.roundTons( - Number(booking.cargoTotalWeightVgm ?? 0), - ), - })); - let bookingIndex = 0; - - return baseWagonPlan.map((wagon) => { - let wagonRemaining = this.roundTons(wagon.capacityTons); - const allocations: WagonAllocationRecord[] = []; - let assignedWeightTons = 0; - - while (wagonRemaining > 0 && bookingIndex < remaining.length) { - const booking = remaining[bookingIndex]; - const allocatedWeightTons = this.roundTons( - Math.min(wagonRemaining, booking.remainingWeightTons), - ); - - if (allocatedWeightTons <= 0) { - bookingIndex += 1; - continue; - } - - allocations.push({ - bookingId: booking.bookingId, - bookingReference: booking.bookingReference, - allocatedWeightTons, - }); - booking.remainingWeightTons = this.roundTons( - booking.remainingWeightTons - allocatedWeightTons, - ); - wagonRemaining = this.roundTons(wagonRemaining - allocatedWeightTons); - assignedWeightTons = this.roundTons( - assignedWeightTons + allocatedWeightTons, - ); - - if (booking.remainingWeightTons <= 0) { - bookingIndex += 1; - } - } - - return { - ...wagon, - assignedWeightTons, - allocations, - }; - }); - } - - async getContainerTrainSchedules() { - const schedules = await this.dataSource.getRepository(TrainSchedule).find({ - relations: { - trainSet: { locomotive: true }, - route: true, - originStation: true, - destinationStation: true, - scheduleBookings: true, - }, - order: { scheduledDepartureDate: "DESC", createdAt: "DESC" }, - }); - - return schedules.map((schedule) => ({ - id: schedule.id, - scheduleDate: schedule.scheduledDepartureDate, - routeName: schedule.route?.name ?? null, - origin: - schedule.originStation?.label ?? schedule.originStation?.code ?? null, - destination: - schedule.destinationStation?.label ?? - schedule.destinationStation?.code ?? - null, - locomotive: schedule.trainSet?.locomotive - ? { - id: schedule.trainSet.locomotive.id, - code: schedule.trainSet.locomotive.code, - name: schedule.trainSet.locomotive.name ?? null, - } - : null, - wagonCount: schedule.trainSet?.wagonCount ?? 0, - totalWeightTons: this.roundTons( - Number(schedule.trainSet?.totalWeightTons ?? 0), - ), - totalLengthMeters: this.roundTons( - Number(schedule.trainSet?.totalLengthMeters ?? 0), - ), - bookingsCount: schedule.scheduleBookings?.length ?? 0, - status: schedule.status, - })); - } - - async getContainerTrainScheduleById(id: string) { - const schedule = await this.dataSource - .getRepository(TrainSchedule) - .findOne({ - where: { id }, - relations: { - route: true, - trainSet: { - locomotive: true, - wagons: { wagonType: true, allocations: { booking: true } }, - }, - originStation: true, - destinationStation: true, - scheduleBookings: { - booking: { company: true, originYard: true, destinationYard: true }, - }, - }, - }); - - if (!schedule) { - throw new NotFoundException(`Train schedule ${id} not found`); - } - - return { - id: schedule.id, - status: schedule.status, - route: schedule.route - ? { - id: schedule.route.id, - name: schedule.route.name, - } - : null, - scheduledDepartureDate: schedule.scheduledDepartureDate, - scheduledArrivalDate: schedule.scheduledArrivalDate, - originStation: schedule.originStation, - destinationStation: schedule.destinationStation, - trainSet: schedule.trainSet - ? { - id: schedule.trainSet.id, - status: schedule.trainSet.status, - wagonCount: schedule.trainSet.wagonCount, - totalWeightTons: this.roundTons( - Number(schedule.trainSet.totalWeightTons), - ), - totalLengthMeters: this.roundTons( - Number(schedule.trainSet.totalLengthMeters), - ), - locomotive: schedule.trainSet.locomotive - ? { - id: schedule.trainSet.locomotive.id, - code: schedule.trainSet.locomotive.code, - name: schedule.trainSet.locomotive.name, - status: schedule.trainSet.locomotive.status, - maxPullWeightTons: this.roundTons( - Number(schedule.trainSet.locomotive.maxPullWeightTons), - ), - maxTrainLengthMeters: this.roundTons( - Number(schedule.trainSet.locomotive.maxTrainLengthMeters), - ), - } - : null, - wagons: [...(schedule.trainSet.wagons ?? [])] - .sort((left, right) => left.sequenceNo - right.sequenceNo) - .map((wagon) => ({ - id: wagon.id, - sequenceNo: wagon.sequenceNo, - capacityTons: this.roundTons(Number(wagon.capacityTons)), - lengthMeters: this.roundTons(Number(wagon.lengthMeters)), - assignedWeightTons: this.roundTons( - Number(wagon.assignedWeightTons), - ), - wagonType: wagon.wagonType - ? { - id: wagon.wagonType.id, - code: wagon.wagonType.code, - name: wagon.wagonType.name, - } - : null, - allocations: - wagon.allocations?.map((allocation) => ({ - id: allocation.id, - bookingId: allocation.bookingId, - bookingReference: allocation.booking?.reference ?? null, - allocatedWeightTons: this.roundTons( - Number(allocation.allocatedWeightTons), - ), - })) ?? [], - })), - } - : null, - bookings: - schedule.scheduleBookings?.map((scheduleBooking) => ({ - id: scheduleBooking.booking?.id ?? scheduleBooking.bookingId, - reference: scheduleBooking.booking?.reference ?? null, - customer: - scheduleBooking.booking?.company?.name ?? - scheduleBooking.booking?.company?.email ?? - null, - weightTons: this.roundTons( - Number(scheduleBooking.booking?.cargoTotalWeightVgm ?? 0), - ), - status: scheduleBooking.booking?.status ?? null, - })) ?? [], - }; - } - - async cancelTrainSchedule(id: string) { - const schedule = await this.dataSource - .getRepository(TrainSchedule) - .findOne({ - where: { id }, - relations: { trainSet: { locomotive: true } }, - }); - - if (!schedule) { - throw new NotFoundException(`Train schedule ${id} not found`); - } - - await this.dataSource.transaction(async (manager) => { - await manager.getRepository(TrainSchedule).update(schedule.id, { - status: "CANCELLED", - }); - - if (schedule.trainSetId) { - await manager.getRepository(TrainSet).update(schedule.trainSetId, { - status: "CANCELLED", - }); - } - - if (schedule.trainSet?.locomotiveId) { - await manager - .getRepository(Locomotive) - .update(schedule.trainSet.locomotiveId, { - status: "AVAILABLE", - }); - } - }); - - return this.getContainerTrainScheduleById(id); - } - - private async loadBookingsForScheduling(bookingIds: string[]) { - return this.dataSource.getRepository(Booking).find({ - where: { id: In(bookingIds) }, - relations: { - company: true, - originYard: true, - destinationYard: true, - bookingContainers: { containerType: true }, - }, - order: { createdAt: "ASC" }, - }); - } - private async getActiveRoute(routeId: string) { const route = await this.dataSource.getRepository(Route).findOne({ where: { id: routeId }, + relations: { originYard: true, destinationYard: true }, }); - - if (!route) { - throw new NotFoundException(`Route ${routeId} not found`); - } - - if (!route.isActive) { - throw new BadRequestException(`Route ${route.name} is inactive`); - } - + if (!route) throw new NotFoundException(`Route ${routeId} not found`); + if (!route.isActive) throw new BadRequestException(`Route ${route.name} is inactive`); return route; } - private toUtcDateKey(value: Date | string) { - const date = value instanceof Date ? value : new Date(value); - return date.toISOString().slice(0, 10); + private mapEligibleBooking(booking: Booking) { + return { + id: booking.id, + reference: booking.reference, + freightType: booking.freightType, + customer: booking.company?.name ?? booking.company?.email ?? 'Unknown customer', + priorityScore: booking.priorityScore, + schedulingStatus: booking.schedulingStatus, + containerType: + booking.bookingContainers + ?.map((c) => c.containerType?.label ?? c.containerType?.code ?? 'Container') + .join(', ') ?? (booking.cargoType?.cargoTypeName ?? 'Bulk'), + quantity: + booking.bookingContainers?.reduce((sum, c) => sum + Number(c.quantity ?? 0), 0) ?? 0, + weightTons: roundTons(booking.cargoTotalWeightVgm), + origin: booking.originYard?.label ?? booking.originYard?.code ?? 'Unknown origin', + destination: + booking.destinationYard?.label ?? booking.destinationYard?.code ?? 'Unknown destination', + preferredDepartureDate: booking.scheduledDate.toISOString(), + status: booking.status, + }; } - private roundTons(value: number | string | null | undefined) { - const numericValue = typeof value === "number" ? value : Number(value ?? 0); + private resolveScheduleFreightType( + schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule, + ): 'CONTAINER' | 'BULK' | 'MIXED' | null { + const types = new Set( + (schedule.scheduleBookings ?? []) + .map((sb) => sb.booking?.freightType) + .filter((t): t is string => Boolean(t)), + ); + if (types.size === 1) return [...types][0] as 'CONTAINER' | 'BULK'; + if (types.size > 1) return 'MIXED'; + return null; + } - if (!Number.isFinite(numericValue)) { - return 0; + private mapScheduleListItem(schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule) { + return { + id: schedule.id, + scheduleDate: schedule.scheduledDepartureDate, + trainNumber: schedule.trainNumber ?? null, + routeName: schedule.route?.name ?? null, + origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null, + destination: + schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null, + locomotive: schedule.trainSet?.locomotive + ? { + id: schedule.trainSet.locomotive.id, + code: schedule.trainSet.locomotive.code, + name: schedule.trainSet.locomotive.name ?? null, + } + : null, + wagonCount: schedule.trainSet?.wagonCount ?? 0, + totalWeightTons: roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0)), + totalLengthMeters: roundTons(Number(schedule.trainSet?.totalLengthMeters ?? 0)), + bookingsCount: schedule.scheduleBookings?.length ?? 0, + freightType: this.resolveScheduleFreightType(schedule), + status: schedule.status, + }; + } + + private async mapScheduleDetail( + schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule, + ) { + const allocationIds = (schedule.trainSet?.wagons ?? []) + .flatMap((w) => w.allocations ?? []) + .map((a) => a.id); + + const [containerItems, bulkLoads] = await Promise.all([ + allocationIds.length + ? this.wagonAllocationContainerItemsRepository.findAll({ + where: { wagonBookingAllocationId: In(allocationIds) }, + relations: { containerType: true, bookingContainer: true }, + }) + : [], + allocationIds.length + ? this.wagonAllocationBulkLoadsRepository.findAll({ + where: { wagonBookingAllocationId: In(allocationIds) }, + relations: { cargoType: true }, + }) + : [], + ]); + + const containerItemsByAllocation = new Map(); + for (const item of containerItems) { + const list = containerItemsByAllocation.get(item.wagonBookingAllocationId) ?? []; + list.push(item); + containerItemsByAllocation.set(item.wagonBookingAllocationId, list); } + const bulkLoadsByAllocation = new Map( + bulkLoads.map((load) => [load.wagonBookingAllocationId, load]), + ); - return Number(numericValue.toFixed(3)); + return { + id: schedule.id, + status: schedule.status, + freightType: this.resolveScheduleFreightType(schedule), + trainNumber: schedule.trainNumber ?? null, + direction: schedule.direction ?? null, + route: schedule.route ? { id: schedule.route.id, name: schedule.route.name } : null, + scheduledDepartureDate: schedule.scheduledDepartureDate, + scheduledArrivalDate: schedule.scheduledArrivalDate, + actualDepartureAt: schedule.actualDepartureAt ?? null, + originStation: schedule.originStation, + destinationStation: schedule.destinationStation, + trainSet: schedule.trainSet + ? { + id: schedule.trainSet.id, + status: schedule.trainSet.status, + wagonCount: schedule.trainSet.wagonCount, + totalWeightTons: roundTons(Number(schedule.trainSet.totalWeightTons)), + totalLengthMeters: roundTons(Number(schedule.trainSet.totalLengthMeters)), + locomotive: schedule.trainSet.locomotive + ? { + id: schedule.trainSet.locomotive.id, + code: schedule.trainSet.locomotive.code, + name: schedule.trainSet.locomotive.name, + status: schedule.trainSet.locomotive.status, + maxPullWeightTons: roundTons( + Number(schedule.trainSet.locomotive.maxPullWeightTons), + ), + maxTrainLengthMeters: roundTons( + Number(schedule.trainSet.locomotive.maxTrainLengthMeters), + ), + } + : null, + wagons: [...(schedule.trainSet.wagons ?? [])] + .sort((a, b) => a.sequenceNo - b.sequenceNo) + .map((wagon) => ({ + id: wagon.id, + sequenceNo: wagon.sequenceNo, + capacityTons: roundTons(Number(wagon.capacityTons)), + lengthMeters: roundTons(Number(wagon.lengthMeters)), + assignedWeightTons: roundTons(Number(wagon.assignedWeightTons)), + status: wagon.status, + physicalWagonId: wagon.physicalWagonId ?? null, + physicalWagonNumber: wagon.physicalWagon?.wagonNumber ?? null, + 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: roundTons(Number(allocation.allocatedWeightTons)), + loadType: allocation.loadType ?? null, + status: allocation.status, + containerItems: (containerItemsByAllocation.get(allocation.id) ?? []).map( + (item) => ({ + id: item.id, + containerNumber: item.containerNumber ?? null, + containerTypeId: item.containerTypeId, + grossWeightTons: item.grossWeightTons ?? null, + containerId: item.containerId ?? null, + positionOnWagon: item.positionOnWagon ?? null, + bookingContainerId: item.bookingContainerId ?? null, + }), + ), + bulkLoad: bulkLoadsByAllocation.get(allocation.id) + ? { + id: bulkLoadsByAllocation.get(allocation.id)!.id, + weightTons: bulkLoadsByAllocation.get(allocation.id)!.weightTons, + cargoDescription: + bulkLoadsByAllocation.get(allocation.id)!.cargoDescription ?? null, + } + : null, + })) ?? [], + })), + } + : null, + bookings: + schedule.scheduleBookings?.map((sb) => ({ + id: sb.booking?.id ?? sb.bookingId, + reference: sb.booking?.reference ?? null, + customer: sb.booking?.company?.name ?? sb.booking?.company?.email ?? null, + weightTons: roundTons(Number(sb.booking?.cargoTotalWeightVgm ?? 0)), + status: sb.booking?.status ?? null, + schedulingStatus: sb.booking?.schedulingStatus ?? null, + })) ?? [], + }; + } + + private isHoldActive(booking: Booking): boolean { + if (!booking.holdExpiresAt) return false; + return booking.holdExpiresAt.getTime() > Date.now(); + } + + private resolvePostUnassignStatus(booking: Booking | null): string { + if (!booking) return SchedulingStatus.NotScheduled; + if (booking.holdExpiresAt && booking.holdExpiresAt.getTime() > Date.now()) { + return SchedulingStatus.Holding; + } + return SchedulingStatus.Eligible; } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts new file mode 100644 index 000000000..824c45e6e --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts @@ -0,0 +1,202 @@ +import { AllocationLoadType } from '@edr/types'; + +import { Booking } from '../bookings/entities/booking.entity'; +import { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { + buildBulkWagonPlan, + buildContainerWagonPlan, + buildMixedWagonPlan, + expandBookingContainerUnits, + expandContainerItems, + roundTons, + sumWagonsRequired, + validate20ftContainerRules, + validateContainerPlacements, +} from './wagon-plan.util'; + +const nw5: WagonType = { + id: 'wt-nw5', + code: 'NW5', + name: 'Flat Wagon', + capacityTons: 70, + lengthMeters: 14, + maxWagonsPerTrain: 53, + supportedLoadTypes: ['CONTAINER'], + isActive: true, + supportsContainer: true, +} as WagonType; + +const cw3: WagonType = { + id: 'wt-cw3', + code: 'CW3', + name: 'Covered Wagon', + capacityTons: 60, + lengthMeters: 14, + maxWagonsPerTrain: 53, + supportedLoadTypes: ['BULK'], + isActive: true, + supportsContainer: false, +} as WagonType; + +const makeContainerBooking = ( + id: string, + lines: Array<{ quantity: number; wagonsRequired: number; vgmPerUnitTons?: number }>, +): Booking => + ({ + id, + reference: id, + freightType: 'CONTAINER', + cargoTotalWeightVgm: lines.reduce( + (sum, line) => sum + line.quantity * (line.vgmPerUnitTons ?? 25), + 0, + ), + bookingContainers: lines.map((line, index) => ({ + id: `${id}-line-${index}`, + containerTypeId: `ct-${index}`, + quantity: line.quantity, + wagonsRequired: line.wagonsRequired, + vgmPerUnitTons: line.vgmPerUnitTons ?? 25, + })), + }) as Booking; + +describe('wagon-plan.util', () => { + it('uses slot-based planning: 2×20ft = 1 wagon slot', () => { + const booking = makeContainerBooking('b1', [{ quantity: 2, wagonsRequired: 1 }]); + const plan = buildContainerWagonPlan([booking], nw5); + expect(plan).toHaveLength(1); + expect(plan[0]?.allocations[0]?.loadType).toBe(AllocationLoadType.Container); + }); + + it('uses slot-based planning: 1×40ft = 1 wagon slot', () => { + const booking = makeContainerBooking('b2', [{ quantity: 1, wagonsRequired: 1 }]); + const plan = buildContainerWagonPlan([booking], nw5); + expect(plan).toHaveLength(1); + }); + + it('sums wagons across multiple container lines', () => { + const booking = makeContainerBooking('b3', [ + { quantity: 2, wagonsRequired: 1 }, + { quantity: 1, wagonsRequired: 1 }, + ]); + expect(sumWagonsRequired(booking)).toBe(2); + const plan = buildContainerWagonPlan([booking], nw5); + expect(plan).toHaveLength(2); + }); + + it('6×20ft containers = 3 wagon slots (2 per wagon)', () => { + // 20ft containers have wagonsPerUnit = 0.5, so 6 * 0.5 = 3 wagons + const booking = makeContainerBooking('b6x20', [{ quantity: 6, wagonsRequired: 3 }]); + expect(sumWagonsRequired(booking)).toBe(3); + const plan = buildContainerWagonPlan([booking], nw5); + expect(plan).toHaveLength(3); + // Verify sequence numbers are 1, 2, 3 + expect(plan.map((s) => s.sequenceNo)).toEqual([1, 2, 3]); + }); + + it('expands container items per quantity', () => { + const booking = makeContainerBooking('b4', [{ quantity: 3, wagonsRequired: 3 }]); + const items = expandContainerItems(booking, 'alloc-1'); + expect(items).toHaveLength(3); + expect(items[0]?.wagonBookingAllocationId).toBe('alloc-1'); + }); + + it('rounds tons to three decimal places', () => { + expect(roundTons(1.23456)).toBe(1.235); + expect(roundTons('bad')).toBe(0); + }); + + it('builds mixed plan with container block before bulk', () => { + const containerBooking = makeContainerBooking('c1', [{ quantity: 2, wagonsRequired: 2 }]); + const bulkBooking = { + id: 'b1', + reference: 'BKG-BULK', + freightType: 'BULK', + cargoTotalWeightVgm: 120, + bookingContainers: [], + } as unknown as Booking; + + const plan = buildMixedWagonPlan([containerBooking], [bulkBooking], nw5, cw3); + expect(plan).toHaveLength(4); + expect(plan[0]?.slotLoadType).toBe('CONTAINER'); + expect(plan[2]?.slotLoadType).toBe('BULK'); + expect(plan.map((s) => s.sequenceNo)).toEqual([1, 2, 3, 4]); + }); + + it('expands booking container units for UI rows', () => { + const booking = makeContainerBooking('c2', [{ quantity: 3, wagonsRequired: 3 }]); + const units = expandBookingContainerUnits([booking]); + expect(units).toHaveLength(3); + expect(units[1]?.unitIndex).toBe(1); + expect(units[1]?.bookingContainerId).toBe('c2-line-0'); + }); + + it('validates required placements per container unit', () => { + const booking = makeContainerBooking('c3', [{ quantity: 2, wagonsRequired: 2 }]); + const plan = buildContainerWagonPlan([booking], nw5); + const violations = validateContainerPlacements([booking], plan, []); + expect(violations.some((v) => v.includes('required'))).toBe(true); + + const units = expandBookingContainerUnits([booking]); + const placements = units.map((unit, index) => ({ + bookingContainerId: unit.bookingContainerId, + unitIndex: unit.unitIndex, + sequenceNo: plan[index]?.sequenceNo ?? 1, + containerNumber: `CNTR-${index + 1}`, + })); + expect(validateContainerPlacements([booking], plan, placements)).toEqual([]); + }); + + it('rejects 20ft container over max individual weight', () => { + const booking = makeContainerBooking('c20', [{ quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 35 }]); + const units = expandBookingContainerUnits([booking]); + const placements = units.map((unit, index) => ({ + bookingContainerId: unit.bookingContainerId, + unitIndex: unit.unitIndex, + sequenceNo: 1, + containerNumber: `CNTR-${index + 1}`, + })); + + const violations = validate20ftContainerRules(units, placements, { + max20ftContainerWeightTons: 30, + max20ftPairWeightDiffTons: 10, + }); + + expect(violations.some((v) => v.includes('exceeds max 30T'))).toBe(true); + }); + + it('rejects 20ft pair when weight difference exceeds limit', () => { + const booking = makeContainerBooking('c21', [ + { quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 25 }, + ]); + booking.bookingContainers![0]!.vgmPerUnitTons = 25; + const units = expandBookingContainerUnits([booking]); + units[1]!.grossWeightTons = 10; + const placements = units.map((unit) => ({ + bookingContainerId: unit.bookingContainerId, + unitIndex: unit.unitIndex, + sequenceNo: 1, + containerNumber: `CNTR-${unit.unitIndex}`, + })); + + const violations = validate20ftContainerRules(units, placements, { + max20ftContainerWeightTons: 30, + max20ftPairWeightDiffTons: 10, + }); + + expect(violations.some((v) => v.includes('weight difference'))).toBe(true); + }); + + it('builds bulk-only plan as degenerate mixed case', () => { + const bulkBooking = { + id: 'b2', + reference: 'BKG-BULK-2', + freightType: 'BULK', + cargoTotalWeightVgm: 60, + bookingContainers: [], + } as unknown as Booking; + const plan = buildMixedWagonPlan([], [bulkBooking], nw5, cw3); + expect(plan).toHaveLength(1); + expect(plan[0]?.slotLoadType).toBe('BULK'); + expect(buildBulkWagonPlan([bulkBooking], cw3)).toHaveLength(1); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts new file mode 100644 index 000000000..249758fb0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts @@ -0,0 +1,551 @@ +import { AllocationLoadType } from '@edr/types'; + +import { Booking } from '../bookings/entities/booking.entity'; +import { WagonType } from '../wagon-types/entities/wagon-type.entity'; + +export const MAX_TRAIN_WEIGHT_TONS = 3500; +export const MAX_TRAIN_LENGTH_METERS = 760; +export const MAX_TEU_SLOTS_PER_WAGON = 2; + +export type TrainLimitConfig = { + maxWeightTons?: number; + maxLengthMeters?: number; + maxWagonsPerTrain?: number; + max20ftContainerWeightTons?: number; + max20ftPairWeightDiffTons?: number; +}; + +export type ContainerPlacementRules = { + max20ftContainerWeightTons?: number; + max20ftPairWeightDiffTons?: number; +}; + +export type WagonAllocationRecord = { + bookingId: string; + bookingReference: string; + allocatedWeightTons: number; + loadType: AllocationLoadType; +}; + +export type SlotLoadType = 'CONTAINER' | 'BULK'; + +export type WagonPlanSlot = { + sequenceNo: number; + wagonTypeId: string; + wagonTypeCode: string; + capacityTons: number; + lengthMeters: number; + assignedWeightTons: number; + allocations: WagonAllocationRecord[]; + slotLoadType?: SlotLoadType; +}; + +export type ContainerUnitRow = { + bookingId: string; + bookingReference: string; + bookingContainerId: string; + unitIndex: number; + containerTypeId: string; + containerTypeCode: string; + label: string; + grossWeightTons: number; + sizeFt?: number; + wagonsPerUnit?: number; + containersPerWagon?: number; + teuSlots?: number; +}; + +export type ContainerPlacementInput = { + bookingContainerId: string; + unitIndex: number; + sequenceNo: number; + containerId?: string; + containerNumber?: string; + sealNumber?: string; +}; + +export function roundTons(value: number | string | null | undefined): number { + const numericValue = typeof value === 'number' ? value : Number(value ?? 0); + if (!Number.isFinite(numericValue)) return 0; + return Number(numericValue.toFixed(3)); +} + +/** TEU slots on a wagon: 40ft = 2, 20ft = 1 (max 2 TEU / wagon). */ +export function teuSlotsForSizeFt(sizeFt: number): number { + return sizeFt >= 40 ? 2 : 1; +} + +export function containersPerWagonFromType(wagonsPerUnit: number): number { + const wpu = Number(wagonsPerUnit); + if (!wpu || wpu <= 0) return 1; + return Math.max(1, Math.round(1 / wpu)); +} + +function lineWagonsRequired(line: { + quantity?: number | null; + wagonsRequired?: number | null; + containerType?: { wagonsPerUnit?: number | null; sizeFt?: number | null } | null; +}): number { + const qty = Number(line.quantity ?? 0); + if (qty <= 0) return 0; + const wpu = Number(line.containerType?.wagonsPerUnit); + if (Number.isFinite(wpu) && wpu > 0) { + return Math.ceil(qty * wpu); + } + return Math.max(1, Math.ceil(Number(line.wagonsRequired ?? 1))); +} + +/** + * Build slot-based wagon plan for CONTAINER bookings using booking_container.wagons_required. + */ +export function buildContainerWagonPlan( + bookings: Booking[], + wagonType: WagonType, +): WagonPlanSlot[] { + const totalSlots = bookings.reduce((sum, booking) => { + const lineSlots = (booking.bookingContainers ?? []).reduce( + (lineSum, line) => lineSum + lineWagonsRequired(line), + 0, + ); + return sum + Math.max(lineSlots, 1); + }, 0); + + const slots = Math.max(1, Math.ceil(totalSlots)); + const basePlan: WagonPlanSlot[] = Array.from({ length: slots }, (_, index) => ({ + sequenceNo: index + 1, + wagonTypeId: wagonType.id, + wagonTypeCode: wagonType.code, + capacityTons: Number(wagonType.capacityTons), + lengthMeters: Number(wagonType.lengthMeters), + assignedWeightTons: 0, + allocations: [], + })); + + return allocateBookingsToSlots(bookings, basePlan, AllocationLoadType.Container).map((slot) => ({ + ...slot, + slotLoadType: 'CONTAINER' as SlotLoadType, + })); +} + +/** + * Build weight-based wagon plan for BULK bookings. + */ +export function buildBulkWagonPlan( + bookings: Booking[], + wagonType: WagonType, +): WagonPlanSlot[] { + const totalWeight = roundTons( + bookings.reduce((sum, b) => sum + Number(b.cargoTotalWeightVgm ?? 0), 0), + ); + const capacity = Number(wagonType.capacityTons); + const slots = Math.max(1, Math.ceil(totalWeight / capacity)); + + const basePlan: WagonPlanSlot[] = Array.from({ length: slots }, (_, index) => ({ + sequenceNo: index + 1, + wagonTypeId: wagonType.id, + wagonTypeCode: wagonType.code, + capacityTons: capacity, + lengthMeters: Number(wagonType.lengthMeters), + assignedWeightTons: 0, + allocations: [], + })); + + return allocateBookingsToSlots(bookings, basePlan, AllocationLoadType.Bulk).map((slot) => ({ + ...slot, + slotLoadType: 'BULK' as SlotLoadType, + })); +} + +/** + * Build a mixed consist: container slots first, then bulk slots, with unified sequence numbers. + */ +export function buildMixedWagonPlan( + containerBookings: Booking[], + bulkBookings: Booking[], + containerWagonType: WagonType, + bulkWagonType: WagonType, +): WagonPlanSlot[] { + const containerPlan = containerBookings.length + ? buildContainerWagonPlan(containerBookings, containerWagonType) + : []; + const bulkPlan = bulkBookings.length + ? buildBulkWagonPlan(bulkBookings, bulkWagonType) + : []; + + const tagged: WagonPlanSlot[] = [ + ...containerPlan.map((slot) => ({ ...slot, slotLoadType: 'CONTAINER' as SlotLoadType })), + ...bulkPlan.map((slot) => ({ ...slot, slotLoadType: 'BULK' as SlotLoadType })), + ]; + + if (!tagged.length) { + return [ + { + sequenceNo: 1, + wagonTypeId: containerWagonType.id, + wagonTypeCode: containerWagonType.code, + capacityTons: Number(containerWagonType.capacityTons), + lengthMeters: Number(containerWagonType.lengthMeters), + assignedWeightTons: 0, + allocations: [], + slotLoadType: 'CONTAINER', + }, + ]; + } + + return tagged.map((slot, index) => ({ + ...slot, + sequenceNo: index + 1, + })); +} + +export function expandBookingContainerUnits(bookings: Booking[]): ContainerUnitRow[] { + const rows: ContainerUnitRow[] = []; + + for (const booking of bookings.filter((b) => b.freightType === 'CONTAINER')) { + for (const line of booking.bookingContainers ?? []) { + const qty = Number(line.quantity ?? 0); + const code = line.containerType?.code ?? line.containerType?.label ?? 'Container'; + const sizeFt = Number(line.containerType?.sizeFt ?? (code.includes('40') ? 40 : 20)); + const wagonsPerUnit = Number(line.containerType?.wagonsPerUnit ?? (sizeFt >= 40 ? 1 : 0.5)); + const perWagon = containersPerWagonFromType(wagonsPerUnit); + const teuSlots = teuSlotsForSizeFt(sizeFt); + for (let i = 0; i < qty; i += 1) { + rows.push({ + bookingId: booking.id, + bookingReference: booking.reference, + bookingContainerId: line.id, + unitIndex: i, + containerTypeId: line.containerTypeId ?? '', + containerTypeCode: code, + label: `${booking.reference} · ${i + 1}/${qty} · ${code}`, + grossWeightTons: Number(line.vgmPerUnitTons), + sizeFt, + wagonsPerUnit, + containersPerWagon: perWagon, + teuSlots, + }); + } + } + } + + return rows; +} + +export function getContainerSlotSequenceNos(wagonPlan: WagonPlanSlot[]): number[] { + return wagonPlan + .filter((slot) => slot.slotLoadType === 'CONTAINER' || slot.allocations.some( + (a) => a.loadType === AllocationLoadType.Container, + )) + .map((slot) => slot.sequenceNo); +} + +function allocateBookingsToSlots( + bookings: Booking[], + basePlan: WagonPlanSlot[], + loadType: AllocationLoadType, +): WagonPlanSlot[] { + const remaining = bookings.map((booking) => ({ + bookingId: booking.id, + bookingReference: booking.reference, + remainingWeightTons: roundTons(Number(booking.cargoTotalWeightVgm ?? 0)), + })); + + let bookingIndex = 0; + + return basePlan.map((slot) => { + let wagonRemaining = roundTons(slot.capacityTons); + const allocations: WagonAllocationRecord[] = []; + let assignedWeightTons = 0; + + while (wagonRemaining > 0 && bookingIndex < remaining.length) { + const booking = remaining[bookingIndex]; + const allocatedWeightTons = roundTons( + Math.min(wagonRemaining, booking.remainingWeightTons), + ); + + if (allocatedWeightTons <= 0) { + bookingIndex += 1; + continue; + } + + allocations.push({ + bookingId: booking.bookingId, + bookingReference: booking.bookingReference, + allocatedWeightTons, + loadType, + }); + + booking.remainingWeightTons = roundTons( + booking.remainingWeightTons - allocatedWeightTons, + ); + wagonRemaining = roundTons(wagonRemaining - allocatedWeightTons); + assignedWeightTons = roundTons(assignedWeightTons + allocatedWeightTons); + + if (booking.remainingWeightTons <= 0) { + bookingIndex += 1; + } + } + + return { ...slot, assignedWeightTons, allocations }; + }); +} + +export function expandContainerItems( + booking: Booking, + allocationId: string, +): Array<{ + wagonBookingAllocationId: string; + bookingContainerId: string; + containerTypeId: string; + grossWeightTons: number; + positionOnWagon: number | null; +}> { + const items: Array<{ + wagonBookingAllocationId: string; + bookingContainerId: string; + containerTypeId: string; + grossWeightTons: number; + positionOnWagon: number | null; + }> = []; + + for (const line of booking.bookingContainers ?? []) { + const qty = Number(line.quantity ?? 0); + for (let i = 0; i < qty; i += 1) { + items.push({ + wagonBookingAllocationId: allocationId, + bookingContainerId: line.id, + containerTypeId: line.containerTypeId ?? '', + grossWeightTons: Number(line.vgmPerUnitTons), + positionOnWagon: qty > 1 ? i + 1 : null, + }); + } + } + + return items; +} + +export function sumWagonsRequired(booking: Booking): number { + if (booking.freightType === 'BULK') { + return 1; + } + return (booking.bookingContainers ?? []).reduce( + (sum, line) => sum + Number(line.wagonsRequired ?? 0), + 0, + ); +} + +export function validateBulkWagonSlotWeights(wagonPlan: WagonPlanSlot[]): string[] { + const violations: string[] = []; + for (const slot of wagonPlan.filter((s) => s.slotLoadType === 'BULK')) { + if (slot.assignedWeightTons > slot.capacityTons) { + violations.push( + `Bulk wagon #${slot.sequenceNo} load ${slot.assignedWeightTons}T exceeds capacity ${slot.capacityTons}T`, + ); + } + } + return violations; +} + +export function validateTrainLimits( + wagonPlan: WagonPlanSlot[], + wagonType: WagonType, + limits?: TrainLimitConfig, +): string[] { + const violations: string[] = []; + const maxWeightTons = limits?.maxWeightTons ?? MAX_TRAIN_WEIGHT_TONS; + const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS; + const maxWagonsPerTrain = + limits?.maxWagonsPerTrain ?? Number(wagonType.maxWagonsPerTrain ?? 53); + + const totalWeightTons = roundTons( + wagonPlan.reduce((sum, w) => sum + w.assignedWeightTons, 0), + ); + const totalLengthMeters = roundTons( + wagonPlan.reduce((sum, w) => sum + w.lengthMeters, 0), + ); + + if (totalWeightTons > maxWeightTons) { + violations.push( + `Total booking weight ${totalWeightTons}T exceeds max train weight ${maxWeightTons}T`, + ); + } + if (totalLengthMeters > maxLengthMeters) { + violations.push( + `Total wagon length ${totalLengthMeters}m exceeds max train length ${maxLengthMeters}m`, + ); + } + if (wagonPlan.length > maxWagonsPerTrain) { + violations.push( + `Wagon count ${wagonPlan.length} exceeds max wagons per train (${maxWagonsPerTrain})`, + ); + } + + violations.push(...validateBulkWagonSlotWeights(wagonPlan)); + + return violations; +} + +export function validateMixedTrainLimits( + wagonPlan: WagonPlanSlot[], + wagonTypes: WagonType[], + limits?: TrainLimitConfig, +): string[] { + const maxWagonsPerTrain = + limits?.maxWagonsPerTrain ?? + Math.max(...wagonTypes.map((wt) => Number(wt.maxWagonsPerTrain ?? 53)), 53); + + return validateTrainLimits( + wagonPlan, + { maxWagonsPerTrain } as WagonType, + { ...limits, maxWagonsPerTrain }, + ); +} + +export function validate20ftContainerRules( + units: ContainerUnitRow[], + placements: ContainerPlacementInput[], + rules?: ContainerPlacementRules, +): string[] { + const violations: string[] = []; + const maxEach = rules?.max20ftContainerWeightTons; + const maxDiff = rules?.max20ftPairWeightDiffTons; + if (maxEach == null && maxDiff == null) return violations; + + const placementByUnit = new Map( + placements.map((p) => [`${p.bookingContainerId}:${p.unitIndex}`, p]), + ); + + const weightsBySlot = new Map(); + + for (const unit of units) { + const sizeFt = unit.sizeFt ?? (unit.containerTypeCode.includes('40') ? 40 : 20); + if (sizeFt >= 40) continue; + + if (maxEach != null && unit.grossWeightTons > maxEach) { + violations.push( + `${unit.label} weight ${unit.grossWeightTons}T exceeds max ${maxEach}T for 20ft containers`, + ); + } + + const placement = placementByUnit.get(`${unit.bookingContainerId}:${unit.unitIndex}`); + if (!placement?.sequenceNo) continue; + + const list = weightsBySlot.get(placement.sequenceNo) ?? []; + list.push(unit.grossWeightTons); + weightsBySlot.set(placement.sequenceNo, list); + } + + if (maxDiff != null) { + for (const [sequenceNo, weights] of weightsBySlot.entries()) { + if (weights.length < 2) continue; + const diff = Math.abs(weights[0]! - weights[1]!); + if (diff > maxDiff) { + violations.push( + `Wagon #${sequenceNo} 20ft pair weight difference ${roundTons(diff)}T exceeds max ${maxDiff}T`, + ); + } + } + } + + return violations; +} + +export function validateContainerPlacements( + containerBookings: Booking[], + wagonPlan: WagonPlanSlot[], + placements: ContainerPlacementInput[], + rules?: ContainerPlacementRules, +): string[] { + const violations: string[] = []; + const units = expandBookingContainerUnits(containerBookings); + if (!units.length) return violations; + + const containerSlots = new Set(getContainerSlotSequenceNos(wagonPlan)); + const unitKeys = new Set(units.map((u) => `${u.bookingContainerId}:${u.unitIndex}`)); + const placementKeys = new Set(); + const containerNumbers = new Set(); + + if (!placements.length) { + violations.push('Container placements are required for container bookings'); + return violations; + } + + for (const placement of placements) { + const unitKey = `${placement.bookingContainerId}:${placement.unitIndex}`; + if (!unitKeys.has(unitKey)) { + violations.push( + `Unknown container unit ${placement.bookingContainerId}#${placement.unitIndex}`, + ); + continue; + } + if (placementKeys.has(unitKey)) { + violations.push(`Duplicate placement for container unit ${unitKey}`); + } + placementKeys.add(unitKey); + + if (!containerSlots.has(placement.sequenceNo)) { + violations.push(`Slot #${placement.sequenceNo} is not a container wagon slot`); + } + + const hasInventory = Boolean(placement.containerId); + const hasManual = Boolean(placement.containerNumber?.trim()); + if (!hasInventory && !hasManual) { + violations.push( + `Container unit ${unitKey} requires an existing container or a new container number`, + ); + } + + if (hasManual) { + const normalized = placement.containerNumber!.trim().toUpperCase(); + if (containerNumbers.has(normalized)) { + violations.push(`Duplicate container number ${normalized}`); + } + containerNumbers.add(normalized); + } + } + + for (const unit of units) { + const unitKey = `${unit.bookingContainerId}:${unit.unitIndex}`; + if (!placementKeys.has(unitKey)) { + violations.push(`Missing placement for ${unit.label}`); + } + } + + const slotTeuUsed = new Map(); + const slotWeightUsed = new Map(); + const slotBySeq = new Map(wagonPlan.map((s) => [s.sequenceNo, s])); + + for (const placement of placements) { + const unit = units.find( + (u) => + u.bookingContainerId === placement.bookingContainerId && + u.unitIndex === placement.unitIndex, + ); + if (!unit) continue; + + const teu = unit.teuSlots ?? teuSlotsForSizeFt(unit.sizeFt ?? 20); + const usedTeu = slotTeuUsed.get(placement.sequenceNo) ?? 0; + if (usedTeu + teu > MAX_TEU_SLOTS_PER_WAGON) { + violations.push( + `Wagon #${placement.sequenceNo} cannot fit another ${unit.containerTypeCode} (max 1×40ft or 2×20ft per wagon)`, + ); + } else { + slotTeuUsed.set(placement.sequenceNo, usedTeu + teu); + } + + const slot = slotBySeq.get(placement.sequenceNo); + if (slot) { + const weight = roundTons(slotWeightUsed.get(placement.sequenceNo) ?? 0) + unit.grossWeightTons; + slotWeightUsed.set(placement.sequenceNo, weight); + if (weight > slot.capacityTons) { + violations.push( + `Wagon #${placement.sequenceNo} total container weight ${weight}T exceeds capacity ${slot.capacityTons}T`, + ); + } + } + } + + violations.push(...validate20ftContainerRules(units, placements, rules)); + + return violations; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-readiness.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-readiness.util.spec.ts new file mode 100644 index 000000000..b39c12e89 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-readiness.util.spec.ts @@ -0,0 +1,35 @@ +import { WagonReadiness } from '@edr/types'; + +import { + requiredWagonReadiness, + wagonReadinessMatchesSchedule, +} from './wagon-readiness.util'; + +describe('wagonReadinessMatchesSchedule', () => { + it('requires IMPORT_READY for IMPORT schedules', () => { + expect(requiredWagonReadiness('IMPORT')).toBe(WagonReadiness.ImportReady); + expect( + wagonReadinessMatchesSchedule(WagonReadiness.ImportReady, 'IMPORT'), + ).toBe(true); + expect( + wagonReadinessMatchesSchedule(WagonReadiness.ExportReady, 'IMPORT'), + ).toBe(false); + }); + + it('requires EXPORT_READY for EXPORT schedules', () => { + expect(requiredWagonReadiness('EXPORT')).toBe(WagonReadiness.ExportReady); + expect( + wagonReadinessMatchesSchedule(WagonReadiness.ExportReady, 'EXPORT'), + ).toBe(true); + expect( + wagonReadinessMatchesSchedule(WagonReadiness.ImportReady, 'EXPORT'), + ).toBe(false); + }); + + it('allows any readiness for DOMESTIC schedules', () => { + expect(requiredWagonReadiness('DOMESTIC')).toBeNull(); + expect( + wagonReadinessMatchesSchedule(WagonReadiness.ExportReady, 'DOMESTIC'), + ).toBe(true); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-readiness.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-readiness.util.ts new file mode 100644 index 000000000..3cdd717d8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-readiness.util.ts @@ -0,0 +1,18 @@ +import { WagonReadiness, type ScheduleTradeDirection } from '@edr/types'; + +export function requiredWagonReadiness( + direction: ScheduleTradeDirection | string | null | undefined, +): WagonReadiness | null { + if (direction === 'IMPORT') return WagonReadiness.ImportReady; + if (direction === 'EXPORT') return WagonReadiness.ExportReady; + return null; +} + +export function wagonReadinessMatchesSchedule( + wagonReadiness: WagonReadiness | string, + direction: ScheduleTradeDirection | string | null | undefined, +): boolean { + const required = requiredWagonReadiness(direction); + if (!required) return true; + return wagonReadiness === required; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-type-resolver.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-type-resolver.util.ts new file mode 100644 index 000000000..bac0330f2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-type-resolver.util.ts @@ -0,0 +1,49 @@ +import { WagonType } from '../wagon-types/entities/wagon-type.entity'; + +const CARGO_CODE_TO_WAGON_TYPE: Record = { + COFFEE: 'KW2', + GRAIN: 'KW2', + WHEAT: 'KW2', + SORGHUM: 'KW2', + CORN: 'KW2', + FERTILIZER: 'PW2', + SUGAR: 'PW2', + COAL: 'KW3', + STEEL: 'CW3', + ORE: 'CW3', +}; + +const DEFAULT_BULK_WAGON_TYPE = 'CW3'; +const DEFAULT_CONTAINER_WAGON_TYPE = 'NW5'; + +/** + * Resolve wagon type code from cargo type code for bulk freight. + */ +export function resolveBulkWagonTypeCode(cargoTypeCode?: string | null): string { + if (!cargoTypeCode) return DEFAULT_BULK_WAGON_TYPE; + const normalized = cargoTypeCode.trim().toUpperCase(); + return CARGO_CODE_TO_WAGON_TYPE[normalized] ?? DEFAULT_BULK_WAGON_TYPE; +} + +/** + * Pick the best matching wagon type entity for bulk cargo. + */ +export function pickBulkWagonType( + wagonTypes: WagonType[], + cargoTypeCode?: string | null, +): WagonType | undefined { + const preferredCode = resolveBulkWagonTypeCode(cargoTypeCode); + const direct = wagonTypes.find((wt) => wt.code === preferredCode && wt.isActive); + if (direct) return direct; + + return wagonTypes.find( + (wt) => + wt.isActive && + !wt.supportsContainer && + wt.code !== DEFAULT_CONTAINER_WAGON_TYPE, + ); +} + +export function getDefaultContainerWagonTypeCode(): string { + return DEFAULT_CONTAINER_WAGON_TYPE; +} diff --git a/apps/edr-freight-api/src/modules/train-sets/entities/train-set-wagon.entity.ts b/apps/edr-freight-api/src/modules/train-sets/entities/train-set-wagon.entity.ts index 780bc1977..455eb405d 100644 --- a/apps/edr-freight-api/src/modules/train-sets/entities/train-set-wagon.entity.ts +++ b/apps/edr-freight-api/src/modules/train-sets/entities/train-set-wagon.entity.ts @@ -1,10 +1,21 @@ import { BaseEntity } from '@edr/api-common'; +import { TrainSetWagonStatus } from '@edr/types'; import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity'; +import { Wagon } from '../../wagons/entities/wagon.entity'; import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; import { TrainSet } from './train-set.entity'; +export const TRAIN_SET_WAGON_STATUSES = [ + TrainSetWagonStatus.Planned, + TrainSetWagonStatus.Reserved, + TrainSetWagonStatus.Loaded, + TrainSetWagonStatus.Departed, +] as const; + +export type TrainSetWagonStatusType = (typeof TRAIN_SET_WAGON_STATUSES)[number]; + @Entity({ schema: 'freight', name: 'train_set_wagons' }) @Index(['trainSetId', 'sequenceNo'], { unique: true }) export class TrainSetWagon extends BaseEntity { @@ -34,6 +45,16 @@ export class TrainSetWagon extends BaseEntity { @Column({ name: 'assigned_weight_tons', type: 'numeric', precision: 10, scale: 3, default: 0 }) assignedWeightTons!: number; + @Column({ name: 'physical_wagon_id', type: 'uuid', nullable: true }) + physicalWagonId?: string | null; + + @ManyToOne(() => Wagon, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'physical_wagon_id' }) + physicalWagon?: Wagon | null; + + @Column({ name: 'status', type: 'varchar', length: 20, default: 'PLANNED' }) + status!: string; + @OneToMany(() => WagonBookingAllocation, (allocation) => allocation.trainSetWagon) allocations?: WagonBookingAllocation[]; } diff --git a/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts b/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts index 184b9c88d..ab6b49b1d 100644 --- a/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts +++ b/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts @@ -4,6 +4,10 @@ import { Freight } from '@edr/types'; import { Column, Entity, OneToMany } from 'typeorm'; import { Wagon } from '../../wagons/entities/wagon.entity'; +/** + * Fleet master data — named wagon consist in inventory (POST /trains). + * Operational departures use train_schedules + locomotives; scheduling never creates trains rows. + */ @Entity({ schema: 'freight', name: 'trains' }) export class Train extends BaseEntity { // --- existing fields (keep for backward compatibility) --- diff --git a/apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts b/apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts index f1bfeedea..2181a2bd1 100644 --- a/apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts +++ b/apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts @@ -28,6 +28,18 @@ export class WagonType extends BaseEntity { @Column({ name: 'is_active', type: 'boolean', default: true }) isActive!: boolean; + @Column({ name: 'equated_length_m', type: 'numeric', precision: 10, scale: 3, nullable: true }) + equatedLengthM?: number | null; + + @Column({ name: 'tare_weight_tons', type: 'numeric', precision: 10, scale: 3, nullable: true }) + tareWeightTons?: number | null; + + @Column({ name: 'supports_container', type: 'boolean', default: false }) + supportsContainer!: boolean; + + @Column({ name: 'max_container_gross_t', type: 'numeric', precision: 10, scale: 3, nullable: true }) + maxContainerGrossT?: number | null; + @OneToMany(() => TrainSetWagon, (wagon) => wagon.wagonType) trainSetWagons?: TrainSetWagon[]; } diff --git a/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts index c3108d68b..06072ae02 100644 --- a/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts +++ b/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts @@ -1,4 +1,5 @@ -import { IsString, IsUUID, IsOptional, IsInt, Min, IsNumber, IsIn } from 'class-validator'; +import { WagonReadiness, WagonStatus } from '@edr/types'; +import { IsString, IsUUID, IsOptional, IsInt, Min, IsNumber, IsEnum } from 'class-validator'; export class CreateWagonDto { @IsString() @@ -25,10 +26,14 @@ export class CreateWagonDto { maxPayloadWeight!: number; @IsOptional() - @IsIn(['AVAILABLE', 'ASSIGNED', 'MAINTENANCE', 'RETIRED']) - status?: string; + @IsEnum(WagonStatus) + status?: WagonStatus; + + @IsOptional() + @IsEnum(WagonReadiness) + readiness?: WagonReadiness; @IsOptional() @IsString() notes?: string; -} \ No newline at end of file +} diff --git a/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts index cdff14330..e2c33c66f 100644 --- a/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts +++ b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts @@ -1,10 +1,29 @@ // apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts -import { Entity, Column, ManyToOne, OneToMany, JoinColumn } from 'typeorm'; +import { WagonReadiness, WagonStatus } from '@edr/types'; +import { Entity, Column, ManyToOne, OneToMany, JoinColumn, Index } from 'typeorm'; import { BaseEntity } from '@edr/api-common'; import { Train } from '../../trains/entities/train.entity'; +import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; +import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity'; import { Container } from '../../container-management/entities/container.entity'; +export const WAGON_STATUSES = [ + WagonStatus.Available, + WagonStatus.Assigned, + WagonStatus.Maintenance, + WagonStatus.Retired, +] as const; + +export const WAGON_READINESS_VALUES = [ + WagonReadiness.ImportReady, + WagonReadiness.ExportReady, +] as const; + +export type WagonStatusType = (typeof WAGON_STATUSES)[number]; +export type WagonReadinessType = (typeof WAGON_READINESS_VALUES)[number]; + @Entity({ name: 'wagons', schema: 'freight' }) +@Index(['readiness']) export class Wagon extends BaseEntity { @Column({ unique: true, name: 'wagon_number' }) wagonNumber!: string; @@ -24,13 +43,30 @@ export class Wagon extends BaseEntity { @Column({ name: 'max_payload_weight', type: 'decimal', precision: 10, scale: 2 }) maxPayloadWeight!: number; - @Column({ type: 'varchar', default: 'AVAILABLE' }) - status!: string; // AVAILABLE, ASSIGNED, MAINTENANCE, RETIRED + @Column({ type: 'varchar', length: 20, default: WagonStatus.Available }) + status!: WagonStatusType; + + @Column({ type: 'varchar', length: 20, default: WagonReadiness.ImportReady }) + readiness!: WagonReadinessType; @Column({ type: 'text', nullable: true }) notes!: string | null; - // Relationship to Train + @Column({ name: 'train_set_wagon_id', type: 'uuid', nullable: true }) + trainSetWagonId!: string | null; + + @ManyToOne(() => TrainSetWagon, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'train_set_wagon_id' }) + trainSetWagon?: TrainSetWagon | null; + + @Column({ name: 'current_train_schedule_id', type: 'uuid', nullable: true }) + currentTrainScheduleId!: string | null; + + @ManyToOne(() => TrainSchedule, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'current_train_schedule_id' }) + currentTrainSchedule?: TrainSchedule | null; + + /** Fleet master consist grouping — separate from operational train_schedules. */ @ManyToOne(() => Train, (train) => train.wagons, { onDelete: 'SET NULL' }) @JoinColumn({ name: 'train_id' }) train!: Train | null; diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts index bc0b52a69..10681d4d0 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -1,3 +1,4 @@ +import { WagonReadiness, WagonStatus } from '@edr/types'; import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike } from 'typeorm'; @@ -19,7 +20,11 @@ export class WagonsService { ) {} async create(dto: CreateWagonDto): Promise { - const wagon = this.wagonRepo.create(dto); + const wagon = this.wagonRepo.create({ + ...dto, + status: dto.status ?? WagonStatus.Available, + readiness: dto.readiness ?? WagonReadiness.ImportReady, + }); // Convert undefined to null for nullable fields if (dto.trainId === undefined) wagon.trainId = null; if (dto.sequenceNumber === undefined) wagon.sequenceNumber = null; @@ -30,23 +35,28 @@ export class WagonsService { const where: FindOptionsWhere[] | FindOptionsWhere = []; const search = query.search?.trim(); const status = query.status?.trim(); + const readiness = query.readiness?.trim(); const trainId = query.trainId?.trim(); + const filters = { + ...(status ? { status: status as Wagon['status'] } : {}), + ...(readiness ? { readiness: readiness as Wagon['readiness'] } : {}), + ...(trainId ? { trainId } : {}), + }; if (search) { where.push({ wagonNumber: ILike(`%${search}%`), - ...(status ? { status } : {}), - ...(trainId ? { trainId } : {}), + ...filters, }); } - const sortBy = ['wagonNumber', 'tareWeight', 'maxPayloadWeight', 'status', 'sequenceNumber'].includes(query.sortBy ?? '') + const sortBy = ['wagonNumber', 'tareWeight', 'maxPayloadWeight', 'status', 'readiness', 'sequenceNumber'].includes(query.sortBy ?? '') ? (query.sortBy as keyof Wagon) : 'wagonNumber'; const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; return this.wagonRepo.find({ - where: search ? where : { ...(status ? { status } : {}), ...(trainId ? { trainId } : {}) }, + where: search ? where : filters, order: { [sortBy]: sortOrder } as FindOptionsOrder, skip: query.page && query.limit ? (Number(query.page) - 1) * Number(query.limit) : undefined, take: query.limit ? Number(query.limit) : undefined, @@ -72,7 +82,7 @@ export class WagonsService { async assignToTrain(wagonId: string, dto: AssignWagonToTrainDto): Promise { const wagon = await this.findById(wagonId); - if (wagon.status === 'ASSIGNED') { + if (wagon.status === WagonStatus.Assigned) { throw new ConflictException('Wagon already assigned to a train'); } @@ -91,7 +101,7 @@ export class WagonsService { wagon.trainId = train.id; wagon.sequenceNumber = sequence; - wagon.status = 'ASSIGNED'; + wagon.status = WagonStatus.Assigned; return this.wagonRepo.save(wagon); } @@ -99,7 +109,7 @@ export class WagonsService { const wagon = await this.findById(wagonId); wagon.trainId = null; wagon.sequenceNumber = null; - wagon.status = 'AVAILABLE'; + wagon.status = WagonStatus.Available; return this.wagonRepo.save(wagon); } diff --git a/apps/edr-freight-api/src/scripts/seed-demo-scheduling.ts b/apps/edr-freight-api/src/scripts/seed-demo-scheduling.ts new file mode 100644 index 000000000..d9ae15f2d --- /dev/null +++ b/apps/edr-freight-api/src/scripts/seed-demo-scheduling.ts @@ -0,0 +1,29 @@ +import 'reflect-metadata'; +import { config } from 'dotenv'; +import { resolve } from 'path'; + +config({ path: resolve(__dirname, '../../.env') }); +process.env.SEED_DEMO_BOOKINGS = 'true'; + +import { NestFactory } from '@nestjs/core'; +import { AppModule } from '../app.module'; +import { DemoBookingsSeeder } from '../seed/demo-bookings.seeder'; + +async function main() { + const app = await NestFactory.createApplicationContext(AppModule, { + logger: ['error', 'warn', 'log'], + }); + + try { + const seeder = app.get(DemoBookingsSeeder); + await seeder.run(); + console.log('Demo train scheduling data seeded successfully.'); + } finally { + await app.close(); + } +} + +main().catch((err) => { + console.error('Demo scheduling seed failed:', err); + process.exit(1); +}); diff --git a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts index edf19adbb..23c5862ac 100644 --- a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts @@ -13,7 +13,13 @@ import { Locomotive } from "../modules/locomotives/entities/locomotive.entity"; import { ServiceType } from "../modules/rule-engine/entities/service-type.entity"; import { Yard } from "../modules/rule-engine/entities/yard.entity"; import { WagonType } from "../modules/wagon-types/entities/wagon-type.entity"; +import { CargoType } from "../modules/rule-engine/entities/cargo-type.entity"; import { ContainerType } from "../modules/rule-engine/entities/container-type.entity"; +import { Container } from "../modules/container-management/entities/container.entity"; +import { Route } from "../modules/routes/entities/route.entity"; +import { RouteMilestone } from "../modules/routes/entities/route-milestone.entity"; +import { Wagon } from "../modules/wagons/entities/wagon.entity"; +import { WagonReadiness, WagonStatus } from "@edr/types"; const SEED_FLAG = "SEED_DEMO_BOOKINGS"; @@ -144,6 +150,39 @@ const DEMO_BOOKINGS = [ }, ]; +const DEMO_BULK_BOOKINGS = [ + { + reference: "BKG-BULK-001", + cargoCode: "COFFEE", + totalWeightTons: 1200, + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-20T08:00:00.000Z", + status: "PAID", + paymentStatus: "PAID", + }, + { + reference: "BKG-BULK-002", + cargoCode: "FERTILIZER", + totalWeightTons: 800, + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-20T08:00:00.000Z", + status: "PAID", + paymentStatus: "PAID", + }, + { + reference: "BKG-BULK-003", + cargoCode: "STEEL", + totalWeightTons: 450, + originCode: "ADDIS_ABABA", + destinationCode: "DIRE_DAWA", + scheduledDate: "2026-06-20T08:00:00.000Z", + status: "PAID", + paymentStatus: "PAID", + }, +]; + @Injectable() export class DemoBookingsSeeder { private readonly logger = new Logger(DemoBookingsSeeder.name); @@ -161,15 +200,57 @@ export class DemoBookingsSeeder { await this.dataSource.transaction(async (manager) => { await manager.getRepository(WagonType).upsert( - { - code: "NW5", - name: "Flat Wagon", - capacityTons: 70, - lengthMeters: 14, - maxWagonsPerTrain: 53, - supportedLoadTypes: ["CONTAINER"], - isActive: true, - }, + [ + { + code: "NW5", + name: "Flat Wagon", + capacityTons: 70, + lengthMeters: 14, + maxWagonsPerTrain: 53, + supportedLoadTypes: ["CONTAINER"], + isActive: true, + equatedLengthM: 14, + tareWeightTons: 20, + supportsContainer: true, + maxContainerGrossT: 70, + }, + { + code: "KW2", + name: "Covered Hopper", + capacityTons: 60, + lengthMeters: 12, + maxWagonsPerTrain: 55, + supportedLoadTypes: ["BULK"], + isActive: true, + equatedLengthM: 12, + tareWeightTons: 18, + supportsContainer: false, + }, + { + code: "PW2", + name: "Powder Wagon", + capacityTons: 55, + lengthMeters: 12, + maxWagonsPerTrain: 55, + supportedLoadTypes: ["BULK"], + isActive: true, + equatedLengthM: 12, + tareWeightTons: 17, + supportsContainer: false, + }, + { + code: "CW3", + name: "Open Wagon", + capacityTons: 65, + lengthMeters: 13, + maxWagonsPerTrain: 53, + supportedLoadTypes: ["BULK"], + isActive: true, + equatedLengthM: 13, + tareWeightTons: 19, + supportsContainer: false, + }, + ], { conflictPaths: { code: true } }, ); @@ -320,6 +401,9 @@ export class DemoBookingsSeeder { await manager .getRepository(BookingContainer) .delete({ bookingId: booking.id }); + const wagonsRequired = + Number(demoBooking.quantity) * Number(containerType.wagonsPerUnit ?? 1); + await manager.getRepository(BookingContainer).insert({ id: randomUUID(), bookingId: booking.id, @@ -327,15 +411,139 @@ export class DemoBookingsSeeder { quantity: demoBooking.quantity, vgmPerUnitTons, totalVgmTons: demoBooking.totalWeightTons, - wagonsRequired: Math.ceil(demoBooking.totalWeightTons / 70), + wagonsRequired, weightLimitRuleId: null, - isOverweight: demoBooking.totalWeightTons > 70, - overweightExcessTons: - demoBooking.totalWeightTons > 70 - ? demoBooking.totalWeightTons - 70 - : null, + isOverweight: vgmPerUnitTons > 35, + overweightExcessTons: vgmPerUnitTons > 35 ? vgmPerUnitTons - 35 : null, }); } + + await manager.getRepository(CargoType).upsert( + [ + { code: "COFFEE", cargoTypeName: "Coffee", isActive: true, displayOrder: 1 }, + { code: "FERTILIZER", cargoTypeName: "Fertilizer", isActive: true, displayOrder: 2 }, + { code: "STEEL", cargoTypeName: "Steel", isActive: true, displayOrder: 3 }, + ], + { conflictPaths: { code: true } }, + ); + + const cargoTypes = await manager.getRepository(CargoType).find(); + const cargoByCode = new Map(cargoTypes.map((c) => [c.code, c])); + + for (const demoBulk of DEMO_BULK_BOOKINGS) { + const origin = yardByCode.get(demoBulk.originCode); + const destination = yardByCode.get(demoBulk.destinationCode); + const cargoType = cargoByCode.get(demoBulk.cargoCode); + + if (!origin || !destination || !cargoType) { + throw new Error(`demo_bulk_seed_dependency_missing:${demoBulk.reference}`); + } + + await manager.getRepository(Booking).upsert( + { + reference: demoBulk.reference, + companyId: company.id, + status: demoBulk.status, + scheduledDate: new Date(demoBulk.scheduledDate), + totalAmount: 0, + paymentStatus: demoBulk.paymentStatus, + contractType: "NEW", + serviceTypeId: serviceType.id, + equipmentReturn: "WITHOUT_RETURN", + originYardId: origin.id, + destinationYardId: destination.id, + tradeDirection: "IMPORT", + freightType: "BULK", + cargoTypeId: cargoType.id, + cargoFreeText: demoBulk.cargoCode, + shippingLineId: null, + cargoTotalWeightVgm: demoBulk.totalWeightTons, + isHazardous: false, + paymentCurrency: "USD", + allowConsolidation: false, + priorityScore: 10, + schedulingStatus: "HOLDING", + versionNumber: 1, + }, + { conflictPaths: { reference: true } }, + ); + } + + const djibouti = yardByCode.get("DJIBOUTI"); + const addis = yardByCode.get("ADDIS_ABABA"); + if (djibouti && addis) { + const routeName = "Djibouti → Addis Ababa"; + let route = await manager.getRepository(Route).findOneBy({ name: routeName }); + if (!route) { + route = await manager.getRepository(Route).save( + manager.getRepository(Route).create({ + name: routeName, + originYardId: djibouti.id, + destinationYardId: addis.id, + isActive: true, + }), + ); + await manager.getRepository(RouteMilestone).save([ + manager.getRepository(RouteMilestone).create({ + routeId: route.id, + yardId: djibouti.id, + sequenceNo: 1, + }), + manager.getRepository(RouteMilestone).create({ + routeId: route.id, + yardId: addis.id, + sequenceNo: 2, + }), + ]); + } + } + + const nw5 = await manager.getRepository(WagonType).findOneBy({ code: "NW5" }); + if (nw5) { + await manager.getRepository(Wagon).upsert( + Array.from({ length: 20 }, (_, index) => ({ + wagonNumber: `WGN-DEMO-${String(index + 1).padStart(3, "0")}`, + wagonTypeId: nw5.id, + trainId: null, + sequenceNumber: null, + tareWeight: 20, + maxPayloadWeight: 70, + status: WagonStatus.Available, + readiness: + index % 2 === 0 + ? WagonReadiness.ImportReady + : WagonReadiness.ExportReady, + notes: "Demo wagon for train scheduling", + trainSetWagonId: null, + currentTrainScheduleId: null, + })), + { conflictPaths: { wagonNumber: true } }, + ); + } + + const ft20 = containerTypeByCode.get("20FT"); + const ft40 = containerTypeByCode.get("40FT"); + if (ft20 && ft40) { + await manager.getRepository(Container).upsert( + Array.from({ length: 30 }, (_, index) => { + const is40Ft = index % 2 === 0; + return { + containerNumber: `CONT-DEMO-${String(index + 1).padStart(3, "0")}`, + containerTypeId: is40Ft ? ft40.id : ft20.id, + wagonId: null, + position: null, + tareWeight: is40Ft ? 4.0 : 2.5, + maxGrossWeight: is40Ft ? 32.5 : 24.5, + sealNumber: null, + status: "AVAILABLE", + bookingId: null, + wagonBookingAllocationId: null, + bookingContainerId: null, + }; + }), + { conflictPaths: { containerNumber: true } }, + ); + } }); this.logger.log("Seeded demo train scheduling data"); diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index e82032cff..14d599ab3 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -52,6 +52,8 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [ perm('a1000001-0001-4000-8000-00000000000c', 'edr_freight_app:bookings:payment_verify', 'Verify payment'), perm('a1000001-0001-4000-8000-00000000000d', 'edr_freight_app:bookings:operations', 'Booking operations'), perm('a1000001-0001-4000-8000-00000000000e', 'edr_freight_app:bookings:cancel', 'Cancel booking'), + perm('a1000001-0001-4000-8000-00000000000f', 'edr_freight_app:train_scheduling:view', 'View train scheduling'), + perm('a1000001-0001-4000-8000-000000000010', 'edr_freight_app:train_scheduling:manage', 'Manage train scheduling'), ]; const RULE_ENGINE_PERMISSION_IDS: Record = { @@ -103,6 +105,10 @@ export const FREIGHT_PERMS = { operations: 'edr_freight_app:bookings:operations', cancel: 'edr_freight_app:bookings:cancel', }, + trainScheduling: { + view: 'edr_freight_app:train_scheduling:view', + manage: 'edr_freight_app:train_scheduling:manage', + }, ruleEngine: { view: (slug: RuleEngineResourceSlug) => `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`, @@ -123,6 +129,8 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.approveLineStaff, FREIGHT_PERMS.bookings.rejectApproval, FREIGHT_PERMS.bookings.cancel, + FREIGHT_PERMS.trainScheduling.view, + FREIGHT_PERMS.trainScheduling.manage, ...allRuleEngineViewKeys(), ], director: [ diff --git a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts index fb8fbf2e6..882674862 100644 --- a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts @@ -115,7 +115,7 @@ export class PricingDataSeeder { code: "20FT", label: "20FT Standard", sizeFt: 20, - wagonsPerUnit: 1, + wagonsPerUnit: 0.5, isReefer: false, isOpenTop: false, isActive: true, @@ -135,7 +135,7 @@ export class PricingDataSeeder { code: "20FT_REEFER", label: "20FT Reefer", sizeFt: 20, - wagonsPerUnit: 1, + wagonsPerUnit: 0.5, isReefer: true, isOpenTop: false, isActive: true, @@ -323,7 +323,11 @@ export class PricingDataSeeder { private async seedPriorityRules(prRepo: any): Promise { const existing = await prRepo.find({ - where: [{ code: "USD_PRIORITY" }, { code: "STANDARD_PRIORITY" }], + where: [ + { code: "USD_PRIORITY" }, + { code: "STANDARD_PRIORITY" }, + { code: "GOVERNMENT_ACCOUNT" }, + ], }); for (const r of existing) { await prRepo.remove(r); @@ -343,6 +347,13 @@ export class PricingDataSeeder { conditionCurrency: null, isActive: true, }), + prRepo.create({ + code: "GOVERNMENT_ACCOUNT", + label: "Government Account Priority", + score: 50000, + conditionCurrency: null, + isActive: true, + }), ]); this.logger.log("Seeded priority rules"); } diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index ea724b31e..4c4c876a9 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -21,6 +21,7 @@ import LoginPage from "./pages/auth/LoginPage"; import BookingContractPage from "./pages/bookings/BookingContractPage"; import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage"; import BookingRequestsPage from "./pages/bookings/BookingRequestsPage"; +import NewBookingPage from "./pages/bookings/NewBookingPage"; import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page"; import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page"; import OverviewPage from "./pages/dashboard/OverviewPage"; @@ -35,13 +36,10 @@ import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; import TrainsPage from "./pages/trains/TrainsPage"; -import { - CargoesCrudPage, - ContainersCrudPage, - LocomotivesCrudPage, - TrainMasterDataPage, - WagonsCrudPage, -} from "./pages/fleet/FleetCrudPages"; +import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage"; +import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage"; +import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; +import FleetResourcePage from "./pages/fleet/FleetResourcePage"; import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; import TrainDetailPage from "./pages/trains/TrainDetailPage"; import RoutesPage from "./pages/fleet/RoutesPage"; @@ -72,6 +70,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ href: "/dashboard/operations/train-scheduling", icon: , }, + { + label: "Train Schedules v2", + href: "/dashboard/operations/train-scheduling-v2", + icon: , + }, ], }, { @@ -159,7 +162,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Configuration", href: "/dashboard/configuration", icon: , - children: getCategorySidebarChildren("configuration"), + children: [ + ...getCategorySidebarChildren("configuration"), + { + label: "Train scheduling rules", + href: "/dashboard/configuration/train-scheduling-rules", + }, + ], }, { label: "Rules", @@ -235,21 +244,28 @@ const App = () => { } /> } /> + } /> } /> } /> } /> - } /> - } /> + } + /> + } + /> } /> - } /> - } /> + } /> + } /> } /> - } /> - } /> - } /> + } /> + } /> + } /> } /> } /> @@ -265,6 +281,10 @@ const App = () => { path="configuration" element={} /> + } + /> } /> void; + onAllocateBooking?: () => void; } export function BookingActionsMenu({ row, variant = "table", onSuppressRowClick, + onAllocateBooking, }: BookingActionsMenuProps) { const navigate = useNavigate(); const { user } = useAuth(); @@ -34,6 +37,7 @@ export function BookingActionsMenu({ paymentCurrency: row.paymentCurrency, reference: row.reference, approvalSteps: row.approvalSteps, + schedulingStatus: row.schedulingStatus, }; const flow = useBookingActionDialog(row.id, context); @@ -46,6 +50,8 @@ export function BookingActionsMenu({ onSuppressRowClick?.(); if (isContractNavAction(action.id)) { goToContract(); + } else if (isAllocateAction(action.id)) { + onAllocateBooking?.(); } else { flow.openAction(action); } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx index e451f2d2c..0c20184cf 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx @@ -1,10 +1,13 @@ +import { useState } from "react"; import { Download, Zap, FileText, Clock } from "lucide-react"; import { Stack, Text, Button } from "@mantine/core"; +import { AllocateBookingWizard } from "@/components/trainScheduling/AllocateBookingWizard"; import type { BookingDetail } from "@/types/booking"; import { BookingActionsMenu } from "./BookingActionsMenu"; import { SectionCard } from "./detail/SectionCard"; import { toBookingListRow } from "@/features/bookings/mapBookingListRow"; +import { canAllocateBooking } from "@/features/bookings/booking-actions.config"; import type { useBookingMutations } from "@/hooks/bookings/useBookings"; type Mutations = ReturnType; @@ -18,6 +21,7 @@ interface BookingActionsToolbarProps { export function BookingActionsToolbar({ booking, mutations }: BookingActionsToolbarProps) { const row = toBookingListRow(booking); const { status } = booking; + const [allocateOpen, setAllocateOpen] = useState(false); const downloadBlob = async (fn: () => Promise, filename: string) => { const blob = await fn(); @@ -98,7 +102,11 @@ export function BookingActionsToolbar({ booking, mutations }: BookingActionsTool Confirm each step before it is applied. - + setAllocateOpen(true)} + /> @@ -118,6 +126,14 @@ export function BookingActionsToolbar({ booking, mutations }: BookingActionsTool )} + + {canAllocateBooking(booking) ? ( + setAllocateOpen(false)} + /> + ) : null} ); } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/OperationsBookingQueue.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/OperationsBookingQueue.tsx new file mode 100644 index 000000000..abd787334 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/OperationsBookingQueue.tsx @@ -0,0 +1,225 @@ +import { useMemo, useState } from "react"; +import { ArrowRight, Building2, Package } from "lucide-react"; +import { + Accordion, + Badge, + Button, + Checkbox, + Group, + Paper, + Stack, + Text, + Title, +} from "@mantine/core"; + +import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge"; +import { canAllocateBooking } from "@/features/bookings/booking-actions.config"; +import type { BookingListRow } from "@/types/booking"; +import { groupBookingsForOperationsQueue } from "@/utils/groupBookingsForOperationsQueue"; + +function BookingQueueRow({ + booking, + selected, + disabled, + onToggle, +}: { + booking: BookingListRow; + selected: boolean; + disabled: boolean; + onToggle: () => void; +}) { + return ( + + + + + + {booking.reference} + {booking.isGovernment ? ( + }> + Government + + ) : null} + {booking.freightType} + {booking.schedulingStatus ? ( + {booking.schedulingStatus} + ) : null} + + {booking.customerLabel} + + {booking.originLabel} + + {booking.destinationLabel} + + + + {booking.serviceTypeLabel ? ( + + {booking.serviceTypeLabel} + {booking.serviceTypeBonus ? ` (+${booking.serviceTypeBonus} bonus)` : ""} + + ) : null} + + + + ); +} + +export function OperationsBookingQueue({ + bookings, + isLoading, + onAllocate, +}: { + bookings: BookingListRow[]; + isLoading?: boolean; + onAllocate: (bookingIds: string[]) => void; +}) { + const { government, commercial } = useMemo( + () => groupBookingsForOperationsQueue(bookings), + [bookings], + ); + const [govSelected, setGovSelected] = useState([]); + const [selectedByBucket, setSelectedByBucket] = useState>({}); + + const allocatable = (row: BookingListRow) => + row.status === "PAID" && + canAllocateBooking({ status: row.status, schedulingStatus: row.schedulingStatus }); + + const govSelection = govSelected.length + ? govSelected + : government.filter(allocatable).map((b) => b.id); + + const bucketSelection = (bucketKey: string, bucketBookings: BookingListRow[]) => { + const existing = selectedByBucket[bucketKey]; + if (existing) return existing; + return bucketBookings.filter(allocatable).map((b) => b.id); + }; + + const toggleGov = (bookingId: string) => { + setGovSelected((prev) => { + const base = prev.length ? prev : government.filter(allocatable).map((b) => b.id); + return base.includes(bookingId) + ? base.filter((id) => id !== bookingId) + : [...base, bookingId]; + }); + }; + + const toggleBucket = (bucketKey: string, bookingId: string) => { + setSelectedByBucket((prev) => { + const current = prev[bucketKey] ?? []; + const next = current.includes(bookingId) + ? current.filter((id) => id !== bookingId) + : [...current, bookingId]; + return { ...prev, [bucketKey]: next }; + }); + }; + + if (isLoading) { + return Loading operations queue…; + } + + if (!government.length && !commercial.length) { + return ( + + No PAID bookings ready to allocate. + + ); + } + + return ( + + {government.length > 0 ? ( + + + + Government priority + + Served first — not grouped by 3-hour window + + + + {govSelection.length} selected + + + + + {government.map((booking) => ( + toggleGov(booking.id)} + /> + ))} + + + ) : null} + + {commercial.length > 0 ? ( + + {commercial.map((bucket) => { + const selected = bucketSelection(bucket.key, bucket.bookings); + return ( + + + + + {bucket.label} + + {bucket.bookings.length} commercial booking + {bucket.bookings.length === 1 ? "" : "s"} + + + + {selected.length} selected + + + + + + + {bucket.bookings.map((booking) => ( + toggleBucket(bucket.key, booking.id)} + /> + ))} + + + + ); + })} + + ) : null} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/OperationsScheduledBookings.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/OperationsScheduledBookings.tsx new file mode 100644 index 000000000..44a1ef030 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/OperationsScheduledBookings.tsx @@ -0,0 +1,97 @@ +import { Link } from "react-router-dom"; +import { ArrowRight, ExternalLink } from "lucide-react"; +import { Badge, Button, Group, Stack, Text } from "@mantine/core"; + +import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge"; +import type { BookingListRow } from "@/types/booking"; +import { DataTable, type ColumnDef } from "@edr/ui-common"; + +export function OperationsScheduledBookings({ + bookings, + isLoading, +}: { + bookings: BookingListRow[]; + isLoading?: boolean; +}) { + const columns: ColumnDef[] = [ + { + id: "reference", + header: "Booking", + cell: ({ row }) => ( + + + {row.original.reference} + {row.original.isGovernment ? ( + Government + ) : null} + + {row.original.customerLabel} + + ), + }, + { + id: "route", + header: "Route", + cell: ({ row }) => ( + + {row.original.originLabel} + + {row.original.destinationLabel} + + ), + }, + { + id: "scheduled", + header: "Scheduled", + cell: ({ row }) => ( + {String(row.original.scheduledDate).slice(0, 16)} + ), + }, + { + id: "status", + header: "Scheduling", + cell: ({ row }) => + row.original.schedulingStatus ? ( + + ) : ( + + ), + }, + { + id: "actions", + header: "", + cell: ({ row }) => ( + + + {row.original.trainScheduleId ? ( + + ) : null} + + ), + }, + ]; + + return ( + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx index cb6503217..193ea5daa 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx @@ -4,6 +4,7 @@ import { Paper, Group, Stack, Title, Text, Button, Box } from "@mantine/core"; import type { BookingDetail } from "@/types/booking"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge"; +import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge"; import { NextStepBanner } from "@/components/bookings/NextStepBanner"; import { detailStyles, formatDate } from "./booking-detail.styles"; @@ -52,7 +53,15 @@ export function BookingRequestHero({ + {booking.schedulingStatus ? ( + + ) : null} + {booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? ( + + Hold expires {new Date(booking.holdExpiresAt).toLocaleString()} + + ) : null} {booking.nextStep && ( diff --git a/apps/edr-freight-web/backoffice/src/components/container_management/AssignContainerDialog.tsx b/apps/edr-freight-web/backoffice/src/components/container_management/AssignContainerDialog.tsx deleted file mode 100644 index 55f44936c..000000000 --- a/apps/edr-freight-web/backoffice/src/components/container_management/AssignContainerDialog.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { useState } from 'react'; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from '@edr/ui-common'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { useContainers, useAssignContainerToWagon } from './use-containers'; -import { useToast } from '@/hooks/use-toast'; -import { Plus } from 'lucide-react'; - -export function AssignContainerDialog({ wagonId }: { wagonId: string }) { - const [open, setOpen] = useState(false); - const [containerId, setContainerId] = useState(''); - const [position, setPosition] = useState(); - const { data: containers } = useContainers(); - const assign = useAssignContainerToWagon(); - const { toast } = useToast(); - - const available = containers?.filter(c => c.status === 'AVAILABLE' && !c.wagonId); - - const handleAssign = async () => { - if (!containerId) return; - await assign.mutateAsync({ containerId, wagonId, position }); - toast({ title: 'Assigned', description: 'Container placed on wagon.' }); - setOpen(false); - }; - - return ( - - - - Assign Container to Wagon -
-
-
setPosition(parseInt(e.target.value) || undefined)} />
- -
-
-
- ); -} \ No newline at end of file diff --git a/apps/edr-freight-web/backoffice/src/components/container_management/CargoFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/container_management/CargoFormDialog.tsx deleted file mode 100644 index b36aafe39..000000000 --- a/apps/edr-freight-web/backoffice/src/components/container_management/CargoFormDialog.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import { useState, useEffect } from 'react'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogFooter, -} from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from '@edr/ui-common'; -import { Textarea } from '@/components/ui/textarea'; -import { useCargoTypes } from './use-cargo-types'; -import { useCargoMutations } from './use-cargoes'; -import { Loader2 } from 'lucide-react'; - -interface Cargo { - id: string; - cargoNumber: string; - cargoTypeId: string; - weight: number; - remarks?: string; -} - -interface CargoFormDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - cargo?: Cargo | null; - onSuccess?: () => void; -} - -export default function CargoFormDialog({ - open, - onOpenChange, - cargo, - onSuccess, -}: CargoFormDialogProps) { - const { data: cargoTypes } = useCargoTypes(); - const { createCargo, updateCargo } = useCargoMutations(); - const [formData, setFormData] = useState>({ - cargoNumber: '', - cargoTypeId: '', - weight: 0, - remarks: '', - }); - - useEffect(() => { - if (cargo) { - setFormData(cargo); - } else { - setFormData({ - cargoNumber: '', - cargoTypeId: '', - weight: 0, - remarks: '', - }); - } - }, [cargo, open]); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (cargo?.id) { - updateCargo.mutate( - { id: cargo.id, data: formData }, - { onSuccess: () => { onOpenChange(false); onSuccess?.(); } } - ); - } else { - createCargo.mutate(formData, { - onSuccess: () => { onOpenChange(false); onSuccess?.(); } - }); - } - }; - - const isLoading = createCargo.isPending || updateCargo.isPending; - - return ( - - - {cargo ? 'Edit Cargo' : 'Create New Cargo'} -
-
-
- - setFormData({...formData, cargoNumber: e.target.value})} required /> -
-
- - -
-
-
- - setFormData({...formData, weight: parseFloat(e.target.value)})} required /> -
-
- -