booking operations and trains scheduling also allocations

This commit is contained in:
marshal
2026-06-10 00:48:32 +03:00
parent 675975bc08
commit 5774d7db9d
180 changed files with 13423 additions and 3877 deletions

View File

@@ -10,7 +10,8 @@
"lint": "eslint src", "lint": "eslint src",
"test": "jest", "test": "jest",
"test:e2e": "jest --config ./test/jest-e2e.json", "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": { "dependencies": {
"@edr/api-common": "workspace:*", "@edr/api-common": "workspace:*",

View File

@@ -19,6 +19,7 @@ import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module";
import { TrainSetsModule } from "./modules/train-sets/train-sets.module"; import { TrainSetsModule } from "./modules/train-sets/train-sets.module";
import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module"; import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module";
import { TrainSchedulingModule } from "./modules/train-scheduling/train-scheduling.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 { CustomersModule } from "./modules/customers/customers.module";
import { CompaniesModule } from "./modules/companies/companies.module"; import { CompaniesModule } from "./modules/companies/companies.module";
import { TrackingModule } from "./modules/tracking/tracking.module"; import { TrackingModule } from "./modules/tracking/tracking.module";
@@ -82,6 +83,7 @@ import { OverviewModule } from './modules/overview/overview.module';
TrainSetsModule, TrainSetsModule,
TrainSchedulesModule, TrainSchedulesModule,
TrainSchedulingModule, TrainSchedulingModule,
SchedulingRescheduleModule,
CustomersModule, CustomersModule,
CompaniesModule, CompaniesModule,
TrackingModule, TrackingModule,

View File

@@ -15,3 +15,9 @@ export const BookingStaff = (permission: string | string[]) =>
); );
export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view); export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view);
export const TrainSchedulingView = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.view);
export const TrainSchedulingManage = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.manage);

View File

@@ -1,7 +1,17 @@
import { registerAs } from "@nestjs/config"; 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", () => ({ export default registerAs("app", () => ({
env: process.env.NODE_ENV ?? "development", env: process.env.NODE_ENV ?? "development",
port: parseInt(process.env.PORT ?? "3001", 10), port: parseInt(process.env.PORT ?? "3001", 10),
apiPrefix: "api", 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),
},
})); }));

View File

@@ -61,7 +61,10 @@ export class ContractPricingScheduleBuilder {
booking.destinationYard?.label ?? booking.destinationYard?.code ?? '—', booking.destinationYard?.label ?? booking.destinationYard?.code ?? '—',
containerLines: (booking.bookingContainers ?? []).map((c) => ({ containerLines: (booking.bookingContainers ?? []).map((c) => ({
label: label:
c.containerType?.label ?? c.containerType?.code ?? c.containerTypeId, c.containerType?.label ??
c.containerType?.code ??
c.containerTypeId ??
'—',
quantity: c.quantity, quantity: c.quantity,
vgmPerUnitTons: Number(c.vgmPerUnitTons), vgmPerUnitTons: Number(c.vgmPerUnitTons),
})), })),

View File

@@ -0,0 +1,321 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddSchedulingAllocationEnhancements1750400000000
implements MigrationInterface
{
name = 'AddSchedulingAllocationEnhancements1750400000000';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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;
`);
}
}

View File

@@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddWagonReadiness1750500000000 implements MigrationInterface {
name = 'AddWagonReadiness1750500000000';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wagons_readiness`);
await queryRunner.query(`
ALTER TABLE freight.wagons
DROP COLUMN IF EXISTS readiness
`);
}
}

View File

@@ -0,0 +1,46 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddGovernmentBookingFields1750600000000 implements MigrationInterface {
name = 'AddGovernmentBookingFields1750600000000';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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
`);
}
}

View File

@@ -0,0 +1,32 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateSchedulingEvents1750700000000 implements MigrationInterface {
name = 'CreateSchedulingEvents1750700000000';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_scheduling_events_train_schedule_id`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.scheduling_events`);
}
}

View File

@@ -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<void> {
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<void> {
const hasContainerTypes = await queryRunner.hasTable('freight.container_types');
if (!hasContainerTypes) {
return;
}
await queryRunner.query(`
UPDATE freight.container_types SET wagons_per_unit = 1.00;
`);
}
}

View File

@@ -0,0 +1,39 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddContainerNumberToBookingContainer1750900000000 implements MigrationInterface {
name = "AddContainerNumberToBookingContainer1750900000000";
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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;
`);
}
}

View File

@@ -0,0 +1,35 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class CreateTrainSchedulingGlobalRules1751000000000 implements MigrationInterface {
name = "CreateTrainSchedulingGlobalRules1751000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_scheduling_global_rules;`);
}
}

View File

@@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddDeletedAtToTrainSchedulingGlobalRules1751000000001
implements MigrationInterface
{
name = "AddDeletedAtToTrainSchedulingGlobalRules1751000000001";
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.query(`
ALTER TABLE freight.train_scheduling_global_rules
DROP COLUMN IF EXISTS deleted_at;
`);
}
}

View File

@@ -159,18 +159,20 @@ export class BookingPricingService {
async buildEvalInputForBooking(booking: Booking): Promise<BookingEvaluationInput> { async buildEvalInputForBooking(booking: Booking): Promise<BookingEvaluationInput> {
const containers = await Promise.all( const containers = await Promise.all(
(booking.bookingContainers ?? []).map(async (bc) => { (booking.bookingContainers ?? [])
const ct = await this.containerTypesService.findById(bc.containerTypeId); .filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null)
const vgm = Number(bc.vgmPerUnitTons); .map(async (bc) => {
const qty = bc.quantity; const ct = await this.containerTypesService.findById(bc.containerTypeId);
return { const vgm = Number(bc.vgmPerUnitTons);
containerTypeId: bc.containerTypeId, const qty = bc.quantity;
quantity: qty, return {
vgmPerUnitTons: vgm, containerTypeId: bc.containerTypeId,
totalVgmTons: qty * vgm, quantity: qty,
isReefer: ct.isReefer, vgmPerUnitTons: vgm,
}; totalVgmTons: qty * vgm,
}), isReefer: ct.isReefer,
};
}),
); );
return { return {
freightType: booking.freightType as 'CONTAINER' | 'BULK', freightType: booking.freightType as 'CONTAINER' | 'BULK',
@@ -179,6 +181,7 @@ export class BookingPricingService {
paymentCurrency: booking.paymentCurrency, paymentCurrency: booking.paymentCurrency,
tradeDirection: booking.tradeDirection, tradeDirection: booking.tradeDirection,
isHazardous: booking.isHazardous, isHazardous: booking.isHazardous,
isGovernment: booking.isGovernment,
allowConsolidation: booking.allowConsolidation, allowConsolidation: booking.allowConsolidation,
shippingLineId: booking.shippingLineId, shippingLineId: booking.shippingLineId,
containers, containers,

View File

@@ -54,6 +54,7 @@ import {
type AuthUserPayload, type AuthUserPayload,
resolveAuthUserId, resolveAuthUserId,
} from '../../common/resolve-auth-user-id'; } from '../../common/resolve-auth-user-id';
import { assertFreightPermission } from '../../common/freight-permission.util';
@ApiTags('bookings') @ApiTags('bookings')
@Controller('bookings') @Controller('bookings')
@@ -75,10 +76,12 @@ export class BookingsController {
create( create(
@Body() dto: CreateBookingDto, @Body() dto: CreateBookingDto,
@UploadedFiles() files: Express.Multer.File[], @UploadedFiles() files: Express.Multer.File[],
@Request() req: { user?: { id?: string; sub?: string } }, @CurrentUser() user: TCurrentUser,
) { ) {
const userId = req.user?.id ?? req.user?.sub; if (dto.isGovernment) {
return this.bookingsService.create(dto, files ?? [], userId); assertFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept);
}
return this.bookingsService.create(dto, files ?? [], user?.id);
} }
@Patch(':id') @Patch(':id')
@@ -244,6 +247,20 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking); 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') @Post(':id/approval-steps/:stepId/approve')
@BookingStaff([ @BookingStaff([
FREIGHT_PERMS.bookings.approveLineStaff, FREIGHT_PERMS.bookings.approveLineStaff,

View File

@@ -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<Repository<Booking>>;
let dataSource: { getRepository: jest.Mock };
let bookingsRepository: BookingsRepository;
beforeEach(() => {
repository = {
createQueryBuilder: jest.fn(),
} as unknown as jest.Mocked<Repository<Booking>>;
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'));
});
});

View File

@@ -1,7 +1,8 @@
import { BaseRepository } from '@edr/api-common'; import { BaseRepository } from '@edr/api-common';
import { SchedulingStatus } from '@edr/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; 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 { ContainerType } from '../rule-engine/entities/container-type.entity';
import { BookingApprovalStep } from './entities/booking-approval-step.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 { BookingContainer } from './entities/booking-container.entity';
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity'; import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.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 { Booking } from './entities/booking.entity';
import { import {
BookingContractSignature, BookingContractSignature,
@@ -20,6 +22,8 @@ import { ContainerWeightResult } from '../rule-engine/rule-engine.service';
export interface BookingListFilterOptions { export interface BookingListFilterOptions {
statuses?: string[]; statuses?: string[];
status?: string; status?: string;
schedulingStatuses?: string[];
assignedToSchedule?: 'true' | 'false';
companyId?: string; companyId?: string;
contractType?: string; contractType?: string;
serviceTypeId?: string; serviceTypeId?: string;
@@ -427,17 +431,37 @@ export class BookingsRepository extends BaseRepository<Booking> {
this.applyListFilters(qb, options); this.applyListFilters(qb, options);
const sortField = if (options.sortBy === 'isGovernment') {
options.sortBy === 'priorityScore' qb.orderBy('booking.isGovernment', 'DESC')
? 'booking.priorityScore' .addOrderBy('booking.priorityScore', 'DESC')
: 'booking.createdAt'; .addOrderBy('booking.scheduledDate', 'ASC');
qb.orderBy(sortField, options.sortOrder ?? 'DESC'); } 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 const [items, total] = await qb
.skip((page - 1) * pageSize) .skip((page - 1) * pageSize)
.take(pageSize) .take(pageSize)
.getManyAndCount(); .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 }; return { items, total };
} }
@@ -556,6 +580,26 @@ export class BookingsRepository extends BaseRepository<Booking> {
} else if (options.consolidationPaired === 'false') { } else if (options.consolidationPaired === 'false') {
qb.andWhere('booking.consolidation_partner_id IS NULL'); 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<Booking>, options: { async findAndCountFiltered(where: FindOptionsWhere<Booking>, options: {
@@ -605,4 +649,99 @@ export class BookingsRepository extends BaseRepository<Booking> {
} }
return repo.save(repo.create(data)); 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<Booking[]> {
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<Booking[]> {
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<void> {
await this.bookingRepo(manager).update(bookingId, fields as never);
}
async setHoldWindowOnPaid(bookingId: string, manager?: EntityManager): Promise<void> {
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,
);
}
} }

View File

@@ -4,6 +4,7 @@ import {
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { SchedulingStatus } from '@edr/types';
// import { CustomersService } from '../customers/customers.service'; // import { CustomersService } from '../customers/customers.service';
import { CompaniesService } from '../companies/companies.service'; import { CompaniesService } from '../companies/companies.service';
import { FilesService } from '../files/files.service'; import { FilesService } from '../files/files.service';
@@ -64,6 +65,7 @@ export class BookingsService {
paymentCurrency: string; paymentCurrency: string;
tradeDirection: string; tradeDirection: string;
isHazardous?: boolean; isHazardous?: boolean;
isGovernment?: boolean;
allowConsolidation?: boolean; allowConsolidation?: boolean;
shippingLineId?: string | null; shippingLineId?: string | null;
containers: CreateBookingContainerDto[]; containers: CreateBookingContainerDto[];
@@ -92,6 +94,7 @@ export class BookingsService {
paymentCurrency: dto.paymentCurrency, paymentCurrency: dto.paymentCurrency,
tradeDirection: dto.tradeDirection, tradeDirection: dto.tradeDirection,
isHazardous: dto.isHazardous ?? false, isHazardous: dto.isHazardous ?? false,
isGovernment: dto.isGovernment ?? false,
allowConsolidation: allowConsolidation:
dto.freightType === 'CONTAINER' ? dto.allowConsolidation : false, dto.freightType === 'CONTAINER' ? dto.allowConsolidation : false,
shippingLineId: dto.shippingLineId, shippingLineId: dto.shippingLineId,
@@ -178,8 +181,15 @@ export class BookingsService {
// customerId = customer.id; // customerId = customer.id;
// } // }
let companyId = dto.companyId; const isGovernment = dto.isGovernment === true;
if (!companyId) {
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) { if (!userId) {
throw new BadRequestException( throw new BadRequestException(
'companyId is required or must be resolvable from auth token', 'companyId is required or must be resolvable from auth token',
@@ -209,6 +219,7 @@ export class BookingsService {
paymentCurrency: dto.paymentCurrency, paymentCurrency: dto.paymentCurrency,
tradeDirection: dto.tradeDirection, tradeDirection: dto.tradeDirection,
isHazardous: dto.isHazardous, isHazardous: dto.isHazardous,
isGovernment,
allowConsolidation, allowConsolidation,
shippingLineId: dto.shippingLineId, shippingLineId: dto.shippingLineId,
containers, containers,
@@ -220,7 +231,9 @@ export class BookingsService {
const booking = await this.bookingsRepository.create({ const booking = await this.bookingsRepository.create({
reference, reference,
companyId, companyId: companyId ?? null,
isGovernment,
governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null,
trainId: dto.trainId, trainId: dto.trainId,
contractType: dto.contractType, contractType: dto.contractType,
previousContractId: dto.previousContractId, previousContractId: dto.previousContractId,
@@ -300,12 +313,13 @@ export class BookingsService {
const freightType = (dto.freightType ?? existing.freightType) as FreightType; const freightType = (dto.freightType ?? existing.freightType) as FreightType;
let containers = let containers =
dto.containers ?? dto.containers ??
existing.bookingContainers?.map((bc) => ({ (existing.bookingContainers ?? [])
containerTypeId: bc.containerTypeId, .filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null)
quantity: bc.quantity, .map((bc) => ({
vgmPerUnitTons: Number(bc.vgmPerUnitTons), containerTypeId: bc.containerTypeId,
})) ?? quantity: bc.quantity,
[]; vgmPerUnitTons: Number(bc.vgmPerUnitTons),
}));
let cargoTypeId = let cargoTypeId =
dto.cargoTypeId !== undefined ? dto.cargoTypeId : existing.cargoTypeId; dto.cargoTypeId !== undefined ? dto.cargoTypeId : existing.cargoTypeId;
@@ -403,6 +417,19 @@ export class BookingsService {
return { booking, warnings }; 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. */ /** Parse comma-separated or repeated status query values. */
private parseStatusFilter(filter: FilterBookingDto): { private parseStatusFilter(filter: FilterBookingDto): {
statuses?: string[]; statuses?: string[];
@@ -433,11 +460,14 @@ export class BookingsService {
const page = filter.page ?? 1; const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20; const pageSize = filter.pageSize ?? 20;
const statusFilter = this.parseStatusFilter(filter); const statusFilter = this.parseStatusFilter(filter);
const schedulingStatusFilter = this.parseSchedulingStatusFilter(filter);
return this.bookingsRepository.findAllPaginated({ return this.bookingsRepository.findAllPaginated({
page, page,
pageSize, pageSize,
...statusFilter, ...statusFilter,
...schedulingStatusFilter,
assignedToSchedule: filter.assignedToSchedule,
companyId: filter.companyId, companyId: filter.companyId,
contractType: filter.contractType, contractType: filter.contractType,
serviceTypeId: filter.serviceTypeId, serviceTypeId: filter.serviceTypeId,
@@ -695,12 +725,13 @@ export class BookingsService {
return true; return true;
} }
if (dto.containers !== undefined) { if (dto.containers !== undefined) {
const existingContainers = const existingContainers = (existing.bookingContainers ?? [])
existing.bookingContainers?.map((bc) => ({ .filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null)
.map((bc) => ({
containerTypeId: bc.containerTypeId, containerTypeId: bc.containerTypeId,
quantity: bc.quantity, quantity: bc.quantity,
vgmPerUnitTons: Number(bc.vgmPerUnitTons), vgmPerUnitTons: Number(bc.vgmPerUnitTons),
})) ?? []; }));
if (JSON.stringify(existingContainers) !== JSON.stringify(containers)) { if (JSON.stringify(existingContainers) !== JSON.stringify(containers)) {
return true; return true;
} }
@@ -714,4 +745,32 @@ export class BookingsService {
} }
return false; return false;
} }
/** Staff expedite: mark a government booking PAID and ready for scheduling (no commercial hold). */
async governmentExpedite(id: string, staffUserId: string): Promise<Booking> {
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);
}
} }

View File

@@ -76,11 +76,12 @@ export class ConsolidationService {
} }
async slotsFromBooking(booking: Booking): Promise<ConsolidationSlot[]> { async slotsFromBooking(booking: Booking): Promise<ConsolidationSlot[]> {
const lines = const lines = (booking.bookingContainers ?? [])
booking.bookingContainers?.map((bc) => ({ .filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null)
.map((bc) => ({
containerTypeId: bc.containerTypeId, containerTypeId: bc.containerTypeId,
quantity: bc.quantity, quantity: bc.quantity,
})) ?? []; }));
return this.slotsFromContainerLines(lines); return this.slotsFromContainerLines(lines);
} }

View File

@@ -12,6 +12,7 @@ import {
IsString, IsString,
IsUUID, IsUUID,
Min, Min,
MinLength,
Validate, Validate,
ValidateIf, ValidateIf,
ValidateNested, ValidateNested,
@@ -66,7 +67,21 @@ export class CreateBookingDto {
// @IsUUID() // @IsUUID()
// customerId?: string; // 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' }) @ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target company' })
@ValidateIf((o) => o.isGovernment !== true)
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()
companyId?: string; companyId?: string;

View File

@@ -84,8 +84,25 @@ export class FilterBookingDto {
@Transform(({ value }) => (value ? parseInt(value, 10) : 20)) @Transform(({ value }) => (value ? parseInt(value, 10) : 20))
pageSize?: number; 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' }) @ApiPropertyOptional({ default: 'createdAt' })
@IsOptional() @IsOptional()
@IsIn(['createdAt', 'priorityScore', 'scheduledDate', 'isGovernment'])
sortBy?: string; sortBy?: string;
@ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'DESC' }) @ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'DESC' })

View File

@@ -15,12 +15,15 @@ export class BookingContainer extends BaseEntity {
@JoinColumn({ name: 'booking_id' }) @JoinColumn({ name: 'booking_id' })
booking?: Booking; booking?: Booking;
@Column({ name: 'container_type_id', type: 'uuid' }) @Column({ name: 'container_type_id', type: 'uuid', nullable: true })
containerTypeId!: string; containerTypeId?: string | null;
@ManyToOne(() => ContainerType) @ManyToOne(() => ContainerType, { nullable: true })
@JoinColumn({ name: 'container_type_id' }) @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' }) @Column({ name: 'quantity', type: 'smallint' })
quantity!: number; quantity!: number;

View File

@@ -2,7 +2,7 @@ import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Booking } from './booking.entity'; 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]; export type ReviewNoteType = (typeof REVIEW_NOTE_TYPES)[number];
@Entity({ schema: 'freight', name: 'booking_review_note' }) @Entity({ schema: 'freight', name: 'booking_review_note' })

View File

@@ -1,4 +1,5 @@
import { BaseEntity } from '@edr/api-common'; import { BaseEntity } from '@edr/api-common';
import { SchedulingStatus } from '@edr/types';
import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
// import { Customer } from '../../customers/entities/customer.entity'; // import { Customer } from '../../customers/entities/customer.entity';
import { Company } from '../../companies/entities/company.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 const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
export type FreightType = (typeof FREIGHT_TYPES)[number]; 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. */ /** Statuses where the customer may edit booking fields. */
export const CUSTOMER_EDITABLE_STATUSES: BookingStatus[] = [ export const CUSTOMER_EDITABLE_STATUSES: BookingStatus[] = [
'DRAFT', 'DRAFT',
@@ -69,16 +80,24 @@ export class Booking extends BaseEntity {
// @JoinColumn({ name: 'customer_id' }) // @JoinColumn({ name: 'customer_id' })
// customer?: Customer; // customer?: Customer;
@Column({ name: 'company_id', type: 'uuid' }) @Column({ name: 'company_id', type: 'uuid', nullable: true })
companyId!: string; companyId?: string | null;
@ManyToOne(() => Company) @ManyToOne(() => Company, { nullable: true })
@JoinColumn({ name: 'company_id' }) @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 }) @Column({ name: 'train_id', type: 'uuid', nullable: true })
trainId?: string | null; trainId?: string | null;
/** @deprecated Use train_schedule_bookings for operational scheduling. */
@ManyToOne(() => Train, { nullable: true }) @ManyToOne(() => Train, { nullable: true })
@JoinColumn({ name: 'train_id' }) @JoinColumn({ name: 'train_id' })
train?: Train | null; train?: Train | null;
@@ -240,6 +259,21 @@ export class Booking extends BaseEntity {
@JoinColumn({ name: 'consolidation_partner_id' }) @JoinColumn({ name: 'consolidation_partner_id' })
consolidationPartner?: Booking | null; 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) @OneToMany(() => BookingContainer, (bc) => bc.booking)
bookingContainers?: BookingContainer[]; bookingContainers?: BookingContainer[];

View File

@@ -159,9 +159,12 @@ export class CargoesService {
cargo.status = 'DELIVERED'; cargo.status = 'DELIVERED';
if (dto?.deliveryRemarks) cargo.description = dto.deliveryRemarks; if (dto?.deliveryRemarks) cargo.description = dto.deliveryRemarks;
const remaining = await this.cargoRepo.count({ const remaining =
where: { containerId: cargo.containerId, status: 'LOADED' }, cargo.containerId != null
}); ? await this.cargoRepo.count({
where: { containerId: cargo.containerId, status: 'LOADED' },
})
: 0;
if (remaining === 0 && cargo.container) { if (remaining === 0 && cargo.container) {
cargo.container.status = 'AVAILABLE'; cargo.container.status = 'AVAILABLE';
await this.containerRepo.save(cargo.container); await this.containerRepo.save(cargo.container);

View File

@@ -1,7 +1,9 @@
// apps/edr-freight-api/src/modules/cargoes/entities/cargo.entity.ts // apps/edr-freight-api/src/modules/cargoes/entities/cargo.entity.ts
import { Entity, Column, ManyToOne, JoinColumn } from 'typeorm'; import { Entity, Column, ManyToOne, JoinColumn } from 'typeorm';
import { BaseEntity } from '@edr/api-common'; import { BaseEntity } from '@edr/api-common';
import { Booking } from '../../bookings/entities/booking.entity';
import { Container } from '../../container-management/entities/container.entity'; import { Container } from '../../container-management/entities/container.entity';
import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity';
@Entity({ name: 'cargoes', schema: 'freight' }) @Entity({ name: 'cargoes', schema: 'freight' })
export class Cargo extends BaseEntity { export class Cargo extends BaseEntity {
@@ -11,8 +13,8 @@ export class Cargo extends BaseEntity {
@Column({ name: 'shipment_id', type: 'uuid' }) @Column({ name: 'shipment_id', type: 'uuid' })
shipmentId!: string; shipmentId!: string;
@Column({ name: 'container_id', type: 'uuid' }) @Column({ name: 'container_id', type: 'uuid', nullable: true })
containerId!: string; containerId!: string | null;
@Column({ name: 'cargo_type_id', type: 'uuid', nullable: true }) @Column({ name: 'cargo_type_id', type: 'uuid', nullable: true })
cargoTypeId!: string | null; // optional link to cargo_types table 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 }) @Column({ name: 'unloaded_at', type: 'timestamp', nullable: true })
unloadedAt!: Date | null; unloadedAt!: Date | null;
// Relationship to Container @Column({ name: 'wagon_booking_allocation_id', type: 'uuid', nullable: true })
@ManyToOne(() => Container, (container) => container.cargoes, { onDelete: 'RESTRICT' }) 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' }) @JoinColumn({ name: 'container_id' })
container!: Container; container!: Container | null;
} }

View File

@@ -1,6 +1,9 @@
// apps/edr-freight-api/src/modules/container-management/entities/container.entity.ts // apps/edr-freight-api/src/modules/container-management/entities/container.entity.ts
import { Entity, Column, ManyToOne, OneToMany, JoinColumn } from 'typeorm'; import { Entity, Column, ManyToOne, OneToMany, JoinColumn } from 'typeorm';
import { BaseEntity } from '@edr/api-common'; 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 { Wagon } from '../../wagons/entities/wagon.entity';
import { Cargo } from '../../cargoes/entities/cargoes.entity'; import { Cargo } from '../../cargoes/entities/cargoes.entity';
@@ -34,7 +37,27 @@ sealNumber!: string | null;
@Column({ type: 'varchar', default: 'AVAILABLE' }) @Column({ type: 'varchar', default: 'AVAILABLE' })
status!: string; // AVAILABLE, LOADED, IN_TRANSIT, MAINTENANCE, DAMAGED 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' }) @ManyToOne(() => Wagon, (wagon) => wagon.containers, { onDelete: 'SET NULL' })
@JoinColumn({ name: 'wagon_id' }) @JoinColumn({ name: 'wagon_id' })
wagon!: Wagon | null; wagon!: Wagon | null;

View File

@@ -124,7 +124,7 @@ export class OverviewRepository {
this.wagonRepository this.wagonRepository
.createQueryBuilder('wagon') .createQueryBuilder('wagon')
.where('wagon.deleted_at IS NULL') .where('wagon.deleted_at IS NULL')
.andWhere('wagon.status = :status', { status: 'AVAILABLE' }) .andWhere('wagon.status = :status', { status: Freight.WagonStatus.Available })
.getCount(), .getCount(),
this.containerRepository this.containerRepository
.createQueryBuilder('container') .createQueryBuilder('container')

View File

@@ -16,6 +16,7 @@ import * as fs from "fs";
import * as path from "path"; import * as path from "path";
import * as Handlebars from "handlebars"; import * as Handlebars from "handlebars";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { SchedulingStatus } from "@edr/types";
import { Booking } from "../bookings/entities/booking.entity"; import { Booking } from "../bookings/entities/booking.entity";
type PaymentMethod = PaymentEntity["method"]; type PaymentMethod = PaymentEntity["method"];
@@ -168,7 +169,14 @@ export class PaymentService {
const ordersStatus = bizContent.order_status; const ordersStatus = bizContent.order_status;
if (ordersStatus == "PAY_SUCCESS") { if (ordersStatus == "PAY_SUCCESS") {
await this.datasource.transaction(async (mg) => { 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" }); await mg.update(PaymentEntity, { id: resp.id }, { status: "success" });
}); });
} }

View File

@@ -0,0 +1,2 @@
/** Ensures government bookings outrank commercial priority (max ~1,500 today). */
export const GOVERNMENT_PRIORITY_BONUS = 50_000;

View File

@@ -36,6 +36,7 @@ import {
SHIPPING_LINES_REPOSITORY, SHIPPING_LINES_REPOSITORY,
} from './interfaces/shipping-lines.repository.interface'; } from './interfaces/shipping-lines.repository.interface';
import { DEFAULT_APPROVAL_RULE_ROWS } from './approval-rules.defaults'; import { DEFAULT_APPROVAL_RULE_ROWS } from './approval-rules.defaults';
import { GOVERNMENT_PRIORITY_BONUS } from './government-priority.constants';
export interface BookingContainerEvalInput { export interface BookingContainerEvalInput {
containerTypeId: string; containerTypeId: string;
@@ -54,6 +55,7 @@ export interface BookingEvaluationInput {
paymentCurrency: string; paymentCurrency: string;
tradeDirection: string; tradeDirection: string;
isHazardous: boolean; isHazardous: boolean;
isGovernment?: boolean;
allowConsolidation?: boolean; allowConsolidation?: boolean;
shippingLineId?: string | null; shippingLineId?: string | null;
containers: BookingContainerEvalInput[]; containers: BookingContainerEvalInput[];
@@ -180,6 +182,10 @@ export class RuleEngineService {
} }
} }
if (input.isGovernment) {
priorityScore += GOVERNMENT_PRIORITY_BONUS;
}
let shippingLineMapped = false; let shippingLineMapped = false;
if (input.shippingLineId) { if (input.shippingLineId) {
const line = await this.shippingLinesRepo.findById(input.shippingLineId); const line = await this.shippingLinesRepo.findById(input.shippingLineId);

View File

@@ -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[];
}

View File

@@ -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<string, unknown>;
@Column({ name: 'displaced_booking_ids', type: 'jsonb', default: '[]' })
displacedBookingIds!: string[];
}

View File

@@ -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),
);
}
}

View File

@@ -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 {}

View File

@@ -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<SchedulingEvent>,
) {}
/** Persist an audit record for a completed reschedule. */
async createEvent(data: {
trainScheduleId: string;
trigger: RescheduleTrigger;
actorUserId?: string;
reason?: string;
planSnapshot: Record<string, unknown>;
displacedBookingIds: string[];
}): Promise<SchedulingEvent> {
return this.repository.save(this.repository.create(data));
}
}

View File

@@ -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<string, unknown> = {},
) => ({
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<string, jest.Mock>;
let bookingsRepository: Record<string, jest.Mock>;
let trainSchedulingService: Record<string, jest.Mock>;
let schedulingRescheduleRepository: Record<string, jest.Mock>;
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<string, number>();
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']);
});
});

View File

@@ -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<ReschedulePlan> {
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<string, Booking>();
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<string, unknown>,
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<boolean> {
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,
};
}
}

View File

@@ -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();
}

View File

@@ -1,4 +1,5 @@
import { BaseEntity } from '@edr/api-common'; import { BaseEntity } from '@edr/api-common';
import { TrainScheduleStatus as TrainScheduleStatusEnum } from '@edr/types';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm'; import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm';
import { Yard } from '../../rule-engine/entities/yard.entity'; 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'; import { TrainScheduleBooking } from './train-schedule-booking.entity';
export const TRAIN_SCHEDULE_STATUSES = [ export const TRAIN_SCHEDULE_STATUSES = [
'DRAFT', TrainScheduleStatusEnum.Draft,
'SCHEDULED', TrainScheduleStatusEnum.Scheduled,
'DISPATCHED', TrainScheduleStatusEnum.Dispatched,
'ARRIVED', TrainScheduleStatusEnum.Arrived,
'CANCELLED', TrainScheduleStatusEnum.Cancelled,
] as const; ] as const;
export type TrainScheduleStatus = (typeof TRAIN_SCHEDULE_STATUSES)[number]; 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' }) @Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' })
status!: TrainScheduleStatus; 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) @OneToMany(() => TrainScheduleBooking, (scheduleBooking) => scheduleBooking.trainSchedule)
scheduleBookings?: TrainScheduleBooking[]; scheduleBookings?: TrainScheduleBooking[];
} }

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -1,8 +1,22 @@
import { BaseEntity } from '@edr/api-common'; 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 { Booking } from '../../bookings/entities/booking.entity';
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.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' }) @Entity({ schema: 'freight', name: 'wagon_booking_allocations' })
@Index(['trainSetWagonId', 'bookingId']) @Index(['trainSetWagonId', 'bookingId'])
@@ -23,4 +37,19 @@ export class WagonBookingAllocation extends BaseEntity {
@Column({ name: 'allocated_weight_tons', type: 'numeric', precision: 10, scale: 3 }) @Column({ name: 'allocated_weight_tons', type: 'numeric', precision: 10, scale: 3 })
allocatedWeightTons!: number; 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[];
} }

View File

@@ -1,7 +1,7 @@
import { BaseRepository } from '@edr/api-common'; import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { DeepPartial, EntityManager, In, Repository } from 'typeorm';
import { TrainScheduleBooking } from './entities/train-schedule-booking.entity'; import { TrainScheduleBooking } from './entities/train-schedule-booking.entity';
@@ -13,4 +13,38 @@ export class TrainScheduleBookingsRepository extends BaseRepository<TrainSchedul
) { ) {
super(repository); super(repository);
} }
private repo(manager?: EntityManager) {
return manager ? manager.getRepository(TrainScheduleBooking) : this.repository;
}
async createMany(
records: DeepPartial<TrainScheduleBooking>[],
manager?: EntityManager,
): Promise<TrainScheduleBooking[]> {
if (!records.length) return [];
const repo = this.repo(manager);
return repo.save(repo.create(records));
}
async deleteByScheduleAndBooking(
trainScheduleId: string,
bookingId: string,
manager?: EntityManager,
): Promise<void> {
await this.repo(manager).delete({ trainScheduleId, bookingId });
}
async existsForBooking(bookingId: string, manager?: EntityManager): Promise<boolean> {
const count = await this.repo(manager).count({ where: { bookingId } });
return count > 0;
}
findByBookingIds(bookingIds: string[], manager?: EntityManager): Promise<TrainScheduleBooking[]> {
if (!bookingIds.length) return Promise.resolve([]);
return this.repo(manager).find({
where: { bookingId: In(bookingIds) },
select: { id: true, bookingId: true, trainScheduleId: true },
});
}
} }

View File

@@ -3,22 +3,38 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { TrainScheduleBooking } from './entities/train-schedule-booking.entity'; import { TrainScheduleBooking } from './entities/train-schedule-booking.entity';
import { TrainSchedule } from './entities/train-schedule.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 { WagonBookingAllocation } from './entities/wagon-booking-allocation.entity';
import { TrainScheduleBookingsRepository } from './train-schedule-bookings.repository'; import { TrainScheduleBookingsRepository } from './train-schedule-bookings.repository';
import { TrainSchedulesRepository } from './train-schedules.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'; import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.repository';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([TrainSchedule, TrainScheduleBooking, WagonBookingAllocation])], imports: [
TypeOrmModule.forFeature([
TrainSchedule,
TrainScheduleBooking,
WagonBookingAllocation,
WagonAllocationContainerItem,
WagonAllocationBulkLoad,
]),
],
providers: [ providers: [
TrainSchedulesRepository, TrainSchedulesRepository,
TrainScheduleBookingsRepository, TrainScheduleBookingsRepository,
WagonBookingAllocationsRepository, WagonBookingAllocationsRepository,
WagonAllocationContainerItemsRepository,
WagonAllocationBulkLoadsRepository,
], ],
exports: [ exports: [
TrainSchedulesRepository, TrainSchedulesRepository,
TrainScheduleBookingsRepository, TrainScheduleBookingsRepository,
WagonBookingAllocationsRepository, WagonBookingAllocationsRepository,
WagonAllocationContainerItemsRepository,
WagonAllocationBulkLoadsRepository,
], ],
}) })
export class TrainSchedulesModule {} export class TrainSchedulesModule {}

View File

@@ -1,9 +1,9 @@
import { BaseRepository } from '@edr/api-common'; import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; 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() @Injectable()
export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> { export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
@@ -13,4 +13,48 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
) { ) {
super(repository); super(repository);
} }
private repo(manager?: EntityManager) {
return manager ? manager.getRepository(TrainSchedule) : this.repository;
}
findByIdWithFullGraph(id: string, manager?: EntityManager): Promise<TrainSchedule | null> {
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<TrainSchedule>,
manager?: EntityManager,
): Promise<void> {
await this.repo(manager).update(id, { status, ...extra } as never);
}
} }

View File

@@ -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<WagonAllocationBulkLoad> {
constructor(
@InjectRepository(WagonAllocationBulkLoad)
repository: Repository<WagonAllocationBulkLoad>,
) {
super(repository);
}
private repo(manager?: EntityManager) {
return manager
? manager.getRepository(WagonAllocationBulkLoad)
: this.repository;
}
async createMany(
items: DeepPartial<WagonAllocationBulkLoad>[],
manager?: EntityManager,
): Promise<WagonAllocationBulkLoad[]> {
if (!items.length) return [];
const repo = this.repo(manager);
return repo.save(repo.create(items));
}
async deleteByAllocationIds(allocationIds: string[], manager?: EntityManager): Promise<void> {
if (!allocationIds.length) return;
await this.repo(manager).delete({ wagonBookingAllocationId: In(allocationIds) });
}
}

View File

@@ -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<WagonAllocationContainerItem> {
constructor(
@InjectRepository(WagonAllocationContainerItem)
repository: Repository<WagonAllocationContainerItem>,
) {
super(repository);
}
private repo(manager?: EntityManager) {
return manager
? manager.getRepository(WagonAllocationContainerItem)
: this.repository;
}
async createMany(
items: DeepPartial<WagonAllocationContainerItem>[],
manager?: EntityManager,
): Promise<WagonAllocationContainerItem[]> {
if (!items.length) return [];
const repo = this.repo(manager);
return repo.save(repo.create(items));
}
async deleteByAllocationIds(allocationIds: string[], manager?: EntityManager): Promise<void> {
if (!allocationIds.length) return;
await this.repo(manager).delete({ wagonBookingAllocationId: In(allocationIds) });
}
}

View File

@@ -1,7 +1,7 @@
import { BaseRepository } from '@edr/api-common'; import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { DeepPartial, EntityManager, Repository } from 'typeorm';
import { WagonBookingAllocation } from './entities/wagon-booking-allocation.entity'; import { WagonBookingAllocation } from './entities/wagon-booking-allocation.entity';
@@ -13,4 +13,43 @@ export class WagonBookingAllocationsRepository extends BaseRepository<WagonBooki
) { ) {
super(repository); super(repository);
} }
private repo(manager?: EntityManager) {
return manager ? manager.getRepository(WagonBookingAllocation) : this.repository;
}
async createMany(
records: DeepPartial<WagonBookingAllocation>[],
manager?: EntityManager,
): Promise<WagonBookingAllocation[]> {
if (!records.length) return [];
const repo = this.repo(manager);
return repo.save(repo.create(records));
}
findByScheduleId(trainScheduleId: string, manager?: EntityManager): Promise<WagonBookingAllocation[]> {
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<string[]> {
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;
}
} }

View File

@@ -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');
});
});

View File

@@ -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';
}

View File

@@ -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;
}

View File

@@ -1,5 +1,6 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsUUID } from 'class-validator'; import { Type } from 'class-transformer';
import { IsDateString, IsInt, IsNumber, IsOptional, IsUUID, Min } from 'class-validator';
export class CreateContainerTrainScheduleDto { export class CreateContainerTrainScheduleDto {
@ApiProperty({ format: 'uuid' }) @ApiProperty({ format: 'uuid' })
@@ -13,4 +14,25 @@ export class CreateContainerTrainScheduleDto {
@ApiProperty({ format: 'uuid' }) @ApiProperty({ format: 'uuid' })
@IsUUID() @IsUUID()
locomotiveId!: string; 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;
} }

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -1,5 +1,5 @@
import { ApiPropertyOptional } from '@nestjs/swagger'; import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsOptional, IsUUID } from 'class-validator'; import { IsOptional, IsUUID } from 'class-validator';
export class GetEligibleContainerBookingsDto { export class GetEligibleContainerBookingsDto {
@ApiPropertyOptional({ format: 'uuid' }) @ApiPropertyOptional({ format: 'uuid' })
@@ -12,8 +12,7 @@ export class GetEligibleContainerBookingsDto {
@IsUUID() @IsUUID()
destinationStationId?: string; destinationStationId?: string;
@ApiPropertyOptional({ example: '2026-06-20T08:00:00.000Z' }) @ApiPropertyOptional({ example: 'HOLDING' })
@IsOptional() @IsOptional()
@IsDateString() schedulingStatus?: string;
scheduleDate?: string;
} }

View File

@@ -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[];
}

View File

@@ -0,0 +1,3 @@
import { PreviewTrainScheduleDto } from './preview-train-schedule.dto';
export class PreviewBulkTrainScheduleDto extends PreviewTrainScheduleDto {}

View File

@@ -1,22 +1,3 @@
import { ApiProperty } from '@nestjs/swagger'; import { PreviewTrainScheduleDto } from './preview-train-schedule.dto';
import { ArrayMinSize, IsArray, IsDateString, IsUUID } from 'class-validator';
export class PreviewContainerTrainScheduleDto { export class PreviewContainerTrainScheduleDto extends 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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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> = {},
): 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);
});
});

View File

@@ -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<string, { code: string; count: number }> {
const map = new Map<string, { code: string; count: number }>();
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<string, number>,
fleetTypeCodes: Map<string, string>,
): 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<string, number>,
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));
}

View File

@@ -1,17 +1,27 @@
import { import {
Body, Body,
Controller, Controller,
Delete,
Get, Get,
Param, Param,
ParseUUIDPipe, ParseUUIDPipe,
Patch,
Post, Post,
Query, Query,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; 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 { 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 { 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 { 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'; import { TrainSchedulingService } from './train-scheduling.service';
@ApiTags('train-scheduling') @ApiTags('train-scheduling')
@@ -20,39 +30,176 @@ import { TrainSchedulingService } from './train-scheduling.service';
export class TrainSchedulingController { export class TrainSchedulingController {
constructor(private readonly trainSchedulingService: TrainSchedulingService) {} 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') @Get('container/eligible-bookings')
@TrainSchedulingView()
@ApiOperation({ summary: 'List eligible container bookings' }) @ApiOperation({ summary: 'List eligible container bookings' })
getEligibleContainerBookings(@Query() query: GetEligibleContainerBookingsDto) { getEligibleContainerBookings(@Query() query: GetEligibleContainerBookingsDto) {
return this.trainSchedulingService.getEligibleContainerBookings(query); 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') @Post('container/preview')
@TrainSchedulingView()
@ApiOperation({ summary: 'Preview a container train schedule' }) @ApiOperation({ summary: 'Preview a container train schedule' })
previewContainerTrainSchedule(@Body() dto: PreviewContainerTrainScheduleDto) { previewContainerTrainSchedule(@Body() dto: PreviewContainerTrainScheduleDto) {
return this.trainSchedulingService.previewContainerTrainSchedule(dto); 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') @Post('container/schedules')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Create a container train schedule' }) @ApiOperation({ summary: 'Create a container train schedule' })
createContainerTrainSchedule(@Body() dto: CreateContainerTrainScheduleDto) { createContainerTrainSchedule(@Body() dto: CreateContainerTrainScheduleDto) {
return this.trainSchedulingService.createContainerTrainSchedule(dto); 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') @Get('container/schedules')
@TrainSchedulingView()
@ApiOperation({ summary: 'List container train schedules' }) @ApiOperation({ summary: 'List container train schedules' })
getContainerTrainSchedules() { getContainerTrainSchedules() {
return this.trainSchedulingService.getContainerTrainSchedules(); return this.trainSchedulingService.getContainerTrainSchedules();
} }
@Get('bulk/schedules')
@TrainSchedulingView()
@ApiOperation({ summary: 'List bulk train schedules' })
getBulkTrainSchedules() {
return this.trainSchedulingService.getContainerTrainSchedules();
}
@Get('container/schedules/:id') @Get('container/schedules/:id')
@TrainSchedulingView()
@ApiOperation({ summary: 'Get container train schedule detail' }) @ApiOperation({ summary: 'Get container train schedule detail' })
getContainerTrainScheduleById(@Param('id', ParseUUIDPipe) id: string) { getContainerTrainScheduleById(@Param('id', ParseUUIDPipe) id: string) {
return this.trainSchedulingService.getContainerTrainScheduleById(id); 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') @Post('container/schedules/:id/cancel')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Cancel container train schedule' }) @ApiOperation({ summary: 'Cancel container train schedule' })
cancelTrainSchedule(@Param('id', ParseUUIDPipe) id: string) { cancelTrainSchedule(@Param('id', ParseUUIDPipe) id: string) {
return this.trainSchedulingService.cancelTrainSchedule(id); 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);
}
} }

View File

@@ -2,42 +2,40 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { BookingsModule } from '../bookings/bookings.module'; import { BookingsModule } from '../bookings/bookings.module';
import { Booking } from '../bookings/entities/booking.entity'; import { Container } from '../container-management/entities/container.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
import { LocomotivesModule } from '../locomotives/locomotives.module'; import { LocomotivesModule } from '../locomotives/locomotives.module';
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
import { Locomotive } from '../locomotives/entities/locomotive.entity'; 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 { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { WagonTypesModule } from '../wagon-types/wagon-types.module'; import { WagonTypesModule } from '../wagon-types/wagon-types.module';
import { TrainSet } from '../train-sets/entities/train-set.entity'; import { Wagon } from '../wagons/entities/wagon.entity';
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.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 { TrainSchedulingController } from './train-scheduling.controller'; import { TrainSchedulingController } from './train-scheduling.controller';
import { TrainSchedulingService } from './train-scheduling.service'; import { TrainSchedulingService } from './train-scheduling.service';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([ TypeOrmModule.forFeature([
Booking,
BookingContainer,
Locomotive, Locomotive,
WagonType, WagonType,
TrainSet, TrainSet,
TrainSetWagon, TrainSetWagon,
TrainSchedule, Route,
TrainScheduleBooking, Wagon,
WagonBookingAllocation, Container,
Yard, TrainSchedulingGlobalRules,
]), ]),
BookingsModule, BookingsModule,
LocomotivesModule, LocomotivesModule,
WagonTypesModule, WagonTypesModule,
TrainSetsModule, TrainSetsModule,
TrainSchedulesModule, TrainSchedulesModule,
RuleEngineModule,
], ],
controllers: [TrainSchedulingController], controllers: [TrainSchedulingController],
providers: [TrainSchedulingService], providers: [TrainSchedulingService],

View File

@@ -1,5 +1,10 @@
import { ConflictException } from '@nestjs/common'; 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'; import { TrainSchedulingService } from './train-scheduling.service';
const nw5 = { const nw5 = {
@@ -11,6 +16,7 @@ const nw5 = {
maxWagonsPerTrain: 53, maxWagonsPerTrain: 53,
supportedLoadTypes: ['CONTAINER'], supportedLoadTypes: ['CONTAINER'],
isActive: true, isActive: true,
supportsContainer: true,
}; };
const locomotive = { const locomotive = {
@@ -21,15 +27,29 @@ const locomotive = {
status: 'AVAILABLE', 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 = ( const makeBooking = (
id: string, id: string,
reference: string, reference: string,
weight: number, weight: number,
quantity: number, quantity: number,
containerCode: string, containerCode: string,
wagonsRequired: number,
scheduledDate = '2026-06-20T08:00:00.000Z', scheduledDate = '2026-06-20T08:00:00.000Z',
originYardId = 'yard-origin', originYardId = 'yard-origin',
destinationYardId = 'yard-destination', destinationYardId = 'yard-destination',
extra: Record<string, unknown> = {},
) => ({ ) => ({
id, id,
reference, reference,
@@ -39,75 +59,177 @@ const makeBooking = (
originYardId, originYardId,
destinationYardId, destinationYardId,
status: 'PAID', 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' }, originYard: { label: 'Djibouti', code: 'DJIBOUTI' },
destinationYard: { label: 'Addis Ababa', code: 'ADDIS_ABABA' }, destinationYard: { label: 'Addis Ababa', code: 'ADDIS_ABABA' },
bookingContainers: [ bookingContainers: [
{ {
id: `${id}-line`,
containerTypeId: 'ct-1',
quantity, quantity,
wagonsRequired,
vgmPerUnitTons: weight / quantity,
isOverweight: false,
containerType: { code: containerCode, label: containerCode }, containerType: { code: containerCode, label: containerCode },
}, },
], ],
...extra,
}); });
describe('TrainSchedulingService', () => { describe('TrainSchedulingService', () => {
let service: TrainSchedulingService; let service: TrainSchedulingService;
let dataSource: { let dataSource: { getRepository: jest.Mock; transaction: jest.Mock };
getRepository: jest.Mock; let bookingsRepository: Record<string, jest.Mock>;
transaction: jest.Mock; let locomotivesRepository: { findById: jest.Mock; findAll: jest.Mock };
}; let wagonTypesRepository: { findAll: jest.Mock };
let locomotivesRepository: { let trainSchedulesRepository: Record<string, jest.Mock>;
findById: jest.Mock; let trainScheduleBookingsRepository: Record<string, jest.Mock>;
}; let wagonBookingAllocationsRepository: Record<string, jest.Mock>;
let wagonTypesRepository: { let wagonAllocationContainerItemsRepository: Record<string, jest.Mock>;
findAll: jest.Mock; let wagonAllocationBulkLoadsRepository: Record<string, jest.Mock>;
};
beforeEach(() => { beforeEach(() => {
dataSource = { dataSource = { getRepository: jest.fn(), transaction: jest.fn() };
getRepository: jest.fn(), bookingsRepository = {
transaction: jest.fn(), 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(), findById: jest.fn(),
}; findByIdWithFullGraph: jest.fn(),
wagonTypesRepository = {
findAll: 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( service = new TrainSchedulingService(
dataSource as never, dataSource as never,
bookingsRepository as never,
locomotivesRepository as never, locomotivesRepository as never,
wagonTypesRepository 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 = [ const bookings = [
makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT'), makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20),
makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT'), makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT', 10),
makeBooking('b3', 'BKG-CONT-003', 450, 15, '40FT'),
]; ];
wagonTypesRepository.findAll.mockResolvedValue([nw5]); wagonTypesRepository.findAll.mockResolvedValue([nw5]);
dataSource.getRepository.mockImplementation((entity: { name?: string }) => { bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
if (entity?.name === 'Booking') { trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
return { find: jest.fn().mockResolvedValue(bookings) }; locomotivesRepository.findAll.mockResolvedValue([locomotive]);
}
if (entity?.name === 'TrainScheduleBooking') { 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([]) }; return { find: jest.fn().mockResolvedValue([]) };
} }
if (entity?.name === 'Locomotive') { if (entity === Wagon) {
return { return { find: jest.fn().mockResolvedValue(availableWagons) };
count: jest.fn().mockResolvedValue(2),
find: jest.fn().mockResolvedValue([locomotive]),
};
} }
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({ 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', scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin', originStationId: 'yard-origin',
destinationStationId: 'yard-destination', destinationStationId: 'yard-destination',
@@ -115,40 +237,50 @@ describe('TrainSchedulingService', () => {
expect(result.valid).toBe(true); expect(result.valid).toBe(true);
expect(result.violations).toEqual([]); expect(result.violations).toEqual([]);
expect(result.summary).toEqual({ expect(result.summary.wagonsNeeded).toBe(45);
totalBookings: 3, expect(result.wagonPlan).toHaveLength(45);
totalWeightTons: 1250, });
wagonType: 'NW5',
wagonsNeeded: 18, it('returns soft hold warnings without forceAssign', async () => {
totalLengthMeters: 252, const bookings = [makeBooking('b7', 'BKG-CONT-007', 120, 2, '40FT', 2)];
});
expect(result.wagonPlan).toHaveLength(18); wagonTypesRepository.findAll.mockResolvedValue([nw5]);
expect(result.wagonPlan[0]?.allocations[0]).toEqual({ bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
bookingId: 'b1', trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
bookingReference: 'BKG-CONT-001', locomotivesRepository.findAll.mockResolvedValue([locomotive]);
allocatedWeightTons: 70,
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 () => { 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]); wagonTypesRepository.findAll.mockResolvedValue([nw5]);
dataSource.getRepository.mockImplementation((entity: { name?: string }) => { bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
if (entity?.name === 'Booking') { trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
return { find: jest.fn().mockResolvedValue(bookings) }; locomotivesRepository.findAll.mockResolvedValue([locomotive]);
}
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}`);
});
const result = await service.previewContainerTrainSchedule({ const result = await service.previewContainerTrainSchedule({
bookingIds: ['b6'], bookingIds: ['b6'],
@@ -158,36 +290,70 @@ describe('TrainSchedulingService', () => {
}); });
expect(result.valid).toBe(false); expect(result.valid).toBe(false);
expect(result.summary.totalWeightTons).toBe(3600); expect(result.violations.some((v) => v.includes('overweight'))).toBe(true);
expect(result.violations).toContain( });
'Total booking weight 3600T exceeds max train weight 3500T',
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 () => { it('rejects bookings that are not in schedulable status', async () => {
const bookings = [ const bookings = [
{ { ...makeBooking('b7', 'BKG-CONT-007', 120, 2, '40FT', 2), status: 'APPROVED' },
...makeBooking('b7', 'BKG-CONT-007', 120, 2, '40FT'),
status: 'APPROVED',
},
]; ];
wagonTypesRepository.findAll.mockResolvedValue([nw5]); wagonTypesRepository.findAll.mockResolvedValue([nw5]);
dataSource.getRepository.mockImplementation((entity: { name?: string }) => { bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
if (entity?.name === 'Booking') { trainScheduleBookingsRepository.findByBookingIds.mockResolvedValue([]);
return { find: jest.fn().mockResolvedValue(bookings) }; locomotivesRepository.findAll.mockResolvedValue([locomotive]);
}
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}`);
});
const result = await service.previewContainerTrainSchedule({ const result = await service.previewContainerTrainSchedule({
bookingIds: ['b7'], bookingIds: ['b7'],
@@ -239,13 +405,16 @@ describe('TrainSchedulingService', () => {
}; };
jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never); jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never);
dataSource.getRepository.mockImplementation((entity: { name?: string }) => { dataSource.getRepository.mockImplementation((entity: unknown) => {
if (entity?.name === 'Route') { if ((entity as { name?: string })?.name === 'Route') {
return { findOne: jest.fn().mockResolvedValue(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<string>) => dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise<string>) =>
callback(manager), callback(manager),
); );
@@ -259,7 +428,62 @@ describe('TrainSchedulingService', () => {
expect(trainSetRepo.save).toHaveBeenCalled(); expect(trainSetRepo.save).toHaveBeenCalled();
expect(trainScheduleRepo.save).toHaveBeenCalled(); expect(trainScheduleRepo.save).toHaveBeenCalled();
expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith('loc-1', { status: 'ASSIGNED' }); 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 () => { it('rejects create when the locked locomotive is no longer available', async () => {
@@ -296,4 +520,48 @@ describe('TrainSchedulingService', () => {
}), }),
).rejects.toBeInstanceOf(ConflictException); ).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<void>) =>
callback(manager),
);
await expect(
service.pinWagons(scheduleId, {
assignments: [{ trainSetWagonId: slotId, physicalWagonId: 'wagon-1' }],
}),
).rejects.toBeInstanceOf(ConflictException);
});
}); });

View File

@@ -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);
});
});

View File

@@ -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<number, number[]>();
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<string>();
const containerNumbers = new Set<string>();
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<number, number>();
const slotWeightUsed = new Map<number, number>();
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;
}

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -0,0 +1,49 @@
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
const CARGO_CODE_TO_WAGON_TYPE: Record<string, string> = {
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;
}

View File

@@ -1,10 +1,21 @@
import { BaseEntity } from '@edr/api-common'; import { BaseEntity } from '@edr/api-common';
import { TrainSetWagonStatus } from '@edr/types';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity'; 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 { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import { TrainSet } from './train-set.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' }) @Entity({ schema: 'freight', name: 'train_set_wagons' })
@Index(['trainSetId', 'sequenceNo'], { unique: true }) @Index(['trainSetId', 'sequenceNo'], { unique: true })
export class TrainSetWagon extends BaseEntity { 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 }) @Column({ name: 'assigned_weight_tons', type: 'numeric', precision: 10, scale: 3, default: 0 })
assignedWeightTons!: number; 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) @OneToMany(() => WagonBookingAllocation, (allocation) => allocation.trainSetWagon)
allocations?: WagonBookingAllocation[]; allocations?: WagonBookingAllocation[];
} }

View File

@@ -4,6 +4,10 @@ import { Freight } from '@edr/types';
import { Column, Entity, OneToMany } from 'typeorm'; import { Column, Entity, OneToMany } from 'typeorm';
import { Wagon } from '../../wagons/entities/wagon.entity'; 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' }) @Entity({ schema: 'freight', name: 'trains' })
export class Train extends BaseEntity { export class Train extends BaseEntity {
// --- existing fields (keep for backward compatibility) --- // --- existing fields (keep for backward compatibility) ---

View File

@@ -28,6 +28,18 @@ export class WagonType extends BaseEntity {
@Column({ name: 'is_active', type: 'boolean', default: true }) @Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean; 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) @OneToMany(() => TrainSetWagon, (wagon) => wagon.wagonType)
trainSetWagons?: TrainSetWagon[]; trainSetWagons?: TrainSetWagon[];
} }

View File

@@ -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 { export class CreateWagonDto {
@IsString() @IsString()
@@ -25,10 +26,14 @@ export class CreateWagonDto {
maxPayloadWeight!: number; maxPayloadWeight!: number;
@IsOptional() @IsOptional()
@IsIn(['AVAILABLE', 'ASSIGNED', 'MAINTENANCE', 'RETIRED']) @IsEnum(WagonStatus)
status?: string; status?: WagonStatus;
@IsOptional()
@IsEnum(WagonReadiness)
readiness?: WagonReadiness;
@IsOptional() @IsOptional()
@IsString() @IsString()
notes?: string; notes?: string;
} }

View File

@@ -1,10 +1,29 @@
// apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts // 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 { BaseEntity } from '@edr/api-common';
import { Train } from '../../trains/entities/train.entity'; 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'; 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' }) @Entity({ name: 'wagons', schema: 'freight' })
@Index(['readiness'])
export class Wagon extends BaseEntity { export class Wagon extends BaseEntity {
@Column({ unique: true, name: 'wagon_number' }) @Column({ unique: true, name: 'wagon_number' })
wagonNumber!: string; wagonNumber!: string;
@@ -24,13 +43,30 @@ export class Wagon extends BaseEntity {
@Column({ name: 'max_payload_weight', type: 'decimal', precision: 10, scale: 2 }) @Column({ name: 'max_payload_weight', type: 'decimal', precision: 10, scale: 2 })
maxPayloadWeight!: number; maxPayloadWeight!: number;
@Column({ type: 'varchar', default: 'AVAILABLE' }) @Column({ type: 'varchar', length: 20, default: WagonStatus.Available })
status!: string; // AVAILABLE, ASSIGNED, MAINTENANCE, RETIRED status!: WagonStatusType;
@Column({ type: 'varchar', length: 20, default: WagonReadiness.ImportReady })
readiness!: WagonReadinessType;
@Column({ type: 'text', nullable: true }) @Column({ type: 'text', nullable: true })
notes!: string | null; 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' }) @ManyToOne(() => Train, (train) => train.wagons, { onDelete: 'SET NULL' })
@JoinColumn({ name: 'train_id' }) @JoinColumn({ name: 'train_id' })
train!: Train | null; train!: Train | null;

View File

@@ -1,3 +1,4 @@
import { WagonReadiness, WagonStatus } from '@edr/types';
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike } from 'typeorm'; import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike } from 'typeorm';
@@ -19,7 +20,11 @@ export class WagonsService {
) {} ) {}
async create(dto: CreateWagonDto): Promise<Wagon> { async create(dto: CreateWagonDto): Promise<Wagon> {
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 // Convert undefined to null for nullable fields
if (dto.trainId === undefined) wagon.trainId = null; if (dto.trainId === undefined) wagon.trainId = null;
if (dto.sequenceNumber === undefined) wagon.sequenceNumber = null; if (dto.sequenceNumber === undefined) wagon.sequenceNumber = null;
@@ -30,23 +35,28 @@ export class WagonsService {
const where: FindOptionsWhere<Wagon>[] | FindOptionsWhere<Wagon> = []; const where: FindOptionsWhere<Wagon>[] | FindOptionsWhere<Wagon> = [];
const search = query.search?.trim(); const search = query.search?.trim();
const status = query.status?.trim(); const status = query.status?.trim();
const readiness = query.readiness?.trim();
const trainId = query.trainId?.trim(); const trainId = query.trainId?.trim();
const filters = {
...(status ? { status: status as Wagon['status'] } : {}),
...(readiness ? { readiness: readiness as Wagon['readiness'] } : {}),
...(trainId ? { trainId } : {}),
};
if (search) { if (search) {
where.push({ where.push({
wagonNumber: ILike(`%${search}%`), wagonNumber: ILike(`%${search}%`),
...(status ? { status } : {}), ...filters,
...(trainId ? { trainId } : {}),
}); });
} }
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) ? (query.sortBy as keyof Wagon)
: 'wagonNumber'; : 'wagonNumber';
const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
return this.wagonRepo.find({ return this.wagonRepo.find({
where: search ? where : { ...(status ? { status } : {}), ...(trainId ? { trainId } : {}) }, where: search ? where : filters,
order: { [sortBy]: sortOrder } as FindOptionsOrder<Wagon>, order: { [sortBy]: sortOrder } as FindOptionsOrder<Wagon>,
skip: query.page && query.limit ? (Number(query.page) - 1) * Number(query.limit) : undefined, skip: query.page && query.limit ? (Number(query.page) - 1) * Number(query.limit) : undefined,
take: query.limit ? 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<Wagon> { async assignToTrain(wagonId: string, dto: AssignWagonToTrainDto): Promise<Wagon> {
const wagon = await this.findById(wagonId); const wagon = await this.findById(wagonId);
if (wagon.status === 'ASSIGNED') { if (wagon.status === WagonStatus.Assigned) {
throw new ConflictException('Wagon already assigned to a train'); throw new ConflictException('Wagon already assigned to a train');
} }
@@ -91,7 +101,7 @@ export class WagonsService {
wagon.trainId = train.id; wagon.trainId = train.id;
wagon.sequenceNumber = sequence; wagon.sequenceNumber = sequence;
wagon.status = 'ASSIGNED'; wagon.status = WagonStatus.Assigned;
return this.wagonRepo.save(wagon); return this.wagonRepo.save(wagon);
} }
@@ -99,7 +109,7 @@ export class WagonsService {
const wagon = await this.findById(wagonId); const wagon = await this.findById(wagonId);
wagon.trainId = null; wagon.trainId = null;
wagon.sequenceNumber = null; wagon.sequenceNumber = null;
wagon.status = 'AVAILABLE'; wagon.status = WagonStatus.Available;
return this.wagonRepo.save(wagon); return this.wagonRepo.save(wagon);
} }

View File

@@ -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);
});

View File

@@ -13,7 +13,13 @@ import { Locomotive } from "../modules/locomotives/entities/locomotive.entity";
import { ServiceType } from "../modules/rule-engine/entities/service-type.entity"; import { ServiceType } from "../modules/rule-engine/entities/service-type.entity";
import { Yard } from "../modules/rule-engine/entities/yard.entity"; import { Yard } from "../modules/rule-engine/entities/yard.entity";
import { WagonType } from "../modules/wagon-types/entities/wagon-type.entity"; import { WagonType } from "../modules/wagon-types/entities/wagon-type.entity";
import { CargoType } from "../modules/rule-engine/entities/cargo-type.entity";
import { ContainerType } from "../modules/rule-engine/entities/container-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"; 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() @Injectable()
export class DemoBookingsSeeder { export class DemoBookingsSeeder {
private readonly logger = new Logger(DemoBookingsSeeder.name); private readonly logger = new Logger(DemoBookingsSeeder.name);
@@ -161,15 +200,57 @@ export class DemoBookingsSeeder {
await this.dataSource.transaction(async (manager) => { await this.dataSource.transaction(async (manager) => {
await manager.getRepository(WagonType).upsert( await manager.getRepository(WagonType).upsert(
{ [
code: "NW5", {
name: "Flat Wagon", code: "NW5",
capacityTons: 70, name: "Flat Wagon",
lengthMeters: 14, capacityTons: 70,
maxWagonsPerTrain: 53, lengthMeters: 14,
supportedLoadTypes: ["CONTAINER"], maxWagonsPerTrain: 53,
isActive: true, 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 } }, { conflictPaths: { code: true } },
); );
@@ -320,6 +401,9 @@ export class DemoBookingsSeeder {
await manager await manager
.getRepository(BookingContainer) .getRepository(BookingContainer)
.delete({ bookingId: booking.id }); .delete({ bookingId: booking.id });
const wagonsRequired =
Number(demoBooking.quantity) * Number(containerType.wagonsPerUnit ?? 1);
await manager.getRepository(BookingContainer).insert({ await manager.getRepository(BookingContainer).insert({
id: randomUUID(), id: randomUUID(),
bookingId: booking.id, bookingId: booking.id,
@@ -327,15 +411,139 @@ export class DemoBookingsSeeder {
quantity: demoBooking.quantity, quantity: demoBooking.quantity,
vgmPerUnitTons, vgmPerUnitTons,
totalVgmTons: demoBooking.totalWeightTons, totalVgmTons: demoBooking.totalWeightTons,
wagonsRequired: Math.ceil(demoBooking.totalWeightTons / 70), wagonsRequired,
weightLimitRuleId: null, weightLimitRuleId: null,
isOverweight: demoBooking.totalWeightTons > 70, isOverweight: vgmPerUnitTons > 35,
overweightExcessTons: overweightExcessTons: vgmPerUnitTons > 35 ? vgmPerUnitTons - 35 : null,
demoBooking.totalWeightTons > 70
? demoBooking.totalWeightTons - 70
: 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"); this.logger.log("Seeded demo train scheduling data");

View File

@@ -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-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-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-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<RuleEngineResourceSlug, { view: string; manage: string }> = { const RULE_ENGINE_PERMISSION_IDS: Record<RuleEngineResourceSlug, { view: string; manage: string }> = {
@@ -103,6 +105,10 @@ export const FREIGHT_PERMS = {
operations: 'edr_freight_app:bookings:operations', operations: 'edr_freight_app:bookings:operations',
cancel: 'edr_freight_app:bookings:cancel', cancel: 'edr_freight_app:bookings:cancel',
}, },
trainScheduling: {
view: 'edr_freight_app:train_scheduling:view',
manage: 'edr_freight_app:train_scheduling:manage',
},
ruleEngine: { ruleEngine: {
view: (slug: RuleEngineResourceSlug) => view: (slug: RuleEngineResourceSlug) =>
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`, `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`,
@@ -123,6 +129,8 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.bookings.approveLineStaff, FREIGHT_PERMS.bookings.approveLineStaff,
FREIGHT_PERMS.bookings.rejectApproval, FREIGHT_PERMS.bookings.rejectApproval,
FREIGHT_PERMS.bookings.cancel, FREIGHT_PERMS.bookings.cancel,
FREIGHT_PERMS.trainScheduling.view,
FREIGHT_PERMS.trainScheduling.manage,
...allRuleEngineViewKeys(), ...allRuleEngineViewKeys(),
], ],
director: [ director: [

View File

@@ -115,7 +115,7 @@ export class PricingDataSeeder {
code: "20FT", code: "20FT",
label: "20FT Standard", label: "20FT Standard",
sizeFt: 20, sizeFt: 20,
wagonsPerUnit: 1, wagonsPerUnit: 0.5,
isReefer: false, isReefer: false,
isOpenTop: false, isOpenTop: false,
isActive: true, isActive: true,
@@ -135,7 +135,7 @@ export class PricingDataSeeder {
code: "20FT_REEFER", code: "20FT_REEFER",
label: "20FT Reefer", label: "20FT Reefer",
sizeFt: 20, sizeFt: 20,
wagonsPerUnit: 1, wagonsPerUnit: 0.5,
isReefer: true, isReefer: true,
isOpenTop: false, isOpenTop: false,
isActive: true, isActive: true,
@@ -323,7 +323,11 @@ export class PricingDataSeeder {
private async seedPriorityRules(prRepo: any): Promise<void> { private async seedPriorityRules(prRepo: any): Promise<void> {
const existing = await prRepo.find({ 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) { for (const r of existing) {
await prRepo.remove(r); await prRepo.remove(r);
@@ -343,6 +347,13 @@ export class PricingDataSeeder {
conditionCurrency: null, conditionCurrency: null,
isActive: true, isActive: true,
}), }),
prRepo.create({
code: "GOVERNMENT_ACCOUNT",
label: "Government Account Priority",
score: 50000,
conditionCurrency: null,
isActive: true,
}),
]); ]);
this.logger.log("Seeded priority rules"); this.logger.log("Seeded priority rules");
} }

View File

@@ -21,6 +21,7 @@ import LoginPage from "./pages/auth/LoginPage";
import BookingContractPage from "./pages/bookings/BookingContractPage"; import BookingContractPage from "./pages/bookings/BookingContractPage";
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage"; import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage"; import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
import NewBookingPage from "./pages/bookings/NewBookingPage";
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page"; import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page"; import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
import OverviewPage from "./pages/dashboard/OverviewPage"; import OverviewPage from "./pages/dashboard/OverviewPage";
@@ -35,13 +36,10 @@ import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
import TrainsPage from "./pages/trains/TrainsPage"; import TrainsPage from "./pages/trains/TrainsPage";
import { import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage";
CargoesCrudPage, import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage";
ContainersCrudPage, import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
LocomotivesCrudPage, import FleetResourcePage from "./pages/fleet/FleetResourcePage";
TrainMasterDataPage,
WagonsCrudPage,
} from "./pages/fleet/FleetCrudPages";
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
import TrainDetailPage from "./pages/trains/TrainDetailPage"; import TrainDetailPage from "./pages/trains/TrainDetailPage";
import RoutesPage from "./pages/fleet/RoutesPage"; import RoutesPage from "./pages/fleet/RoutesPage";
@@ -72,6 +70,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
href: "/dashboard/operations/train-scheduling", href: "/dashboard/operations/train-scheduling",
icon: <Train />, icon: <Train />,
}, },
{
label: "Train Schedules v2",
href: "/dashboard/operations/train-scheduling-v2",
icon: <Train />,
},
], ],
}, },
{ {
@@ -159,7 +162,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
label: "Configuration", label: "Configuration",
href: "/dashboard/configuration", href: "/dashboard/configuration",
icon: <Boxes />, icon: <Boxes />,
children: getCategorySidebarChildren("configuration"), children: [
...getCategorySidebarChildren("configuration"),
{
label: "Train scheduling rules",
href: "/dashboard/configuration/train-scheduling-rules",
},
],
}, },
{ {
label: "Rules", label: "Rules",
@@ -235,21 +244,28 @@ const App = () => {
<Route path="overview" element={<OverviewPage />} /> <Route path="overview" element={<OverviewPage />} />
<Route path="booking-requests" element={<BookingRequestsPage />} /> <Route path="booking-requests" element={<BookingRequestsPage />} />
<Route path="booking-requests/new" element={<NewBookingPage />} />
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} /> <Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
<Route <Route
path="booking-requests/:id/contract" path="booking-requests/:id/contract"
element={<BookingContractPage />} element={<BookingContractPage />}
/> />
<Route path="operations/train-scheduling" element={<TrainsPage />} /> <Route path="operations/train-scheduling" element={<TrainsPage />} />
<Route path="trains" element={<TrainMasterDataPage />} /> <Route
<Route path="operations/train-scheduling" element={<TrainsPage />} /> path="operations/train-scheduling-v2"
element={<TrainScheduleV2ListPage />}
/>
<Route
path="operations/train-scheduling-v2/:scheduleId"
element={<TrainScheduleV2DetailPage />}
/>
<Route path="routes" element={<RoutesPage />} /> <Route path="routes" element={<RoutesPage />} />
<Route path="locomotives" element={<LocomotivesCrudPage />} /> <Route path="locomotives" element={<FleetResourcePage />} />
<Route path="trains" element={<TrainMasterDataPage />} /> <Route path="trains" element={<FleetResourcePage />} />
<Route path="trains/:id" element={<TrainDetailPage />} /> <Route path="trains/:id" element={<TrainDetailPage />} />
<Route path="wagons" element={<WagonsCrudPage />} /> <Route path="wagons" element={<FleetResourcePage />} />
<Route path="containers" element={<ContainersCrudPage />} /> <Route path="containers" element={<FleetResourcePage />} />
<Route path="cargoes" element={<CargoesCrudPage />} /> <Route path="cargoes" element={<FleetResourcePage />} />
<Route path="user-management" element={<UserManagementPage />} /> <Route path="user-management" element={<UserManagementPage />} />
<Route path="user-management/users" element={<UsersPage />} /> <Route path="user-management/users" element={<UsersPage />} />
@@ -265,6 +281,10 @@ const App = () => {
path="configuration" path="configuration"
element={<Navigate to="/dashboard/configuration/cargo-types" replace />} element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
/> />
<Route
path="configuration/train-scheduling-rules"
element={<TrainSchedulingGlobalRulesPage />}
/>
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} /> <Route path="configuration/:resource" element={<RuleEngineResourcePage />} />
<Route <Route

View File

@@ -7,6 +7,7 @@ import { useBookingActionDialog } from "./useBookingActionDialog";
import { useAuth } from "@/auth/useAuth"; import { useAuth } from "@/auth/useAuth";
import { import {
getNextPendingApprovalStep, getNextPendingApprovalStep,
isAllocateAction,
isContractNavAction, isContractNavAction,
listRowHasActions, listRowHasActions,
type BookingActionContext, type BookingActionContext,
@@ -20,12 +21,14 @@ interface BookingActionsMenuProps {
className?: string; className?: string;
/** Suppresses table row navigation after menu/dialog close (click-through). */ /** Suppresses table row navigation after menu/dialog close (click-through). */
onSuppressRowClick?: () => void; onSuppressRowClick?: () => void;
onAllocateBooking?: () => void;
} }
export function BookingActionsMenu({ export function BookingActionsMenu({
row, row,
variant = "table", variant = "table",
onSuppressRowClick, onSuppressRowClick,
onAllocateBooking,
}: BookingActionsMenuProps) { }: BookingActionsMenuProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useAuth(); const { user } = useAuth();
@@ -34,6 +37,7 @@ export function BookingActionsMenu({
paymentCurrency: row.paymentCurrency, paymentCurrency: row.paymentCurrency,
reference: row.reference, reference: row.reference,
approvalSteps: row.approvalSteps, approvalSteps: row.approvalSteps,
schedulingStatus: row.schedulingStatus,
}; };
const flow = useBookingActionDialog(row.id, context); const flow = useBookingActionDialog(row.id, context);
@@ -46,6 +50,8 @@ export function BookingActionsMenu({
onSuppressRowClick?.(); onSuppressRowClick?.();
if (isContractNavAction(action.id)) { if (isContractNavAction(action.id)) {
goToContract(); goToContract();
} else if (isAllocateAction(action.id)) {
onAllocateBooking?.();
} else { } else {
flow.openAction(action); flow.openAction(action);
} }

View File

@@ -1,10 +1,13 @@
import { useState } from "react";
import { Download, Zap, FileText, Clock } from "lucide-react"; import { Download, Zap, FileText, Clock } from "lucide-react";
import { Stack, Text, Button } from "@mantine/core"; import { Stack, Text, Button } from "@mantine/core";
import { AllocateBookingWizard } from "@/components/trainScheduling/AllocateBookingWizard";
import type { BookingDetail } from "@/types/booking"; import type { BookingDetail } from "@/types/booking";
import { BookingActionsMenu } from "./BookingActionsMenu"; import { BookingActionsMenu } from "./BookingActionsMenu";
import { SectionCard } from "./detail/SectionCard"; import { SectionCard } from "./detail/SectionCard";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow"; import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import { canAllocateBooking } from "@/features/bookings/booking-actions.config";
import type { useBookingMutations } from "@/hooks/bookings/useBookings"; import type { useBookingMutations } from "@/hooks/bookings/useBookings";
type Mutations = ReturnType<typeof useBookingMutations>; type Mutations = ReturnType<typeof useBookingMutations>;
@@ -18,6 +21,7 @@ interface BookingActionsToolbarProps {
export function BookingActionsToolbar({ booking, mutations }: BookingActionsToolbarProps) { export function BookingActionsToolbar({ booking, mutations }: BookingActionsToolbarProps) {
const row = toBookingListRow(booking); const row = toBookingListRow(booking);
const { status } = booking; const { status } = booking;
const [allocateOpen, setAllocateOpen] = useState(false);
const downloadBlob = async (fn: () => Promise<Blob>, filename: string) => { const downloadBlob = async (fn: () => Promise<Blob>, filename: string) => {
const blob = await fn(); const blob = await fn();
@@ -98,7 +102,11 @@ export function BookingActionsToolbar({ booking, mutations }: BookingActionsTool
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
Confirm each step before it is applied. Confirm each step before it is applied.
</Text> </Text>
<BookingActionsMenu row={row} variant="toolbar" /> <BookingActionsMenu
row={row}
variant="toolbar"
onAllocateBooking={() => setAllocateOpen(true)}
/>
</Stack> </Stack>
</SectionCard> </SectionCard>
@@ -118,6 +126,14 @@ export function BookingActionsToolbar({ booking, mutations }: BookingActionsTool
</Button> </Button>
</SectionCard> </SectionCard>
)} )}
{canAllocateBooking(booking) ? (
<AllocateBookingWizard
booking={booking}
opened={allocateOpen}
onClose={() => setAllocateOpen(false)}
/>
) : null}
</Stack> </Stack>
); );
} }

View File

@@ -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 (
<Group
align="flex-start"
wrap="nowrap"
p="sm"
style={{
border: "1px solid var(--mantine-color-gray-3)",
borderRadius: 8,
}}
>
<Checkbox checked={selected} disabled={disabled} onChange={onToggle} mt={4} />
<Stack gap={4} style={{ flex: 1 }}>
<Group gap="xs">
<Package size={14} />
<Text fw={600} size="sm">{booking.reference}</Text>
{booking.isGovernment ? (
<Badge color="violet" size="xs" leftSection={<Building2 size={10} />}>
Government
</Badge>
) : null}
<Badge variant="outline" size="xs">{booking.freightType}</Badge>
{booking.schedulingStatus ? (
<Badge variant="light" size="xs">{booking.schedulingStatus}</Badge>
) : null}
</Group>
<Text size="xs" c="dimmed">{booking.customerLabel}</Text>
<Group gap={6}>
<Text size="xs">{booking.originLabel}</Text>
<ArrowRight size={12} />
<Text size="xs">{booking.destinationLabel}</Text>
</Group>
<Group gap="sm">
<BookingPriorityBadge score={booking.priorityScore} />
{booking.serviceTypeLabel ? (
<Text size="xs" c="dimmed">
{booking.serviceTypeLabel}
{booking.serviceTypeBonus ? ` (+${booking.serviceTypeBonus} bonus)` : ""}
</Text>
) : null}
</Group>
</Stack>
</Group>
);
}
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<string[]>([]);
const [selectedByBucket, setSelectedByBucket] = useState<Record<string, string[]>>({});
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 <Text size="sm" c="dimmed">Loading operations queue</Text>;
}
if (!government.length && !commercial.length) {
return (
<Text size="sm" c="dimmed">
No PAID bookings ready to allocate.
</Text>
);
}
return (
<Stack gap="lg">
{government.length > 0 ? (
<Paper withBorder p="md" radius="md">
<Group justify="space-between" mb="md">
<Stack gap={2}>
<Title order={5}>Government priority</Title>
<Text size="xs" c="dimmed">
Served first not grouped by 3-hour window
</Text>
</Stack>
<Group gap="xs">
<Badge variant="light">{govSelection.length} selected</Badge>
<Button
size="compact-sm"
color="violet"
disabled={!govSelection.length}
onClick={() => onAllocate(govSelection)}
>
Allocate
</Button>
</Group>
</Group>
<Stack gap="sm">
{government.map((booking) => (
<BookingQueueRow
key={booking.id}
booking={booking}
selected={govSelection.includes(booking.id)}
disabled={!allocatable(booking)}
onToggle={() => toggleGov(booking.id)}
/>
))}
</Stack>
</Paper>
) : null}
{commercial.length > 0 ? (
<Accordion defaultValue={commercial[0]?.key} variant="separated" radius="md">
{commercial.map((bucket) => {
const selected = bucketSelection(bucket.key, bucket.bookings);
return (
<Accordion.Item key={bucket.key} value={bucket.key}>
<Accordion.Control>
<Group justify="space-between" wrap="nowrap" pr="md">
<Stack gap={2}>
<Text fw={600} size="sm">{bucket.label}</Text>
<Text size="xs" c="dimmed">
{bucket.bookings.length} commercial booking
{bucket.bookings.length === 1 ? "" : "s"}
</Text>
</Stack>
<Group gap="xs">
<Badge variant="light">{selected.length} selected</Badge>
<Button
size="compact-sm"
color="green"
disabled={!selected.length}
onClick={(e) => {
e.stopPropagation();
onAllocate(selected);
}}
>
Allocate
</Button>
</Group>
</Group>
</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
{bucket.bookings.map((booking) => (
<BookingQueueRow
key={booking.id}
booking={booking}
selected={selected.includes(booking.id)}
disabled={!allocatable(booking)}
onToggle={() => toggleBucket(bucket.key, booking.id)}
/>
))}
</Stack>
</Accordion.Panel>
</Accordion.Item>
);
})}
</Accordion>
) : null}
</Stack>
);
}

View File

@@ -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<BookingListRow>[] = [
{
id: "reference",
header: "Booking",
cell: ({ row }) => (
<Stack gap={2}>
<Group gap={6}>
<Text fw={600} size="sm">{row.original.reference}</Text>
{row.original.isGovernment ? (
<Badge color="violet" size="xs">Government</Badge>
) : null}
</Group>
<Text size="xs" c="dimmed">{row.original.customerLabel}</Text>
</Stack>
),
},
{
id: "route",
header: "Route",
cell: ({ row }) => (
<Group gap={6}>
<Text size="sm">{row.original.originLabel}</Text>
<ArrowRight size={12} />
<Text size="sm">{row.original.destinationLabel}</Text>
</Group>
),
},
{
id: "scheduled",
header: "Scheduled",
cell: ({ row }) => (
<Text size="sm">{String(row.original.scheduledDate).slice(0, 16)}</Text>
),
},
{
id: "status",
header: "Scheduling",
cell: ({ row }) =>
row.original.schedulingStatus ? (
<SchedulingStatusBadge status={row.original.schedulingStatus} />
) : (
<Badge variant="light"></Badge>
),
},
{
id: "actions",
header: "",
cell: ({ row }) => (
<Group gap="xs">
<Button
component={Link}
to={`/dashboard/booking-requests/${row.original.id}`}
variant="light"
size="compact-sm"
>
View booking
</Button>
{row.original.trainScheduleId ? (
<Button
component={Link}
to={`/dashboard/operations/train-scheduling-v2/${row.original.trainScheduleId}`}
variant="subtle"
size="compact-sm"
leftSection={<ExternalLink size={14} />}
>
Train schedule
</Button>
) : null}
</Group>
),
},
];
return (
<DataTable
columns={columns}
data={bookings}
status={isLoading ? "loading" : "success"}
emptyMessage="No bookings currently assigned to a train schedule"
/>
);
}

View File

@@ -4,6 +4,7 @@ import { Paper, Group, Stack, Title, Text, Button, Box } from "@mantine/core";
import type { BookingDetail } from "@/types/booking"; import type { BookingDetail } from "@/types/booking";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge"; import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import { NextStepBanner } from "@/components/bookings/NextStepBanner"; import { NextStepBanner } from "@/components/bookings/NextStepBanner";
import { detailStyles, formatDate } from "./booking-detail.styles"; import { detailStyles, formatDate } from "./booking-detail.styles";
@@ -52,7 +53,15 @@ export function BookingRequestHero({
</Title> </Title>
<BookingStatusBadge status={booking.status} /> <BookingStatusBadge status={booking.status} />
<BookingPriorityBadge score={booking.priorityScore} /> <BookingPriorityBadge score={booking.priorityScore} />
{booking.schedulingStatus ? (
<SchedulingStatusBadge status={booking.schedulingStatus} />
) : null}
</Group> </Group>
{booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? (
<Text size="xs" c="yellow.8">
Hold expires {new Date(booking.holdExpiresAt).toLocaleString()}
</Text>
) : null}
{booking.nextStep && ( {booking.nextStep && (
<Box maw={520}> <Box maw={520}>

View File

@@ -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<number>();
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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild><Button size="sm"><Plus className="mr-2 h-4 w-4" />Assign Container</Button></DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Assign Container to Wagon</DialogTitle></DialogHeader>
<div className="space-y-4">
<div><Label>Container</Label><Select value={containerId} onValueChange={setContainerId}><SelectTrigger><SelectValue placeholder="Select container" /></SelectTrigger><SelectContent>{available?.map(c => <SelectItem key={c.id} value={c.id}>{c.containerNumber}</SelectItem>)}</SelectContent></Select></div>
<div><Label>Position (optional)</Label><Input type="number" value={position ?? ''} onChange={e => setPosition(parseInt(e.target.value) || undefined)} /></div>
<Button onClick={handleAssign} disabled={assign.isPending}>Assign</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -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<Partial<Cargo>>({
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader><DialogTitle>{cargo ? 'Edit Cargo' : 'Create New Cargo'}</DialogTitle></DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Cargo Number *</Label>
<Input value={formData.cargoNumber} onChange={e => setFormData({...formData, cargoNumber: e.target.value})} required />
</div>
<div>
<Label>Cargo Type *</Label>
<Select
value={formData.cargoTypeId || ''}
onValueChange={(val) => setFormData({ ...formData, cargoTypeId: val })}
>
<SelectTrigger>
<SelectValue placeholder="Select cargo type..." />
</SelectTrigger>
<SelectContent>
{cargoTypes?.map((type: any) => (
<SelectItem key={type.id} value={type.id}>{type.cargo_type_name || type.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label>Weight (kg) *</Label>
<Input type="number" value={formData.weight} onChange={e => setFormData({...formData, weight: parseFloat(e.target.value)})} required />
</div>
<div>
<Label>Remarks</Label>
<Textarea value={formData.remarks} onChange={e => setFormData({...formData, remarks: e.target.value})} rows={3} />
</div>
<DialogFooter>
<Button type="submit" disabled={isLoading}>{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}{cargo ? 'Update' : 'Create'}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,246 +0,0 @@
import { useState, useEffect } from 'react';
import { toast } from 'sonner';
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 { useContainerTypes } from './use-container-types';
import { useContainerMutations } from './use-containers';
import { Loader2 } from 'lucide-react';
interface Container {
id: string;
containerNumber: string;
containerTypeId: string;
wagonId?: string;
status: 'AVAILABLE' | 'IN_USE' | 'MAINTENANCE' | 'RETIRED';
capacity: number;
weight: number;
remarks?: string;
}
interface Wagon {
id: string;
wagonNumber: string;
}
interface ContainerFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
container?: Container | null;
wagons: Wagon[];
onSuccess?: () => void;
}
export default function ContainerFormDialog({
open,
onOpenChange,
container,
wagons = [],
onSuccess,
}: ContainerFormDialogProps) {
const { data: containerTypes } = useContainerTypes();
const { createContainer, updateContainer } = useContainerMutations();
const [formData, setFormData] = useState<Partial<Container>>({
containerNumber: '',
containerTypeId: '',
status: 'AVAILABLE',
capacity: 0,
weight: 0,
remarks: '',
});
useEffect(() => {
if (container) {
setFormData(container);
} else {
setFormData({
containerNumber: '',
containerTypeId: '',
status: 'AVAILABLE',
capacity: 0,
weight: 0,
remarks: '',
});
}
}, [container, open]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.containerNumber || !formData.containerTypeId) {
toast.error('Please fill in all required fields');
return;
}
if (container?.id) {
updateContainer.mutate(
{ id: container.id, data: formData },
{ onSuccess: () => { onOpenChange(false); onSuccess?.(); } }
);
} else {
createContainer.mutate(formData, {
onSuccess: () => { onOpenChange(false); onSuccess?.(); }
});
}
};
const isLoading = createContainer.isPending || updateContainer.isPending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>
{container ? 'Edit Container' : 'Create New Container'}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="containerNumber">Container Number *</Label>
<Input
id="containerNumber"
value={formData.containerNumber || ''}
onChange={(e) =>
setFormData({ ...formData, containerNumber: e.target.value })
}
placeholder="e.g., CNT001"
required
/>
</div>
<div>
<Label htmlFor="containerTypeId">Container Type *</Label>
<Select
value={formData.containerTypeId || ''}
onValueChange={(val:any) => setFormData({ ...formData, containerTypeId: val })}
>
<SelectTrigger id="containerTypeId">
<SelectValue placeholder="Select container type..." />
</SelectTrigger>
<SelectContent>
{containerTypes?.map((type: any) => (
<SelectItem key={type.id} value={type.id}>{type.name || type.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="wagonId">Wagon (Optional)</Label>
<Select
value={formData.wagonId || 'none'}
onValueChange={(val:any) => setFormData({ ...formData, wagonId: val === 'none' ? undefined : val })}
>
<SelectTrigger id="wagonId">
<SelectValue placeholder="Select a wagon..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{wagons.map((wagon) => (
<SelectItem key={wagon.id} value={wagon.id}>
{wagon.wagonNumber}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="status">Status</Label>
<Select
value={formData.status || 'AVAILABLE'}
onValueChange={(val:any) => setFormData({ ...formData, status: val })}
>
<SelectTrigger id="status">
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="AVAILABLE">Available</SelectItem>
<SelectItem value="IN_USE">In Use</SelectItem>
<SelectItem value="MAINTENANCE">Maintenance</SelectItem>
<SelectItem value="RETIRED">Retired</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="capacity">Capacity *</Label>
<Input
id="capacity"
type="number"
value={formData.capacity || ''}
onChange={(e) =>
setFormData({
...formData,
capacity: parseFloat(e.target.value) || 0,
})
}
placeholder="0"
required
/>
</div>
<div>
<Label htmlFor="weight">Weight (kg)</Label>
<Input
id="weight"
type="number"
value={formData.weight || ''}
onChange={(e) =>
setFormData({
...formData,
weight: parseFloat(e.target.value) || 0,
})
}
placeholder="0"
/>
</div>
</div>
<div>
<Label htmlFor="remarks">Remarks</Label>
<Textarea
id="remarks"
value={formData.remarks || ''}
onChange={(e) =>
setFormData({ ...formData, remarks: e.target.value })
}
placeholder="Add any additional notes..."
rows={3}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isLoading}
>
Cancel
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{container ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,44 +0,0 @@
import { useContainersByWagon, useUnassignContainer } from './use-containers';
import { Button } from '@/components/ui/button';
import { Trash2 } from 'lucide-react';
import type { Container } from './container.service';
export function ContainersTable({ wagonId }: { wagonId: string }) {
const { data: containers, refetch } = useContainersByWagon(wagonId);
const unassign = useUnassignContainer();
if (!containers?.length) return <div className="text-muted-foreground">No containers assigned.</div>;
return (
<table className="w-full table-fixed">
<thead>
<tr>
<th className="text-left">Number</th>
<th className="text-left">Type</th>
<th className="text-left">Position</th>
<th className="text-left">Status</th>
<th className="text-left">Actions</th>
</tr>
</thead>
<tbody>
{containers.map((container: Container) => (
<tr key={container.id}>
<td className="py-2">{container.containerNumber}</td>
<td className="py-2">{container.containerTypeId}</td>
<td className="py-2">{container.position}</td>
<td className="py-2">{container.status}</td>
<td className="py-2">
<Button
variant="ghost"
size="icon"
onClick={() => unassign.mutateAsync(container.id).then(() => refetch())}
>
<Trash2 className="h-4 w-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
);
}

View File

@@ -1,15 +0,0 @@
import { api } from '../../auth/http';
type ListResponse<T> = T[] | { data: T[] };
const asList = <T>(payload: ListResponse<T>): T[] =>
Array.isArray(payload) ? payload : payload.data;
export const cargoTypesService = {
async getCargoTypes() {
const response = await api.get<ListResponse<unknown>>('/cargo-types', {
params: { isActive: true, pageSize: 500 },
});
return asList(response.data);
},
};

View File

@@ -1,19 +0,0 @@
import { api } from "../../auth/http";
export const cargoService = {
async getCargoes() {
const response = await api.get('/cargoes');
return response.data;
},
async createCargo(data: any) {
const response = await api.post('/cargoes', data);
return response.data;
},
async updateCargo(id: string, data: any) {
const response = await api.patch(`/cargoes/${id}`, data);
return response.data;
},
async deleteCargo(id: string) {
await api.delete(`/cargoes/${id}`);
},
};

View File

@@ -1,15 +0,0 @@
import { api } from '../../auth/http';
type ListResponse<T> = T[] | { data: T[] };
const asList = <T>(payload: ListResponse<T>): T[] =>
Array.isArray(payload) ? payload : payload.data;
export const containerTypesService = {
async getContainerTypes() {
const response = await api.get<ListResponse<unknown>>('/container-types', {
params: { isActive: true, pageSize: 500 },
});
return asList(response.data);
},
};

View File

@@ -1,31 +0,0 @@
import { api } from "../../auth/http";
export const containerService = {
async getContainers() {
const response = await api.get('/containers');
return response.data;
},
async getContainersByWagon(wagonId: string) {
const response = await api.get('/containers', { params: { wagonId } });
return response.data;
},
async createContainer(data: any) {
const response = await api.post('/containers', data);
return response.data;
},
async updateContainer(id: string, data: any) {
const response = await api.patch(`/containers/${id}`, data);
return response.data;
},
async deleteContainer(id: string) {
await api.delete(`/containers/${id}`);
},
async assignToWagon(containerId: string, wagonId: string, position?: number) {
const response = await api.post(`/containers/${containerId}/assign-wagon`, { wagonId, position });
return response.data;
},
async unassignFromWagon(containerId: string) {
const response = await api.post(`/containers/${containerId}/unassign-wagon`);
return response.data;
},
};

View File

@@ -1,12 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { cargoTypesService } from './cargo-types.service';
export const CARGO_TYPES_QUERY_KEY = ['cargo-types'];
export function useCargoTypes() {
return useQuery({
queryKey: CARGO_TYPES_QUERY_KEY,
queryFn: () => cargoTypesService.getCargoTypes(),
staleTime: Infinity,
});
}

View File

@@ -1,42 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { cargoService } from './cargo.service';
import { toast } from 'sonner';
export const CARGOES_QUERY_KEY = ['cargoes'];
export function useCargoes() {
return useQuery({
queryKey: CARGOES_QUERY_KEY,
queryFn: () => cargoService.getCargoes(),
});
}
export function useCargoMutations() {
const queryClient = useQueryClient();
const createCargo = useMutation({
mutationFn: (data: any) => cargoService.createCargo(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: CARGOES_QUERY_KEY });
toast.success('Cargo created successfully');
},
});
const updateCargo = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => cargoService.updateCargo(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: CARGOES_QUERY_KEY });
toast.success('Cargo updated successfully');
},
});
const deleteCargo = useMutation({
mutationFn: (id: string) => cargoService.deleteCargo(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: CARGOES_QUERY_KEY });
toast.success('Cargo deleted successfully');
},
});
return { createCargo, updateCargo, deleteCargo };
}

Some files were not shown because too many files have changed in this diff Show More