From 6d0cf50b4dc808b4b4729eafd3220bfb00c21f95 Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 14 Jul 2026 11:06:49 +0000 Subject: [PATCH] train --- .../src/common/booking-guards.ts | 8 + .../migrations/2150000000000-TrainBuilder.ts | 105 +++ ...0000-MultiWagonTypePerCargoAndContainer.ts | 119 +++ ...70000000000-CreateWagonTransferRequests.ts | 51 ++ .../modules/bookings/bookings.repository.ts | 9 +- .../rule-engine/dto/create-cargo-type.dto.ts | 11 +- .../dto/create-container-type.dto.ts | 11 +- .../rule-engine/entities/cargo-type.entity.ts | 34 +- .../entities/container-type.entity.ts | 25 +- .../repositories/cargo-types.repository.ts | 16 +- .../container-types.repository.ts | 16 +- .../services/cargo-types.service.ts | 12 +- .../services/container-types.service.ts | 12 +- .../train-schedules.repository.ts | 1 + .../booking-batch.service.spec.ts | 4 +- .../train-scheduling/booking-batch.service.ts | 18 +- .../dto/available-trains-query.dto.ts | 8 + .../create-container-train-schedule.dto.ts | 17 +- .../train-scheduling.controller.ts | 13 + .../train-scheduling.service.spec.ts | 29 +- .../train-scheduling.service.ts | 710 ++++++++++++------ .../train-scheduling/wagon-plan-flex.util.ts | 299 ++++++++ .../train-scheduling/wagon-plan.util.ts | 2 +- .../train-sets/entities/train-set.entity.ts | 9 + .../trains/dto/assign-train-wagons.dto.ts | 14 + .../src/modules/trains/dto/build-train.dto.ts | 51 ++ .../trains/dto/list-built-trains-query.dto.ts | 22 + .../trains/dto/reorder-train-wagons.dto.ts | 14 + .../dto/update-train-locomotives.dto.ts | 14 + .../entities/train-locomotive.entity.ts | 34 + .../modules/trains/entities/train.entity.ts | 26 +- .../trains/train-builder.controller.ts | 91 +++ .../modules/trains/train-builder.service.ts | 507 +++++++++++++ .../src/modules/trains/trains.module.ts | 13 +- .../wagons/dto/create-transfer-request.dto.ts | 28 + .../dto/fulfill-transfer-request.dto.ts | 13 + .../entities/wagon-transfer-request.entity.ts | 62 ++ .../wagon-transfer-requests.controller.ts | 76 ++ .../wagons/wagon-transfer-requests.service.ts | 153 ++++ .../src/modules/wagons/wagons.module.ts | 15 +- .../src/seed/freight-permissions.registry.ts | 6 + apps/edr-freight-web/backoffice/src/App.tsx | 41 + .../ruleEngine/RuleEngineFormDialog.tsx | 46 +- .../trainBuilder/AvailableWagonsPanel.tsx | 152 ++++ .../trainBuilder/BuildTrainModal.tsx | 182 +++++ .../trainBuilder/ChangeLocomotivesModal.tsx | 128 ++++ .../trainBuilder/ConsistWagonList.tsx | 171 +++++ .../trainBuilder/TrainConsistStrip.tsx | 159 ++++ .../components/trainBuilder/trainStatus.ts | 25 + .../wagons/WagonTransferRequestsModal.tsx | 344 +++++++++ .../wagons/WagonYardWorkspaceModal.tsx | 64 +- .../backoffice/src/constants/QUERY_KEYS.ts | 8 + .../src/pages/fleet/FleetResourcePage.tsx | 40 +- .../src/pages/ruleEngine/CargoTypesPage.tsx | 35 +- .../ruleEngine/RuleEngineResourcePage.tsx | 11 +- .../src/pages/ruleEngine/config/resources.ts | 17 +- .../trainBuilder/TrainBuilderDetailPage.tsx | 355 +++++++++ .../trainBuilder/TrainBuilderListPage.tsx | 336 +++++++++ .../TrainScheduleV2ListPage.tsx | 84 ++- .../backoffice/src/services/api.ts | 163 ++++ .../src/services/trainBuilder.service.ts | 172 +++++ .../backoffice/src/services/wagon.service.ts | 50 ++ .../backoffice/src/types/trainScheduling.ts | 12 +- packages/types/src/freight/index.ts | 27 + 64 files changed, 4896 insertions(+), 404 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2150000000000-TrainBuilder.ts create mode 100644 apps/edr-freight-api/src/migrations/2160000000000-MultiWagonTypePerCargoAndContainer.ts create mode 100644 apps/edr-freight-api/src/migrations/2170000000000-CreateWagonTransferRequests.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/dto/available-trains-query.dto.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts create mode 100644 apps/edr-freight-api/src/modules/trains/dto/assign-train-wagons.dto.ts create mode 100644 apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts create mode 100644 apps/edr-freight-api/src/modules/trains/dto/list-built-trains-query.dto.ts create mode 100644 apps/edr-freight-api/src/modules/trains/dto/reorder-train-wagons.dto.ts create mode 100644 apps/edr-freight-api/src/modules/trains/dto/update-train-locomotives.dto.ts create mode 100644 apps/edr-freight-api/src/modules/trains/entities/train-locomotive.entity.ts create mode 100644 apps/edr-freight-api/src/modules/trains/train-builder.controller.ts create mode 100644 apps/edr-freight-api/src/modules/trains/train-builder.service.ts create mode 100644 apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts create mode 100644 apps/edr-freight-api/src/modules/wagons/dto/fulfill-transfer-request.dto.ts create mode 100644 apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts create mode 100644 apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts create mode 100644 apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/trainBuilder/AvailableWagonsPanel.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/trainBuilder/ChangeLocomotivesModal.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/trainBuilder/ConsistWagonList.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/trainBuilder/TrainConsistStrip.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/trainBuilder/trainStatus.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/wagons/WagonTransferRequestsModal.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderListPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index 7eae4e94d..d3344aa5a 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -26,6 +26,14 @@ export const FleetView = () => BookingStaff(FREIGHT_PERMS.fleet.view); export const FleetManage = () => BookingStaff(FREIGHT_PERMS.fleet.manage); +/** Requester creates a wagon-transfer request (count-only, no wagon picks). */ +export const WagonTransferRequest = () => + BookingStaff(FREIGHT_PERMS.wagons.transferRequest); + +/** OCC fulfils a wagon-transfer request — picks the wagons and executes the move. */ +export const WagonTransferFulfill = () => + BookingStaff(FREIGHT_PERMS.wagons.transferFulfill); + /** Org-administration endpoints (user mgmt, billing config, company CRUD, settings). */ export const FreightAdmin = () => BookingStaff(FREIGHT_PERMS.admin); diff --git a/apps/edr-freight-api/src/migrations/2150000000000-TrainBuilder.ts b/apps/edr-freight-api/src/migrations/2150000000000-TrainBuilder.ts new file mode 100644 index 000000000..24fa50feb --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2150000000000-TrainBuilder.ts @@ -0,0 +1,105 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Train Builder: a `Train` becomes a first-class buildable consist — a coded + * train (e.g. 81001) assembled in one yard from 2+ locomotives and ordered + * wagons, then reused by scheduling ("schedule the train" instead of picking + * locomotives per departure). + * + * - `freight.train_locomotives` — link table train ⇄ locomotive with an order + * index (mirrors `train_set_locomotives`). + * - `trains.current_yard_id` — yard the train sits in; wagons/locomotives may + * only be attached from this yard. + * - `train_sets.train_id` — which built train an operational set was formed + * from, so schedules can surface the train code and the lifecycle can sync + * the train's status/yard on dispatch/arrival/cancel. + * + * NOTE: the shared dev DB has no applied migration history, so this is also + * hand-applied there. IF NOT EXISTS keeps that idempotent. + */ +export class TrainBuilder2150000000000 implements MigrationInterface { + name = 'TrainBuilder2150000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.train_locomotives ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + train_id uuid NOT NULL, + locomotive_id uuid NOT NULL, + sequence_no int NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT "PK_train_locomotives" PRIMARY KEY (id), + CONSTRAINT "FK_train_locomotives_train" FOREIGN KEY (train_id) + REFERENCES freight.trains (id) ON DELETE CASCADE, + CONSTRAINT "FK_train_locomotives_locomotive" FOREIGN KEY (locomotive_id) + REFERENCES freight.locomotives (id) + ); + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_train_locomotives_train_loco" + ON freight.train_locomotives (train_id, locomotive_id); + `); + + await queryRunner.query(` + ALTER TABLE freight.trains + ADD COLUMN IF NOT EXISTS current_yard_id uuid; + `); + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'FK_trains_current_yard' + ) THEN + ALTER TABLE freight.trains + ADD CONSTRAINT "FK_trains_current_yard" FOREIGN KEY (current_yard_id) + REFERENCES freight.yards (id) ON DELETE SET NULL; + END IF; + END $$; + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_trains_current_yard_id" + ON freight.trains (current_yard_id); + `); + + await queryRunner.query(` + ALTER TABLE freight.train_sets + ADD COLUMN IF NOT EXISTS train_id uuid; + `); + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'FK_train_sets_train' + ) THEN + ALTER TABLE freight.train_sets + ADD CONSTRAINT "FK_train_sets_train" FOREIGN KEY (train_id) + REFERENCES freight.trains (id) ON DELETE SET NULL; + END IF; + END $$; + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_train_sets_train_id" + ON freight.train_sets (train_id); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_train_sets_train_id";`); + await queryRunner.query(` + ALTER TABLE freight.train_sets + DROP CONSTRAINT IF EXISTS "FK_train_sets_train", + DROP COLUMN IF EXISTS train_id; + `); + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_trains_current_yard_id";`); + await queryRunner.query(` + ALTER TABLE freight.trains + DROP CONSTRAINT IF EXISTS "FK_trains_current_yard", + DROP COLUMN IF EXISTS current_yard_id; + `); + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_train_locomotives_train_loco";`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.train_locomotives;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/2160000000000-MultiWagonTypePerCargoAndContainer.ts b/apps/edr-freight-api/src/migrations/2160000000000-MultiWagonTypePerCargoAndContainer.ts new file mode 100644 index 000000000..0fc391904 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2160000000000-MultiWagonTypePerCargoAndContainer.ts @@ -0,0 +1,119 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * A container type / cargo type can now be carried by SEVERAL wagon types + * (e.g. a 20ft container rides NX70 or NW5). Replaces the single + * `wagon_type_id` FK on both tables with proper link tables; train scheduling + * resolves the wagon type from the list, picking whichever type the schedule's + * built train (or the yard) actually has. + * + * Backfills one link row from each existing `wagon_type_id`, then drops the + * old column — the single-FK field is removed from the API and UI entirely. + * + * NOTE: the shared dev DB has no applied migration history, so this is also + * hand-applied there. IF NOT EXISTS keeps that idempotent. + */ +export class MultiWagonTypePerCargoAndContainer2160000000000 implements MigrationInterface { + name = 'MultiWagonTypePerCargoAndContainer2160000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.container_type_wagon_types ( + container_type_id uuid NOT NULL, + wagon_type_id uuid NOT NULL, + CONSTRAINT "PK_container_type_wagon_types" PRIMARY KEY (container_type_id, wagon_type_id), + CONSTRAINT "FK_ctwt_container_type" FOREIGN KEY (container_type_id) + REFERENCES freight.container_types (id) ON DELETE CASCADE, + CONSTRAINT "FK_ctwt_wagon_type" FOREIGN KEY (wagon_type_id) + REFERENCES freight.wagon_types (id) ON DELETE RESTRICT + ); + `); + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.cargo_type_wagon_types ( + cargo_type_id uuid NOT NULL, + wagon_type_id uuid NOT NULL, + CONSTRAINT "PK_cargo_type_wagon_types" PRIMARY KEY (cargo_type_id, wagon_type_id), + CONSTRAINT "FK_cgwt_cargo_type" FOREIGN KEY (cargo_type_id) + REFERENCES freight.cargo_types (id) ON DELETE CASCADE, + CONSTRAINT "FK_cgwt_wagon_type" FOREIGN KEY (wagon_type_id) + REFERENCES freight.wagon_types (id) ON DELETE RESTRICT + ); + `); + + // Backfill from the old single FK (column may already be gone on re-run). + await queryRunner.query(` + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'freight' AND table_name = 'container_types' + AND column_name = 'wagon_type_id' + ) THEN + INSERT INTO freight.container_type_wagon_types (container_type_id, wagon_type_id) + SELECT ct.id, ct.wagon_type_id + FROM freight.container_types ct + WHERE ct.wagon_type_id IS NOT NULL + ON CONFLICT DO NOTHING; + END IF; + END $$; + `); + await queryRunner.query(` + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'freight' AND table_name = 'cargo_types' + AND column_name = 'wagon_type_id' + ) THEN + INSERT INTO freight.cargo_type_wagon_types (cargo_type_id, wagon_type_id) + SELECT cg.id, cg.wagon_type_id + FROM freight.cargo_types cg + WHERE cg.wagon_type_id IS NOT NULL + ON CONFLICT DO NOTHING; + END IF; + END $$; + `); + + // Old single-FK column is fully retired (API + UI now use the lists). + await queryRunner.query(` + ALTER TABLE freight.container_types DROP COLUMN IF EXISTS wagon_type_id; + `); + await queryRunner.query(` + ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS wagon_type_id; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.container_types ADD COLUMN IF NOT EXISTS wagon_type_id uuid + REFERENCES freight.wagon_types (id) ON DELETE RESTRICT; + `); + await queryRunner.query(` + ALTER TABLE freight.cargo_types ADD COLUMN IF NOT EXISTS wagon_type_id uuid + REFERENCES freight.wagon_types (id) ON DELETE RESTRICT; + `); + // Restore the first linked wagon type per row, then drop the link tables. + await queryRunner.query(` + UPDATE freight.container_types ct + SET wagon_type_id = link.wagon_type_id + FROM ( + SELECT DISTINCT ON (container_type_id) container_type_id, wagon_type_id + FROM freight.container_type_wagon_types + ORDER BY container_type_id, wagon_type_id + ) link + WHERE link.container_type_id = ct.id; + `); + await queryRunner.query(` + UPDATE freight.cargo_types cg + SET wagon_type_id = link.wagon_type_id + FROM ( + SELECT DISTINCT ON (cargo_type_id) cargo_type_id, wagon_type_id + FROM freight.cargo_type_wagon_types + ORDER BY cargo_type_id, wagon_type_id + ) link + WHERE link.cargo_type_id = cg.id; + `); + await queryRunner.query(`DROP TABLE IF EXISTS freight.container_type_wagon_types;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.cargo_type_wagon_types;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/2170000000000-CreateWagonTransferRequests.ts b/apps/edr-freight-api/src/migrations/2170000000000-CreateWagonTransferRequests.ts new file mode 100644 index 000000000..2b73eaa0c --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2170000000000-CreateWagonTransferRequests.ts @@ -0,0 +1,51 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Two-person wagon-transfer request queue. A requester records a count-only + * request (N wagons of a type, from yard → to yard); OCC staff later pick the + * physical wagons and execute the move. Replaces the single-step instant + * bulk-transfer as the customer-facing yard-to-yard relocation path. + */ +export class CreateWagonTransferRequests2170000000000 + implements MigrationInterface +{ + name = 'CreateWagonTransferRequests2170000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.wagon_transfer_requests ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + from_yard_id uuid NOT NULL, + to_yard_id uuid NOT NULL, + wagon_type_id uuid NOT NULL, + quantity integer NOT NULL, + status varchar(20) NOT NULL DEFAULT 'PENDING', + requested_by_user_id uuid NULL, + fulfilled_by_user_id uuid NULL, + fulfilled_at timestamptz NULL, + note text NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL, + CONSTRAINT pk_wagon_transfer_requests PRIMARY KEY (id), + CONSTRAINT fk_wtr_from_yard FOREIGN KEY (from_yard_id) REFERENCES freight.yards (id), + CONSTRAINT fk_wtr_to_yard FOREIGN KEY (to_yard_id) REFERENCES freight.yards (id), + CONSTRAINT fk_wtr_wagon_type FOREIGN KEY (wagon_type_id) REFERENCES freight.wagon_types (id), + CONSTRAINT chk_wtr_quantity CHECK (quantity > 0) + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wtr_status_from_yard + ON freight.wagon_transfer_requests (status, from_yard_id) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight.idx_wtr_status_from_yard`, + ); + await queryRunner.query( + `DROP TABLE IF EXISTS freight.wagon_transfer_requests`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 6a04014a7..317066cc4 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -1255,10 +1255,11 @@ export class BookingsRepository extends BaseRepository { destinationYard: true, // units carry the real per-container numbers entered at booking time — // the wagon plan shows those instead of generated placeholders. - // containerType.wagonType + cargoType.wagonType drive wagon-type - // resolution during scheduling (FK, not the old load-type string map). - bookingContainers: { containerType: { wagonType: true }, units: true }, - cargoType: { wagonType: true }, + // containerType.wagonTypes + cargoType.wagonTypes drive wagon-type + // resolution during scheduling (many-to-many lists — the plan mixes + // wagon types within one consist). + bookingContainers: { containerType: { wagonTypes: true }, units: true }, + cargoType: { wagonTypes: true }, }, order: { priorityScore: 'DESC', createdAt: 'ASC' }, }); diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts index 76db7fa78..c2c034c18 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { CargoUnitOfMeasure } from '@edr/types'; -import { IsBoolean, IsEnum, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; +import { IsArray, IsBoolean, IsEnum, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; export class CreateCargoTypeDto { @ApiProperty({ description: 'Cargo type display name', maxLength: 255 }) @@ -22,12 +22,15 @@ export class CreateCargoTypeDto { parentGroupId?: string; @ApiPropertyOptional({ + type: [String], + format: 'uuid', description: - 'Wagon type used to carry this (bulk) cargo. Drives train scheduling wagon-type resolution; required for bulk commodities that are scheduled.', + 'Wagon types that can carry this (bulk) cargo. Drives train scheduling wagon-type resolution; at least one is required for bulk commodities that are scheduled.', }) @IsOptional() - @IsUUID('4') - wagonTypeId?: string | null; + @IsArray() + @IsUUID('4', { each: true }) + wagonTypeIds?: string[]; @ApiPropertyOptional({ default: false }) @IsOptional() diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts index e0baf7251..a01ba4b5b 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsBoolean, IsInt, IsNumber, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator'; +import { IsArray, IsBoolean, IsInt, IsNumber, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator'; export class CreateContainerTypeDto { @ApiProperty({ description: 'Customer-facing label, e.g. "20ft Dry Container"', maxLength: 100 }) @@ -31,12 +31,15 @@ export class CreateContainerTypeDto { isOpenTop?: boolean; @ApiPropertyOptional({ + type: [String], + format: 'uuid', description: - 'Wagon type used to carry this container. Drives train scheduling wagon-type resolution; required when this container type is scheduled.', + 'Wagon types that can carry this container. Drives train scheduling wagon-type resolution; at least one is required when this container type is scheduled.', }) @IsOptional() - @IsUUID('4') - wagonTypeId?: string | null; + @IsArray() + @IsUUID('4', { each: true }) + wagonTypeIds?: string[]; @ApiPropertyOptional({ default: true }) @IsOptional() diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts index ac8a2ea24..7396595d1 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts @@ -1,13 +1,21 @@ import { BaseEntity } from '@edr/api-common'; import { CargoUnitOfMeasure } from '@edr/types'; -import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; +import { + Column, + Entity, + Index, + JoinColumn, + JoinTable, + ManyToMany, + ManyToOne, + OneToMany, +} from 'typeorm'; import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; @Entity({ schema: 'freight', name: 'cargo_types' }) @Index(['isActive']) @Index(['displayOrder']) @Index(['parentGroupId']) -@Index(['wagonTypeId']) @Index(['code']) export class CargoType extends BaseEntity { @Column({ name: 'code', type: 'varchar', length: 50, unique: true, default: '' }) @@ -28,17 +36,19 @@ export class CargoType extends BaseEntity { unitOfMeasure?: CargoUnitOfMeasure | null; /** - * Wagon type that carries this (bulk) cargo. Replaces the former hardcoded - * cargo-code → wagon-code map: train scheduling resolves the bulk wagon type - * through this FK. Nullable — grouping rows and container/legacy cargo never - * carry it; scheduling throws if a scheduled bulk cargo type leaves it unset. + * Wagon types that can carry this (bulk) cargo. Train scheduling resolves the + * bulk wagon type through this list, picking whichever type the schedule's + * train (or yard) actually has. Grouping rows and container/legacy cargo + * leave it empty; scheduling throws if a scheduled bulk cargo type has none. */ - @Column({ name: 'wagon_type_id', type: 'uuid', nullable: true }) - wagonTypeId?: string | null; - - @ManyToOne(() => WagonType, { nullable: true, onDelete: 'RESTRICT' }) - @JoinColumn({ name: 'wagon_type_id' }) - wagonType?: WagonType | null; + @ManyToMany(() => WagonType) + @JoinTable({ + name: 'cargo_type_wagon_types', + schema: 'freight', + joinColumn: { name: 'cargo_type_id', referencedColumnName: 'id' }, + inverseJoinColumn: { name: 'wagon_type_id', referencedColumnName: 'id' }, + }) + wagonTypes?: WagonType[]; @Column({ name: 'requires_director_approval', type: 'boolean', default: false }) requiresDirectorApproval!: boolean; diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts index f7cbeed99..2347426ca 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts @@ -1,12 +1,11 @@ import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; +import { Column, Entity, Index, JoinTable, ManyToMany, OneToMany } from 'typeorm'; import { WeightLimitRule } from './weight-limit-rule.entity'; import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; @Entity({ schema: 'freight', name: 'container_types' }) @Index(['code']) @Index(['isActive']) -@Index(['wagonTypeId']) export class ContainerType extends BaseEntity { @Column({ name: 'code', type: 'varchar', length: 20, unique: true }) code!: string; @@ -27,17 +26,19 @@ export class ContainerType extends BaseEntity { isOpenTop!: boolean; /** - * Wagon type that carries this container. Replaces the former hardcoded - * container wagon-code default (NW5): train scheduling resolves the container - * wagon type through this FK. Nullable; scheduling throws if a scheduled - * container type leaves it unset. + * Wagon types that can carry this container (e.g. a 20ft rides NX70 or NW5). + * Train scheduling resolves the container wagon type through this list, + * picking whichever type the schedule's train (or yard) actually has. + * Scheduling throws if a scheduled container type has none configured. */ - @Column({ name: 'wagon_type_id', type: 'uuid', nullable: true }) - wagonTypeId?: string | null; - - @ManyToOne(() => WagonType, { nullable: true, onDelete: 'RESTRICT' }) - @JoinColumn({ name: 'wagon_type_id' }) - wagonType?: WagonType | null; + @ManyToMany(() => WagonType) + @JoinTable({ + name: 'container_type_wagon_types', + schema: 'freight', + joinColumn: { name: 'container_type_id', referencedColumnName: 'id' }, + inverseJoinColumn: { name: 'wagon_type_id', referencedColumnName: 'id' }, + }) + wagonTypes?: WagonType[]; @Column({ name: 'is_active', type: 'boolean', default: true }) isActive!: boolean; diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts index 8e4be5a3b..4df961aaa 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts @@ -15,7 +15,7 @@ export class CargoTypesRepository implements ICargoTypesRepository { } findById(id: string): Promise { - return this.repo.findOne({ where: { id }, relations: { parent: true } }); + return this.repo.findOne({ where: { id }, relations: { parent: true, wagonTypes: true } }); } findByCode(code: string): Promise { @@ -35,6 +35,7 @@ export class CargoTypesRepository implements ICargoTypesRepository { const qb = this.repo .createQueryBuilder('cargoType') .leftJoinAndSelect('cargoType.parent', 'parent') + .leftJoinAndSelect('cargoType.wagonTypes', 'wagonType') .orderBy(`cargoType.${query.sortBy ?? 'displayOrder'}`, query.sortOrder ?? 'ASC'); if (query.isActive !== undefined) { @@ -63,7 +64,18 @@ export class CargoTypesRepository implements ICargoTypesRepository { } async update(id: string, data: Partial): Promise { - await this.repo.update(id, data as never); + // Relation lists can't ride a column UPDATE — sync them via entity save. + const { wagonTypes, ...columns } = data; + if (Object.keys(columns).length) { + await this.repo.update(id, columns as never); + } + if (wagonTypes) { + const entity = await this.repo.findOne({ where: { id } }); + if (entity) { + entity.wagonTypes = wagonTypes; + await this.repo.save(entity); + } + } return this.findById(id); } diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/container-types.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/container-types.repository.ts index ff5a3f994..cc65bf546 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/container-types.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/container-types.repository.ts @@ -15,7 +15,7 @@ export class ContainerTypesRepository implements IContainerTypesRepository { } findById(id: string): Promise { - return this.repo.findOne({ where: { id } }); + return this.repo.findOne({ where: { id }, relations: { wagonTypes: true } }); } findByCode(code: string): Promise { @@ -34,6 +34,7 @@ export class ContainerTypesRepository implements IContainerTypesRepository { findPaged(query: ListContainerTypesQueryDto): Promise> { const qb = this.repo .createQueryBuilder('containerType') + .leftJoinAndSelect('containerType.wagonTypes', 'wagonType') .orderBy(`containerType.${query.sortBy ?? 'displayOrder'}`, query.sortOrder ?? 'ASC'); if (query.isActive !== undefined) { @@ -54,7 +55,18 @@ export class ContainerTypesRepository implements IContainerTypesRepository { } async update(id: string, data: Partial): Promise { - await this.repo.update(id, data as never); + // Relation lists can't ride a column UPDATE — sync them via entity save. + const { wagonTypes, ...columns } = data; + if (Object.keys(columns).length) { + await this.repo.update(id, columns as never); + } + if (wagonTypes) { + const entity = await this.repo.findOne({ where: { id } }); + if (entity) { + entity.wagonTypes = wagonTypes; + await this.repo.save(entity); + } + } return this.findById(id); } diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts index 941d35f2f..8a72cf1f8 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts @@ -6,6 +6,7 @@ import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto'; import { ReorderItemsDto } from '../dto/reorder-items.dto'; import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto'; import { CargoType } from '../entities/cargo-type.entity'; +import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; import { CARGO_TYPES_REPOSITORY, ICargoTypesRepository, @@ -59,7 +60,8 @@ export class CargoTypesService { requiresDirectorApproval: dto.requiresDirectorApproval ?? false, isActive: dto.isActive ?? true, unitOfMeasure: dto.unitOfMeasure ?? null, - wagonTypeId: dto.wagonTypeId ?? null, + // Join rows are written by the save (RESTRICT FK rejects unknown ids). + wagonTypes: (dto.wagonTypeIds ?? []).map((id) => ({ id }) as WagonType), displayOrder, }); } @@ -72,7 +74,13 @@ export class CargoTypesService { const parent = await this.repository.findById(dto.parentGroupId); if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`); } - const updated = await this.repository.update(id, dto); + const { wagonTypeIds, insertAfterId: _insertAfterId, ...columns } = dto; + const updated = await this.repository.update(id, { + ...columns, + ...(wagonTypeIds + ? { wagonTypes: wagonTypeIds.map((wagonTypeId) => ({ id: wagonTypeId }) as WagonType) } + : {}), + }); if (!updated) throw new NotFoundException(`Cargo type ${id} not found`); return updated; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts index da641afe0..42ce389e1 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts @@ -6,6 +6,7 @@ import { ListContainerTypesQueryDto } from '../dto/list-rule-engine-query.dto'; import { ReorderItemsDto } from '../dto/reorder-items.dto'; import { UpdateContainerTypeDto } from '../dto/update-container-type.dto'; import { ContainerType } from '../entities/container-type.entity'; +import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; import { CONTAINER_TYPES_REPOSITORY, IContainerTypesRepository, @@ -51,7 +52,8 @@ export class ContainerTypesService { isReefer: dto.isReefer ?? false, isOpenTop: dto.isOpenTop ?? false, isActive: dto.isActive ?? true, - wagonTypeId: dto.wagonTypeId ?? null, + // Join rows are written by the save (RESTRICT FK rejects unknown ids). + wagonTypes: (dto.wagonTypeIds ?? []).map((id) => ({ id }) as WagonType), displayOrder, }); } @@ -59,7 +61,13 @@ export class ContainerTypesService { /** Update an existing container type. */ async update(id: string, dto: UpdateContainerTypeDto): Promise { await this.findById(id); - const updated = await this.repository.update(id, dto); + const { wagonTypeIds, insertAfterId: _insertAfterId, ...columns } = dto; + const updated = await this.repository.update(id, { + ...columns, + ...(wagonTypeIds + ? { wagonTypes: wagonTypeIds.map((wagonTypeId) => ({ id: wagonTypeId }) as WagonType) } + : {}), + }); if (!updated) throw new NotFoundException(`Container type ${id} not found`); return updated; } diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts index f32f2094e..8e40c0384 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts @@ -29,6 +29,7 @@ export class TrainSchedulesRepository extends BaseRepository { trainSet: { locomotive: true, locomotives: { locomotive: true }, + train: true, wagons: { wagonType: true, physicalWagon: true, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index a87b22c3e..c8e254141 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -886,7 +886,7 @@ describe('BookingBatchService — wagonsFor', () => { }; it('charges a bulk booking the tare of ITS wagon type, not the representative', () => { - const booking = bulk(2100, { cargoType: { wagonTypeId: 'pw2-id' } }); + const booking = bulk(2100, { cargoType: { wagonTypes: [{ id: 'pw2-id' }] } }); const need = service.needFor(booking, dimsWithTypes); expect(need.wagons).toBe(30); expect(need.weightTons).toBe(2856); // 2100 + 30 × 25.2 — matches allocation @@ -905,7 +905,7 @@ describe('BookingBatchService — wagonsFor', () => { { quantity: 2, wagonsRequired: 2, - containerType: { wagonsPerUnit: 1, sizeFt: 40, wagonTypeId: 'pw2-id' }, + containerType: { wagonsPerUnit: 1, sizeFt: 40, wagonTypes: [{ id: 'pw2-id' }] }, }, ], }; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 1c829b4ce..a9711c1e5 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -2913,21 +2913,23 @@ export class BookingBatchService implements OnModuleInit { /** * Dimensions of the wagon type THIS booking rides: bulk resolves through its - * cargo type's wagon_type_id, container through the first container line's - * type — the same FK resolution `resolveWagonType` applies when the paid - * booking is allocated. Board/fill math measured on a representative wagon - * while allocation validated the real one let a selected batch flunk the - * post-payment gross-weight check; sharing the resolution closes that gap. - * Falls back to the representative dims when the FK or relation is absent. + * cargo type's allowed wagon-type list, container through the first container + * line's — the same list resolution the scheduling planner applies when the + * paid booking is allocated. Board/fill math measured on a representative + * wagon while allocation validated the real one let a selected batch flunk + * the post-payment gross-weight check; sharing the resolution closes that + * gap. Uses the first configured type (the fill engine has no train context); + * falls back to the representative dims when the list or relation is absent. */ private dimsFor(booking: Booking, wagonDims: WagonDims): PerWagonDims { const fallback = booking.freightType === "BULK" ? wagonDims.bulk : wagonDims.container; const wagonTypeId = booking.freightType === "BULK" - ? booking.cargoType?.wagonTypeId + ? booking.cargoType?.wagonTypes?.[0]?.id : (booking.bookingContainers ?? []) - .map((line) => line.containerType?.wagonTypeId) + .flatMap((line) => line.containerType?.wagonTypes ?? []) + .map((wagonType) => wagonType.id) .find((id): id is string => Boolean(id)); const dims = wagonTypeId ? wagonDims.byWagonTypeId.get(wagonTypeId) : undefined; if (!dims) return fallback; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/available-trains-query.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/available-trains-query.dto.ts new file mode 100644 index 000000000..7ef63e9db --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/available-trains-query.dto.ts @@ -0,0 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsUUID } from 'class-validator'; + +export class AvailableTrainsQueryDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + routeId!: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts index 60aab2862..5b3e93ba7 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts @@ -20,15 +20,26 @@ export class CreateContainerTrainScheduleDto { @IsDateString() scheduleDate!: string; - @ApiProperty({ + @ApiPropertyOptional({ + format: 'uuid', + description: + 'Built train (Train Builder) to run this departure — its locomotive set is used. Provide either trainId or locomotiveIds.', + }) + @IsOptional() + @IsUUID() + trainId?: string; + + @ApiPropertyOptional({ type: [String], format: 'uuid', - description: 'Locomotives pulling the train (minimum 2 — front and back)', + description: + 'Hand-picked locomotives pulling the train (minimum 2 — front and back). Ignored when trainId is provided.', }) + @IsOptional() @IsArray() @ArrayMinSize(2, { message: 'A train must be pulled by at least two locomotives' }) @IsUUID('all', { each: true }) - locomotiveIds!: string[]; + locomotiveIds?: string[]; @ApiPropertyOptional({ description: 'Maximum total booking weight allowed on this train' }) @IsOptional() diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 4196e0972..f8cc7e3d1 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -39,6 +39,7 @@ import { UploadImportDjiboutiDocumentDto, } from "./dto/import-djibouti-operation.dto"; import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto"; +import { AvailableTrainsQueryDto } from "./dto/available-trains-query.dto"; import { BatchBoardQueryDto } from "./dto/batch-board-query.dto"; import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto"; import { ListTrainSchedulesQueryDto } from "./dto/list-train-schedules-query.dto"; @@ -153,6 +154,18 @@ export class TrainSchedulingController { ); } + @Get("available-trains") + @TrainSchedulingView() + @ApiOperation({ + summary: + "List built trains (Train Builder) schedulable on a route, annotated with yard position and future runs", + }) + getAvailableTrains(@Query() query: AvailableTrainsQueryDto) { + return this.trainSchedulingService.getAvailableTrainsForRoute( + query.routeId, + ); + } + @Get("bookable-schedules") // No staff guard: customers hit this while creating a booking to find OPEN // same-route schedules. Do not attach train_scheduling permissions here. diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index e97b3de56..c4f2e7d6d 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -72,7 +72,7 @@ const makeBooking = ( wagonsRequired, vgmPerUnitTons: weight / quantity, isOverweight: false, - containerType: { code: containerCode, label: containerCode, wagonTypeId: nw5.id }, + containerType: { id: 'ct-1', code: containerCode, label: containerCode, wagonTypes: [nw5] }, }, ], ...extra, @@ -80,7 +80,7 @@ const makeBooking = ( describe('TrainSchedulingService', () => { let service: TrainSchedulingService; - let dataSource: { getRepository: jest.Mock; transaction: jest.Mock }; + let dataSource: { getRepository: jest.Mock; transaction: jest.Mock; query: jest.Mock }; let bookingsRepository: Record; let locomotivesRepository: { findById: jest.Mock; findAll: jest.Mock }; let wagonTypesRepository: { findAll: jest.Mock }; @@ -91,7 +91,12 @@ describe('TrainSchedulingService', () => { let wagonAllocationBulkLoadsRepository: Record; beforeEach(() => { - dataSource = { getRepository: jest.fn(), transaction: jest.fn() }; + dataSource = { + getRepository: jest.fn(), + transaction: jest.fn(), + // Raw-SQL helper lookups (e.g. builtTrainIdOfSchedule) default to "no rows". + query: jest.fn().mockResolvedValue([]), + }; bookingsRepository = { findEligibleForScheduling: jest.fn(), findByIdsForScheduling: jest.fn(), @@ -259,8 +264,10 @@ describe('TrainSchedulingService', () => { expect(result.valid).toBe(true); expect(result.violations).toEqual([]); - expect(result.summary.wagonsNeeded).toBe(45); - expect(result.wagonPlan).toHaveLength(45); + // TEU packing: 20 + 15 wagons of 40ft plus 10×20ft at two per wagon (5) — + // the planner packs by container size, not the stored per-line fallback. + expect(result.summary.wagonsNeeded).toBe(40); + expect(result.wagonPlan).toHaveLength(40); }); it('returns soft hold warnings without forceAssign', async () => { @@ -293,7 +300,7 @@ describe('TrainSchedulingService', () => { wagonsRequired: 80, vgmPerUnitTons: 45, isOverweight: true, - containerType: { code: '40FT', label: '40FT', wagonTypeId: nw5.id }, + containerType: { id: 'ct-1', code: '40FT', label: '40FT', wagonTypes: [nw5] }, }, ], }), @@ -655,10 +662,12 @@ describe('TrainSchedulingService', () => { destinationStationId: 'yard-djibouti', }); - expect(result.valid).toBe(false); - expect( - result.violations.some((v) => v.includes('available at yard') && v.includes('NW5')), - ).toBe(true); + // List-based planner: a booking with no plannable wagon at the yard is + // DEFERRED with the wagon-type reason (assign still hard-fails when no + // booking fits), instead of surfacing a phantom-slot violation. + expect(result.valid).toBe(true); + expect(result.wagonPlan).toHaveLength(0); + expect(result.deferredBookings.some((d) => d.reason.includes('NW5'))).toBe(true); }); it('assignBookingsToSchedule rejects when physical wagons cannot be pinned', async () => { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 9e82bca33..874516548 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -1,5 +1,6 @@ import { AllocationLoadType, + Freight, LoadingStatus, SchedulingStatus, TrainCheckpointKind, @@ -43,6 +44,7 @@ import { Locomotive } from '../locomotives/entities/locomotive.entity'; import { LocomotivesRepository } from '../locomotives/locomotives.repository'; import { formatRouteLabel, Route } from '../routes/entities/route.entity'; import { WagonMovement } from '../wagons/entities/wagon-movement.entity'; +import { Train } from '../trains/entities/train.entity'; import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity'; import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; import { TrainSet } from '../train-sets/entities/train-set.entity'; @@ -90,9 +92,7 @@ import { type BookingWindowConfig } from './booking-window.config'; import { BookingWindowGateway } from './booking-window.gateway'; import { BookingNotifierService } from './booking-notifier.service'; import { - buildCappedWagonPlan, computeFleetAvailability, - selectBookingsWithinFleetCap, summarizeFleetWarnings, totalAssignedWeight, wagonsRequiredForBooking, @@ -100,9 +100,12 @@ import { type FleetAvailabilityRow, } from './fleet-plan.util'; import { - buildBulkWagonPlan, - buildContainerWagonPlan, - buildMixedWagonPlan, + planWagonsWithStock, + unboundedStock, + type AllowedWagonTypeMap, + type WagonStock, +} from './wagon-plan-flex.util'; +import { expandBookingContainerUnits, getContainerSlotSequenceNos, roundTons, @@ -110,7 +113,6 @@ import { type TrainLimitConfig, validateContainerPlacements, validateMixedTrainLimits, - validateTrainLimits, type ContainerPlacementInput, type WagonPlanSlot, } from './wagon-plan.util'; @@ -291,7 +293,9 @@ export class TrainSchedulingService { private readonly dataSource: DataSource, private readonly bookingsRepository: BookingsRepository, private readonly locomotivesRepository: LocomotivesRepository, - private readonly wagonTypesRepository: WagonTypesRepository, + // Kept in the DI signature for constructor-arity stability (specs mock it); + // wagon-type resolution now flows through the config lists on the bookings. + _wagonTypesRepository: WagonTypesRepository, private readonly trainSchedulesRepository: TrainSchedulesRepository, private readonly trainScheduleBookingsRepository: TrainScheduleBookingsRepository, private readonly wagonBookingAllocationsRepository: WagonBookingAllocationsRepository, @@ -980,12 +984,52 @@ export class TrainSchedulingService { async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) { const route = await this.getSchedulableRoute(dto.routeId); - const locomotiveIds = [...new Set(dto.locomotiveIds)]; - if (locomotiveIds.length < 2) { - throw new BadRequestException('A train must be pulled by at least two locomotives'); + const scheduleWarnings: string[] = []; + + // The pulling set comes either from a built train (Train Builder) or from + // hand-picked locomotive ids (legacy path). A built train also links the + // schedule's train set back to it (`train_sets.train_id`) so its lifecycle + // and yard follow the schedule. + let builtTrain: Train | null = null; + let locomotiveIds: string[]; + if (dto.trainId) { + builtTrain = await this.dataSource.getRepository(Train).findOne({ + where: { id: dto.trainId }, + relations: { locomotives: true }, + order: { locomotives: { sequenceNo: 'ASC' } }, + }); + if (!builtTrain) { + throw new NotFoundException(`Train ${dto.trainId} not found`); + } + if ( + builtTrain.status === Freight.TrainStatus.OutOfService || + builtTrain.status === Freight.TrainStatus.UnderMaintenance + ) { + throw new ConflictException( + `Train ${builtTrain.code} is ${builtTrain.status.toLowerCase().replace(/_/g, ' ')}`, + ); + } + locomotiveIds = (builtTrain.locomotives ?? []) + .slice() + .sort((a, b) => a.sequenceNo - b.sequenceNo) + .map((link) => link.locomotiveId); + if (locomotiveIds.length < 2) { + throw new BadRequestException( + `Train ${builtTrain.code} has fewer than two locomotives; rebuild it before scheduling`, + ); + } + if (builtTrain.currentYardId !== route.originYardId) { + scheduleWarnings.push( + `Train ${builtTrain.code} is not at the origin yard yet; it must arrive before this departure dispatches`, + ); + } + } else { + locomotiveIds = [...new Set(dto.locomotiveIds ?? [])]; + if (locomotiveIds.length < 2) { + throw new BadRequestException('A train must be pulled by at least two locomotives'); + } } - const scheduleWarnings: string[] = []; const createdScheduleId = await this.dataSource.transaction(async (manager) => { // Lock every locomotive. Advance scheduling is allowed: a locomotive may sit on // multiple future schedules and does not need to be at the origin yard yet — staff @@ -1020,7 +1064,11 @@ export class TrainSchedulingService { // getSchedulableRoute already rejected DOMESTIC (intercity). const direction = this.resolveRouteDirection(route); - const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotives); + const trainSet = await this.buildEmptyTrainSet( + manager, + lockedLocomotives, + builtTrain?.id ?? null, + ); // Effective capacity is capped by the weakest locomotive in the set. const limitLoco = minLocomotiveLimits(lockedLocomotives) ?? undefined; const departure = new Date(dto.scheduleDate); @@ -1089,8 +1137,16 @@ export class TrainSchedulingService { ? this.groupWindowFieldsFrom(groupAnchor, departure) : computedTimes), }; - const maxWagons = (await this.resolveTrainLimitConfig(dto, limitLoco)) - .maxWagonsPerTrain; + // A built train's own consist is the schedule's capacity: full when all + // its wagons are allocated. Trains built without wagons yet fall back to + // the configured limit. + const builtTrainWagonCount = builtTrain + ? await manager.getRepository(Wagon).count({ where: { trainId: builtTrain.id } }) + : 0; + const maxWagons = + builtTrainWagonCount > 0 + ? builtTrainWagonCount + : (await this.resolveTrainLimitConfig(dto, limitLoco)).maxWagonsPerTrain; // Retry past a concurrent insert that grabbed the same S- sequence // (the unique index rejects the loser; it re-reads the max and tries again). const saved = await this.insertScheduleWithReference(manager, (reference) => @@ -1109,6 +1165,9 @@ export class TrainSchedulingService { ); // Locomotives stay in their current status until dispatch — advance scheduling // must not block the locomotive from serving earlier trains. + if (builtTrain) { + await this.syncBuiltTrainAfterScheduleChange(manager, builtTrain.id); + } return saved.id; }); @@ -1255,7 +1314,7 @@ export class TrainSchedulingService { }); } - const { bookings, wagonType, wagonPlan, warnings, deferredBookings } = validation; + const { bookings, wagonPlan, warnings, deferredBookings } = validation; const totalWeightTons = validation.summary.totalWeightTons; const totalLengthMeters = validation.summary.totalLengthMeters; @@ -1313,7 +1372,6 @@ export class TrainSchedulingService { const savedWagons = await this.persistTrainSetWagons( manager, trainSetId, - wagonType, wagonPlan, ); @@ -1782,6 +1840,10 @@ export class TrainSchedulingService { if (schedule.trainSetId) { await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'DISPATCHED' }); } + // A built train follows its schedule out: IN_SERVICE until arrival. + if (schedule.trainSet?.trainId) { + await this.syncBuiltTrainAfterScheduleChange(manager, schedule.trainSet.trainId); + } // The train is out — every pinned wagon is ASSIGNED to this schedule and // stays pinned so no other schedule can pick it while it's rolling. const dispatchedPhysicalIds = (schedule.trainSet?.wagons ?? []) @@ -2862,6 +2924,15 @@ export class TrainSchedulingService { status: 'COMPLETED', }); } + // A built train arrives with its schedule: settle it at the destination + // yard and re-derive its status (AVAILABLE, or SCHEDULED if more runs wait). + if (schedule.trainSet?.trainId) { + await this.syncBuiltTrainAfterScheduleChange( + manager, + schedule.trainSet.trainId, + schedule.destinationStationId, + ); + } // Per-booking journey: bookings destined for the FINAL yard that the // operator didn't unload individually get their arrival stamped now as a @@ -2894,7 +2965,9 @@ export class TrainSchedulingService { await manager.getRepository(Wagon).update(wagon.id, { currentTrainScheduleId: null, trainSetWagonId: null, - status: WagonStatus.Available, + // A wagon that belongs to a built train stays coupled to it (ASSIGNED); + // only loose wagons return to the open AVAILABLE pool. + status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available, currentYardId: settleYardId, }); // Ledger: the wagon rode this schedule to its settle yard. @@ -2987,7 +3060,7 @@ export class TrainSchedulingService { const [schedules, total] = await this.trainSchedulesRepository.findAndCount({ where, relations: { - trainSet: { locomotive: true, locomotives: { locomotive: true } }, + trainSet: { locomotive: true, locomotives: { locomotive: true }, train: true }, // Yards carry the route's display name used by mapScheduleListItem; // milestones (with yards) let it show the full corridor path. route: { originYard: true, destinationYard: true, milestones: { yard: true } }, @@ -3038,6 +3111,11 @@ export class TrainSchedulingService { if (schedule.trainSetId) { await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'CANCELLED' }); } + // Cancelled run: the built train never left — re-derive its status + // (back to AVAILABLE unless other runs still reference it). + if (schedule.trainSet?.trainId) { + await this.syncBuiltTrainAfterScheduleChange(manager, schedule.trainSet.trainId); + } // Locomotives are only ASSIGNED while out on a dispatched train. Release ours, // but never stomp a locomotive that is currently pulling another dispatched train. const cancelledLocoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id); @@ -3059,7 +3137,11 @@ export class TrainSchedulingService { await manager.getRepository(Wagon).update(wagon.physicalWagonId, { currentTrainScheduleId: null, trainSetWagonId: null, - status: WagonStatus.Available, + // Built-train wagons stay coupled to their train (ASSIGNED); loose + // wagons return to the open AVAILABLE pool. + status: wagon.physicalWagon?.trainId + ? WagonStatus.Assigned + : WagonStatus.Available, // A cancelled train never left — its wagons stay/return at the origin // yard, free to be re-pinned onto another schedule from there. currentYardId: schedule.originStationId, @@ -3194,92 +3276,65 @@ export class TrainSchedulingService { } } - let wagonType: WagonType; - let containerWagonType: WagonType; - let bulkWagonType: WagonType; - let demandPlan: WagonPlanSlot[]; - let fittingBookings = bookings; - let deferredBookings: DeferredBookingRow[] = []; - let fleetAvailability: FleetAvailabilityRow[] = []; + // Wagon-type resolution is list-based: each container/cargo type carries + // the wagon types that can haul it, and the plan mixes wagon types within + // one consist. A schedule created from a built train (Train Builder) plans + // against ONLY that train's own wagons — full when every consist wagon is + // allocated; legacy schedules plan against the boarding yards' pool. + const allowed = await this.loadAllowedWagonTypes(bookings); + const builtTrainId = await this.builtTrainIdOfSchedule(targetScheduleId); - if (resolvedMode === 'MIXED') { - const containerBookings = bookings.filter((b) => b.freightType === 'CONTAINER'); - const bulkBookings = bookings.filter((b) => b.freightType === 'BULK'); - containerWagonType = await this.resolveWagonType('CONTAINER', bookingIds); - bulkWagonType = await this.resolveWagonType('BULK', bookingIds); - wagonType = containerWagonType; - demandPlan = buildMixedWagonPlan( - containerBookings, - bulkBookings, - containerWagonType, - bulkWagonType, - ); - } else { - wagonType = await this.resolveWagonType(resolvedMode, bookingIds); - containerWagonType = wagonType; - bulkWagonType = wagonType; - demandPlan = - resolvedMode === 'CONTAINER' - ? buildContainerWagonPlan(bookings, wagonType) - : buildBulkWagonPlan(bookings, wagonType); - } + // Pure demand (unbounded stock) drives the availability report rows. + const demandPlan = planWagonsWithStock({ + bookings, + allowed, + stock: unboundedStock(allowed), + }).plan; const originYardId = dto.originStationId; - // Dynamic consist: a slot's physical wagon may ride from the train's origin - // OR already sit at the booking's own boarding yard and attach there — so - // the usable fleet is the union across the origin and every boarding yard. - const boardYardIds = [ - ...new Set( - [originYardId, ...bookings.map((b) => b.originYardId)].filter(Boolean), - ), - ]; - const fleetCountsByYard = await Promise.all( - boardYardIds.map((yardId) => - this.countFleetAvailability(yardId, targetScheduleId), - ), - ); - const mergedFleet = new Map(); - for (const rows of fleetCountsByYard) { - for (const row of rows) { - const existing = mergedFleet.get(row.wagonTypeId) ?? { - code: row.wagonTypeCode, - available: 0, - }; - existing.available += row.available; - mergedFleet.set(row.wagonTypeId, existing); + let stock: WagonStock; + if (builtTrainId) { + stock = await this.builtTrainStock(builtTrainId); + } else { + // Dynamic consist: a slot's physical wagon may ride from the train's origin + // OR already sit at the booking's own boarding yard and attach there — so + // the usable fleet is the union across the origin and every boarding yard. + const boardYardIds = [ + ...new Set( + [originYardId, ...bookings.map((b) => b.originYardId)].filter(Boolean), + ), + ]; + const fleetCountsByYard = await Promise.all( + boardYardIds.map((yardId) => + this.countFleetAvailability(yardId, targetScheduleId), + ), + ); + const remainingByTypeId = new Map(); + const codesByTypeId = new Map(); + for (const rows of fleetCountsByYard) { + for (const row of rows) { + remainingByTypeId.set( + row.wagonTypeId, + (remainingByTypeId.get(row.wagonTypeId) ?? 0) + row.available, + ); + codesByTypeId.set(row.wagonTypeId, row.wagonTypeCode); + } } + stock = { mode: 'YARD', remainingByTypeId, codesByTypeId }; } - const fleetCounts = [...mergedFleet.entries()].map( - ([wagonTypeId, value]) => ({ - wagonTypeId, - wagonTypeCode: value.code, - available: value.available, - }), - ); - const fleetByTypeId = new Map(fleetCounts.map((row) => [row.wagonTypeId, row.available])); - fleetAvailability = computeFleetAvailability( + + const planned = planWagonsWithStock({ bookings, allowed, stock }); + violations.push(...planned.configIssues); + const fittingBookings = planned.fitting; + const deferredBookings: DeferredBookingRow[] = planned.deferred; + const wagonPlan = planned.plan; + + const fleetAvailability: FleetAvailabilityRow[] = computeFleetAvailability( demandPlan, - fleetByTypeId, - new Map(fleetCounts.map((row) => [row.wagonTypeId, row.wagonTypeCode])), + stock.remainingByTypeId, + stock.codesByTypeId, ); - - const selection = selectBookingsWithinFleetCap( - bookings, - fleetByTypeId, - (booking) => - booking.freightType === 'BULK' ? bulkWagonType.id : containerWagonType.id, - Number(bulkWagonType.capacityTons), - ); - fittingBookings = selection.fitting; - deferredBookings = selection.deferred; warnings.push(...summarizeFleetWarnings(fleetAvailability, deferredBookings)); - - const wagonPlan = buildCappedWagonPlan({ - bookings: fittingBookings, - resolvedMode, - containerWagonType, - bulkWagonType, - }); this.stampSlotLegs( wagonPlan, fittingBookings, @@ -3307,40 +3362,34 @@ export class TrainSchedulingService { const pushLimit = (issues: string[]) => forceAssign ? warnings.push(...issues) : violations.push(...issues); - if (resolvedMode === 'MIXED') { - pushLimit( - validateMixedTrainLimits(wagonPlan, [containerWagonType, bulkWagonType], trainLimits), + // The plan can mix wagon types, so limit math always runs against the + // distinct types actually planned (shortest length drives the wagon-count + // fallback — validateMixedTrainLimits generalizes the single-type check). + const plannedWagonTypes = [ + ...new Map( + wagonPlan.map((slot) => [slot.wagonTypeId, { lengthMeters: slot.lengthMeters }]), + ).values(), + ]; + pushLimit( + validateMixedTrainLimits( + wagonPlan, + plannedWagonTypes.length ? plannedWagonTypes : [{ lengthMeters: 14 }], + trainLimits, + ), + ); + if (requireContainerPlacements && resolvedMode !== 'BULK') { + const containerBookings = fittingBookings.filter((b) => b.freightType === 'CONTAINER'); + violations.push( + ...validateContainerPlacements( + containerBookings, + wagonPlan, + containerPlacements, + placementRules, + ), + ); + violations.push( + ...(await this.validateFleetContainers(containerPlacements, containerBookings)), ); - if (requireContainerPlacements) { - const containerBookings = fittingBookings.filter((b) => b.freightType === 'CONTAINER'); - violations.push( - ...validateContainerPlacements( - containerBookings, - wagonPlan, - containerPlacements, - placementRules, - ), - ); - violations.push( - ...(await this.validateFleetContainers(containerPlacements, containerBookings)), - ); - } - } else { - pushLimit(validateTrainLimits(wagonPlan, wagonType, trainLimits)); - - if (requireContainerPlacements && resolvedMode === 'CONTAINER') { - violations.push( - ...validateContainerPlacements( - fittingBookings, - wagonPlan, - containerPlacements, - placementRules, - ), - ); - violations.push( - ...(await this.validateFleetContainers(containerPlacements, fittingBookings)), - ); - } } const totalWeightTons = totalAssignedWeight(fittingBookings); @@ -3405,20 +3454,21 @@ export class TrainSchedulingService { } } + const plannedTypeCodes = [...new Set(wagonPlan.map((slot) => slot.wagonTypeCode))]; + return { valid: violations.length === 0, violations, warnings, bookings: fittingBookings, - wagonType, wagonPlan, fleetAvailability, deferredBookings, summary: { totalBookings: fittingBookings.length, totalWeightTons, - wagonType: - resolvedMode === 'MIXED' ? 'MIXED' : wagonType.code, + // Human-readable wagon type(s) of the plan — mixed consists list all. + wagonType: plannedTypeCodes.join('/') || 'NONE', wagonsNeeded: wagonPlan.length, totalLengthMeters, freightMode: resolvedMode, @@ -3576,23 +3626,51 @@ export class TrainSchedulingService { ]; } + /** + * Built train (Train Builder) behind a schedule's train set, if the schedule + * was created by picking a train instead of loose locomotives. + */ + private async builtTrainIdOfSchedule( + scheduleId: string | undefined, + manager?: EntityManager, + ): Promise { + if (!scheduleId) return null; + const runner = manager ?? this.dataSource; + const rows: { train_id: string | null }[] = await runner.query( + `SELECT tset.train_id + FROM freight.train_schedules ts + JOIN freight.train_sets tset ON tset.id = ts.train_set_id + WHERE ts.id = $1`, + [scheduleId], + ); + return rows[0]?.train_id ?? null; + } + private async countFleetAvailability( originYardId: string, targetScheduleId?: string, ): Promise> { - const [wagons, wagonTypes] = await Promise.all([ + const [wagons, wagonTypes, builtTrainId] = await Promise.all([ this.dataSource.getRepository(Wagon).find(), this.dataSource.getRepository(WagonType).find(), + this.builtTrainIdOfSchedule(targetScheduleId), ]); const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code])); const counts = new Map(); for (const wagon of wagons) { - const pinnedOnTarget = targetScheduleId - ? wagon.currentTrainScheduleId === targetScheduleId - : false; - if (wagon.status !== WagonStatus.Available && !pinnedOnTarget) continue; - if (wagon.currentYardId !== originYardId) continue; + // Train-bound schedule: the built train's own consist IS the fleet — only + // its wagons count (wherever they currently sit; they travel with the + // train), and loose yard wagons never do. + if (builtTrainId) { + if (wagon.trainId !== builtTrainId) continue; + } else { + const pinnedOnTarget = targetScheduleId + ? wagon.currentTrainScheduleId === targetScheduleId + : false; + if (wagon.status !== WagonStatus.Available && !pinnedOnTarget) continue; + if (wagon.currentYardId !== originYardId) continue; + } const typeId = wagon.wagonTypeId; const code = typeCodeById.get(typeId) ?? typeId; @@ -3651,10 +3729,16 @@ export class TrainSchedulingService { private async releasePinnedWagonsForTrainSet(manager: EntityManager, trainSetId: string) { const slots = await manager.getRepository(TrainSetWagon).find({ where: { trainSetId } }); - for (const slot of slots) { - if (!slot.physicalWagonId) continue; - await manager.getRepository(Wagon).update(slot.physicalWagonId, { - status: WagonStatus.Available, + const physicalIds = slots + .map((slot) => slot.physicalWagonId) + .filter((id): id is string => Boolean(id)); + if (!physicalIds.length) return; + const wagons = await manager.getRepository(Wagon).find({ where: { id: In(physicalIds) } }); + for (const wagon of wagons) { + await manager.getRepository(Wagon).update(wagon.id, { + // Built-train wagons stay coupled to their train (ASSIGNED); loose + // wagons return to the open AVAILABLE pool. + status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available, trainSetWagonId: null, currentTrainScheduleId: null, }); @@ -3669,6 +3753,7 @@ export class TrainSchedulingService { ) { const wagons = await manager.getRepository(Wagon).find(); const wagonTypes = await manager.getRepository(WagonType).find(); + const builtTrainId = await this.builtTrainIdOfSchedule(scheduleId, manager); const typeCodeById = new Map(wagonTypes.map((wt) => [wt.id, wt.code])); const planSlots = [...slots] @@ -3686,6 +3771,7 @@ export class TrainSchedulingService { wagons, scheduleId, originYardId, + builtTrainId, ); if (unpinnable.length) { throw new BadRequestException({ @@ -3702,6 +3788,7 @@ export class TrainSchedulingService { scheduleId, originYardId, assignedPhysicalIds, + builtTrainId, ); if (!physical) continue; @@ -3726,7 +3813,10 @@ export class TrainSchedulingService { ): Promise { if (!wagonPlan.length) return []; - const wagons = await this.dataSource.getRepository(Wagon).find(); + const [wagons, builtTrainId] = await Promise.all([ + this.dataSource.getRepository(Wagon).find(), + this.builtTrainIdOfSchedule(targetScheduleId), + ]); return this.findUnpinnableWagonSlots( wagonPlan.map((slot) => ({ sequenceNo: slot.sequenceNo, @@ -3737,6 +3827,7 @@ export class TrainSchedulingService { wagons, targetScheduleId, originYardId, + builtTrainId, ); } @@ -3750,6 +3841,7 @@ export class TrainSchedulingService { wagons: Wagon[], scheduleId: string | undefined, originYardId: string, + builtTrainId: string | null = null, ): string[] { const violations: string[] = []; const assignedPhysicalIds = new Set(); @@ -3761,6 +3853,7 @@ export class TrainSchedulingService { scheduleId, originYardId, assignedPhysicalIds, + builtTrainId, ); if (!physical) { violations.push( @@ -3785,6 +3878,7 @@ export class TrainSchedulingService { scheduleId: string | undefined, originYardId: string, assignedPhysicalIds: Set, + builtTrainId: string | null = null, ): Wagon | undefined { const usable = (wagon: Wagon): boolean => { if (wagon.wagonTypeId !== slot.wagonTypeId) return false; @@ -3794,6 +3888,17 @@ export class TrainSchedulingService { : false; return wagon.status === WagonStatus.Available || pinnedOnSchedule; }; + // Train-bound schedule: ONLY the built train's own wagons may be pinned — + // wherever they currently sit (they travel with the train), never a loose + // yard wagon. + if (builtTrainId) { + return wagons.find( + (w) => + w.trainId === builtTrainId && + w.wagonTypeId === slot.wagonTypeId && + !assignedPhysicalIds.has(w.id), + ); + } // Prefer a wagon already waiting at the slot's board yard (no empty haul); // fall back to one riding from the train's origin. if (slot.boardYardId) { @@ -3866,61 +3971,54 @@ export class TrainSchedulingService { * when the relevant type has no wagon type configured — scheduling is blocked * until an admin assigns one on the cargo-type / container-type config screen. */ - private async resolveWagonType( - freightType: 'CONTAINER' | 'BULK', - bookingIds: string[], - ): Promise { - const bookings = await this.bookingsRepository.findByIdsForScheduling(bookingIds); + /** + * Wagon types allowed to carry each container/cargo type on these bookings. + * The many-to-many configuration lists (active wagon types only), keyed by + * type id — the flexible planner mixes wagon types within one consist. + * Bookings must arrive from findByIdsForScheduling so the `wagonTypes` + * relations are loaded. + */ + private loadAllowedWagonTypes(bookings: Booking[]): AllowedWagonTypeMap { + const byContainerTypeId = new Map(); + const byCargoTypeId = new Map(); + const active = (list?: WagonType[] | null) => + (list ?? []).filter((wt) => wt.isActive !== false); - if (freightType === 'CONTAINER') { - // First container type present on the batch drives the container wagon - // type (matches the prior single-wagon-type-per-consist behavior). - const containerType = bookings - .flatMap((b) => b.bookingContainers ?? []) - .map((line) => line.containerType) - .find((ct): ct is NonNullable => Boolean(ct)); - if (!containerType) { - throw new BadRequestException('No container type found on the container booking(s)'); + for (const booking of bookings) { + for (const line of booking.bookingContainers ?? []) { + const containerType = line.containerType; + if (containerType && !byContainerTypeId.has(containerType.id)) { + byContainerTypeId.set(containerType.id, active(containerType.wagonTypes)); + } + } + const cargoType = booking.cargoType; + if (cargoType && !byCargoTypeId.has(cargoType.id)) { + byCargoTypeId.set(cargoType.id, active(cargoType.wagonTypes)); } - const wagonType = await this.loadWagonTypeForType( - containerType.wagonTypeId ?? null, - `Container type "${containerType.label ?? containerType.code}"`, - ); - return wagonType; } - - const cargoType = bookings.map((b) => b.cargoType).find((ct) => Boolean(ct)); - if (!cargoType) { - throw new BadRequestException('No cargo type found on the bulk booking(s)'); - } - return this.loadWagonTypeForType( - cargoType.wagonTypeId ?? null, - `Cargo type "${cargoType.cargoTypeName ?? cargoType.code}"`, - ); + return { byContainerTypeId, byCargoTypeId }; } /** - * Load an active wagon type by FK id, throwing a clear error when the id is - * unset (type not configured) or points at a missing/inactive wagon type. + * TRAIN-mode wagon stock: the built train's own consist, grouped by wagon + * type. This is the whole plannable pool for its schedules — the plan is + * full when every consist wagon is allocated. */ - private async loadWagonTypeForType( - wagonTypeId: string | null, - typeLabel: string, - ): Promise { - if (!wagonTypeId) { - throw new BadRequestException( - `${typeLabel} has no wagon type configured — set one on its configuration before scheduling.`, - ); - } - const [wagonType] = await this.wagonTypesRepository.findAll({ - where: { id: wagonTypeId, isActive: true }, + private async builtTrainStock(builtTrainId: string): Promise { + const wagons = await this.dataSource.getRepository(Wagon).find({ + where: { trainId: builtTrainId }, + relations: { wagonType: true }, }); - if (!wagonType) { - throw new NotFoundException( - `${typeLabel} references wagon type ${wagonTypeId}, which was not found or is inactive.`, + const remainingByTypeId = new Map(); + const codesByTypeId = new Map(); + for (const wagon of wagons) { + remainingByTypeId.set( + wagon.wagonTypeId, + (remainingByTypeId.get(wagon.wagonTypeId) ?? 0) + 1, ); + if (wagon.wagonType) codesByTypeId.set(wagon.wagonTypeId, wagon.wagonType.code); } - return wagonType; + return { mode: 'TRAIN', remainingByTypeId, codesByTypeId }; } /** @@ -3962,13 +4060,12 @@ export class TrainSchedulingService { private async persistTrainSetWagons( manager: EntityManager, trainSetId: string, - wagonType: WagonType, wagonPlan: WagonPlanSlot[], ) { const wagons = wagonPlan.map((slot) => manager.getRepository(TrainSetWagon).create({ trainSetId, - wagonTypeId: slot.wagonTypeId ?? wagonType.id, + wagonTypeId: slot.wagonTypeId, sequenceNo: slot.sequenceNo, capacityTons: slot.capacityTons, lengthMeters: slot.lengthMeters, @@ -4198,11 +4295,17 @@ export class TrainSchedulingService { return locomotive; } - private async buildEmptyTrainSet(manager: EntityManager, locomotives: Locomotive[]) { + private async buildEmptyTrainSet( + manager: EntityManager, + locomotives: Locomotive[], + builtTrainId: string | null = null, + ) { const [primary] = locomotives; const trainSet = manager.getRepository(TrainSet).create({ // `locomotiveId` retained as the primary locomotive for single-loco read paths. locomotiveId: primary.id, + // Built fleet train this set was formed from (Train Builder), if any. + trainId: builtTrainId, totalWeightTons: 0, totalLengthMeters: 0, wagonCount: 0, @@ -4358,6 +4461,14 @@ export class TrainSchedulingService { origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null, destination: schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null, + // Built train (Train Builder) behind this departure, when scheduled by train. + train: schedule.trainSet?.train + ? { + id: schedule.trainSet.train.id, + code: schedule.trainSet.train.code, + trainName: schedule.trainSet.train.trainName ?? null, + } + : null, locomotive: schedule.trainSet?.locomotive ? { id: schedule.trainSet.locomotive.id, @@ -4431,6 +4542,159 @@ export class TrainSchedulingService { })); } + /** + * All schedulable built trains (Train Builder), annotated for the + * schedule-creation picker. Mirrors the locomotive picker's advance-scheduling + * philosophy: nothing serviceable is filtered out — staff see the status, + * whether the train sits at the origin yard yet, and its future schedules. + * Trains with fewer than two locomotives are omitted (never schedulable). + */ + async getAvailableTrainsForRoute(routeId: string) { + const route = await this.getSchedulableRoute(routeId); + + const trains = await this.dataSource.getRepository(Train).find({ + where: { + status: Not( + In([Freight.TrainStatus.OutOfService, Freight.TrainStatus.UnderMaintenance]), + ), + }, + relations: { + currentYard: true, + locomotives: { locomotive: true }, + wagons: { wagonType: true }, + }, + order: { code: 'ASC', locomotives: { sequenceNo: 'ASC' } }, + }); + + const counts: { train_id: string; future_count: string }[] = trains.length + ? await this.dataSource.query( + `SELECT tset.train_id, COUNT(DISTINCT ts.id) AS future_count + FROM freight.train_schedules ts + JOIN freight.train_sets tset ON tset.id = ts.train_set_id + WHERE ts.status IN ('DRAFT', 'SCHEDULED') + AND ts.deleted_at IS NULL + AND tset.train_id = ANY($1) + GROUP BY tset.train_id`, + [trains.map((t) => t.id)], + ) + : []; + const futureCounts = new Map(counts.map((c) => [c.train_id, Number(c.future_count)])); + + return trains + .filter((train) => (train.locomotives ?? []).length >= 2) + .map((train) => { + const wagons = train.wagons ?? []; + return { + id: train.id, + code: train.code, + trainName: train.trainName ?? null, + status: train.status, + currentYardId: train.currentYardId ?? null, + currentYard: train.currentYard + ? { + id: train.currentYard.id, + code: train.currentYard.code, + label: train.currentYard.label, + } + : null, + locomotives: (train.locomotives ?? []) + .filter((link) => link.locomotive) + .map((link) => ({ + id: link.locomotive!.id, + code: link.locomotive!.code, + name: link.locomotive!.name ?? null, + })), + wagonCount: wagons.length, + maxGrossTons: roundTons( + wagons.reduce( + (sum, w) => + sum + + (Number(w.wagonType?.tareWeightTons) || 0) + + (Number(w.wagonType?.capacityTons) || 0), + 0, + ), + ), + totalLengthMeters: roundTons( + wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0), + ), + maxPullWeightTons: roundTons(Number(train.capacityTons)), + atOriginYard: train.currentYardId === route.originYardId, + futureScheduleCount: futureCounts.get(train.id) ?? 0, + }; + }); + } + + /** + * Re-derive a built train's lifecycle status from its schedules after one of + * them changes: any DISPATCHED schedule → IN_SERVICE; any DRAFT/SCHEDULED → + * SCHEDULED; otherwise AVAILABLE. `moveToYardId` relocates the train (arrival + * at destination). Manually parked trains (UNDER_MAINTENANCE / OUT_OF_SERVICE) + * keep their status — staff own that flag, not the scheduler. + */ + private async syncBuiltTrainAfterScheduleChange( + manager: EntityManager, + trainId: string, + moveToYardId?: string | null, + ): Promise { + const train = await manager.getRepository(Train).findOne({ where: { id: trainId } }); + if (!train) return; + + const yardPatch = moveToYardId ? { currentYardId: moveToYardId } : {}; + const managed = [ + Freight.TrainStatus.Available, + Freight.TrainStatus.Scheduled, + Freight.TrainStatus.InService, + ]; + if (!managed.includes(train.status)) { + if (moveToYardId) await manager.getRepository(Train).update(trainId, yardPatch); + return; + } + + const rows: { status: string }[] = await manager.query( + `SELECT DISTINCT ts.status + FROM freight.train_schedules ts + JOIN freight.train_sets tset ON tset.id = ts.train_set_id + WHERE tset.train_id = $1 + AND ts.deleted_at IS NULL + AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')`, + [trainId], + ); + const statuses = new Set(rows.map((r) => r.status)); + const next = statuses.has('DISPATCHED') + ? Freight.TrainStatus.InService + : statuses.size + ? Freight.TrainStatus.Scheduled + : Freight.TrainStatus.Available; + await manager.getRepository(Train).update(trainId, { status: next, ...yardPatch }); + } + + /** + * A contract_route (`cr`) serves a schedule (`ts`) when its yard pair is a + * FORWARD sub-leg of the schedule's corridor — both yards sit on `ts.route`'s + * milestones with the destination stop AFTER the origin stop — OR (routes with + * no milestones recorded) the pair equals the schedule's own endpoints. This + * mirrors the sub-leg matching the create-booking path already does + * (`getBookableScheduleEntities`), so a through train (Djibouti → Kality → + * Dire) is announced and bookable for every leg it actually serves + * (Djibouti → Kality, Djibouti → Dire, Kality → Dire) — not only its two + * endpoints. Static SQL fragment (no user input) interpolated into the window + * queries below; `ts` and `cr` must be the schedule and contract_route aliases. + */ + private readonly CONTRACT_ROUTE_SERVES_SCHEDULE = `( + EXISTS ( + SELECT 1 + FROM freight.route_milestones mo + JOIN freight.route_milestones md + ON md.route_id = mo.route_id + AND md.sequence_no > mo.sequence_no + WHERE mo.route_id = ts.route_id + AND mo.yard_id = cr.origin_yard_id + AND md.yard_id = cr.destination_yard_id + ) + OR (cr.origin_yard_id = ts.origin_station_id + AND cr.destination_yard_id = ts.destination_station_id) + )`; + /** * Upcoming/open booking windows announced on the portal home "booking * windows" section. ALL window-engine schedules (IMPORT cycle / EXPORT lead) @@ -4441,6 +4705,8 @@ export class TrainSchedulingService { * LEFT-JOINed in so the row carries `contractId`/`contractKind` (enabling * "Book now"); customers with no covering contract still see the window with a * null contract, and the portal routes them to the contract list to get one. + * A contract covering any FORWARD sub-leg of the corridor counts as covering + * the lane (see `CONTRACT_ROUTE_SERVES_SCHEDULE`). */ async getBookingWindowsForCompany(companyId: string | null) { const rows: Array = await this.dataSource.query( @@ -4462,9 +4728,8 @@ export class TrainSchedulingService { dy.label AS destination_label, dy.code AS destination_code FROM freight.train_schedules ts LEFT JOIN freight.contract_routes cr - ON cr.origin_yard_id = ts.origin_station_id - AND cr.destination_yard_id = ts.destination_station_id - AND cr.deleted_at IS NULL + ON cr.deleted_at IS NULL + AND ${this.CONTRACT_ROUTE_SERVES_SCHEDULE} LEFT JOIN freight.contracts c ON c.id = cr.contract_id AND c.company_id = $1 @@ -4515,10 +4780,9 @@ export class TrainSchedulingService { dy.label AS destination_label, dy.code AS destination_code FROM freight.train_schedules ts JOIN freight.contract_routes cr - ON cr.origin_yard_id = ts.origin_station_id - AND cr.destination_yard_id = ts.destination_station_id - AND cr.contract_id = $1 + ON cr.contract_id = $1 AND cr.deleted_at IS NULL + AND ${this.CONTRACT_ROUTE_SERVES_SCHEDULE} JOIN freight.contracts c ON c.id = cr.contract_id AND c.deleted_at IS NULL @@ -4622,7 +4886,7 @@ export class TrainSchedulingService { bookingWindowStatus: 'OPEN', }, relations: { - trainSet: { locomotive: true, locomotives: { locomotive: true } }, + trainSet: { locomotive: true, locomotives: { locomotive: true }, train: true }, route: { milestones: true }, originStation: true, destinationStation: true, @@ -4939,6 +5203,14 @@ export class TrainSchedulingService { actualDepartureAt: schedule.actualDepartureAt ?? null, originStation: schedule.originStation, destinationStation: schedule.destinationStation, + // Built train (Train Builder) behind this departure, when scheduled by train. + train: schedule.trainSet?.train + ? { + id: schedule.trainSet.train.id, + code: schedule.trainSet.train.code, + trainName: schedule.trainSet.train.trainName ?? null, + } + : null, trainSet: schedule.trainSet ? { id: schedule.trainSet.id, @@ -5557,10 +5829,16 @@ export class TrainSchedulingService { } const freightType = booking.freightType === 'BULK' ? 'BULK' : 'CONTAINER'; - let wagonType: WagonType; - try { - wagonType = await this.resolveWagonType(freightType, [booking.id]); - } catch { + const [fullBooking] = await this.bookingsRepository.findByIdsForScheduling([booking.id]); + const resolvedBooking = fullBooking ?? booking; + // List-based resolution: every wagon type allowed for the booking's + // container/cargo type counts toward its availability. + const allowed = this.loadAllowedWagonTypes([resolvedBooking]); + const candidates = + freightType === 'BULK' + ? [...allowed.byCargoTypeId.values()].flat() + : [...allowed.byContainerTypeId.values()].flat(); + if (!candidates.length) { return { wagonsRequired: 0, requiredWagonTypeCode: '', @@ -5571,11 +5849,15 @@ export class TrainSchedulingService { } const bulkCapacity = - freightType === 'BULK' ? Number(wagonType.capacityTons) : undefined; - const [fullBooking] = await this.bookingsRepository.findByIdsForScheduling([booking.id]); - const resolvedBooking = fullBooking ?? booking; + freightType === 'BULK' + ? Math.max(...candidates.map((wt) => Number(wt.capacityTons))) + : undefined; const wagonsRequired = wagonsRequiredForBooking(resolvedBooking, bulkCapacity); - const yardWagonsAvailable = fleetByTypeId.get(wagonType.id)?.available ?? 0; + const requiredWagonTypeCode = [...new Set(candidates.map((wt) => wt.code))].join('/'); + const yardWagonsAvailable = candidates.reduce( + (sum, wt) => sum + (fleetByTypeId.get(wt.id)?.available ?? 0), + 0, + ); const allBookingIds = [...wagonAssignedIds, booking.id]; const previewDto = { @@ -5603,7 +5885,7 @@ export class TrainSchedulingService { } catch (err) { return { wagonsRequired, - requiredWagonTypeCode: wagonType.code, + requiredWagonTypeCode, yardWagonsAvailable, canAssign: false, blockReason: err instanceof Error ? err.message : 'Validation failed', @@ -5613,7 +5895,7 @@ export class TrainSchedulingService { if (!validation.valid) { return { wagonsRequired, - requiredWagonTypeCode: wagonType.code, + requiredWagonTypeCode, yardWagonsAvailable, canAssign: false, blockReason: validation.violations[0] ?? 'Booking validation failed', @@ -5625,17 +5907,17 @@ export class TrainSchedulingService { const deferred = validation.deferredBookings.find((d) => d.id === booking.id); const yardShortfall = yardWagonsAvailable < wagonsRequired - ? `No ${wagonType.code} wagons at origin yard (need ${wagonsRequired}, ${yardWagonsAvailable} available)` + ? `No ${requiredWagonTypeCode} wagons at origin yard (need ${wagonsRequired}, ${yardWagonsAvailable} available)` : null; return { wagonsRequired, - requiredWagonTypeCode: wagonType.code, + requiredWagonTypeCode, yardWagonsAvailable, canAssign: false, blockReason: deferred?.reason ?? yardShortfall ?? - `Need ${wagonsRequired} ${wagonType.code} wagon(s) at origin yard`, + `Need ${wagonsRequired} ${requiredWagonTypeCode} wagon(s) at origin yard`, }; } @@ -5650,7 +5932,7 @@ export class TrainSchedulingService { if (missing) { return { wagonsRequired, - requiredWagonTypeCode: wagonType.code, + requiredWagonTypeCode, yardWagonsAvailable, canAssign: false, blockReason: missing.issue, @@ -5660,7 +5942,7 @@ export class TrainSchedulingService { return { wagonsRequired, - requiredWagonTypeCode: wagonType.code, + requiredWagonTypeCode, yardWagonsAvailable, canAssign: true, blockReason: null, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts new file mode 100644 index 000000000..b4b8657ba --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts @@ -0,0 +1,299 @@ +import { AllocationLoadType } from '@edr/types'; + +import { Booking } from '../bookings/entities/booking.entity'; +import { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { sortBookingsForScheduling, type DeferredBookingRow } from './fleet-plan.util'; +import { + MAX_TEU_SLOTS_PER_WAGON, + expandBookingContainerUnits, + roundTons, + tareTonsOf, + teuSlotsForSizeFt, + type SlotLoadType, + type WagonPlanSlot, +} from './wagon-plan.util'; + +/** + * Wagon types allowed to carry each container type / bulk cargo type — the + * many-to-many configuration lists, resolved once per validation run. + */ +export type AllowedWagonTypeMap = { + byContainerTypeId: Map; + byCargoTypeId: Map; +}; + +/** + * Plannable wagon inventory. TRAIN mode is the built train's own consist — + * a hard cap, the plan never reaches for loose yard wagons. YARD mode is the + * AVAILABLE pool at the boarding yards (legacy schedules). + */ +export type WagonStock = { + mode: 'TRAIN' | 'YARD'; + /** Remaining plannable wagons per wagon type id. Missing type = 0. */ + remainingByTypeId: Map; + /** Wagon-type code per id, for human-readable shortfall messages. */ + codesByTypeId: Map; +}; + +export type FlexPlanResult = { + plan: WagonPlanSlot[]; + fitting: Booking[]; + deferred: DeferredBookingRow[]; + /** + * Misconfiguration (a scheduled type with no wagon types configured) — + * a hard violation, unlike stock shortfalls which merely defer bookings. + */ + configIssues: string[]; +}; + +type OpenSlot = { + slot: WagonPlanSlot; + teuUsed: number; + kind: SlotLoadType; + /** Kind purity: a bulk wagon carries ONE cargo type at a time. */ + cargoTypeId: string | null; + freeCapacityTons: number; +}; + +type PlacementProblem = { kind: 'config' | 'stock'; message: string }; + +const slotFromWagonType = (wagonType: WagonType, kind: SlotLoadType): WagonPlanSlot => ({ + sequenceNo: 0, // stamped at the end + wagonTypeId: wagonType.id, + wagonTypeCode: wagonType.code, + capacityTons: Number(wagonType.capacityTons), + lengthMeters: Number(wagonType.lengthMeters), + tareWeightTons: tareTonsOf(wagonType), + assignedWeightTons: 0, + allocations: [], + slotLoadType: kind, +}); + +const addAllocation = ( + slot: WagonPlanSlot, + bookingId: string, + bookingReference: string, + weightTons: number, + loadType: AllocationLoadType, +) => { + let allocation = slot.allocations.find((a) => a.bookingId === bookingId); + if (!allocation) { + allocation = { bookingId, bookingReference, allocatedWeightTons: 0, loadType }; + slot.allocations.push(allocation); + } + allocation.allocatedWeightTons = roundTons(allocation.allocatedWeightTons + weightTons); + slot.assignedWeightTons = roundTons(slot.assignedWeightTons + weightTons); +}; + +/** + * Build the wagon plan against a wagon-type inventory, mixing wagon types + * within one consist. Each booking is atomic: it either fits entirely (its + * containers/tonnage placed on wagons whose type is allowed for its container + * or cargo type) or is deferred with the shortfall reason. Wagon purity rules: + * a wagon carries one kind at a time — containers pack by TEU (one 40ft, or + * two 20ft, never mixed sizes), bulk fills by weight and never shares a wagon + * with a different cargo type. + */ +export function planWagonsWithStock(params: { + bookings: Booking[]; + allowed: AllowedWagonTypeMap; + stock: WagonStock; +}): FlexPlanResult { + const { bookings, allowed, stock } = params; + const remaining = new Map(stock.remainingByTypeId); + const openSlots: OpenSlot[] = []; + const fitting: Booking[] = []; + const deferred: DeferredBookingRow[] = []; + const configIssues = new Set(); + + const noStockMessage = (candidates: WagonType[]): string => { + const codes = candidates.map((wt) => wt.code).join('/'); + return stock.mode === 'TRAIN' + ? `Train has no free ${codes} wagon left` + : `No available ${codes} wagon at the yard`; + }; + + /** Open a new wagon of one of the candidate types, consuming stock. */ + const openSlot = ( + candidates: WagonType[], + kind: SlotLoadType, + cargoTypeId: string | null, + ): OpenSlot | PlacementProblem => { + const inStock = candidates.filter((wt) => (remaining.get(wt.id) ?? 0) > 0); + if (!inStock.length) return { kind: 'stock', message: noStockMessage(candidates) }; + // Bulk favors the largest wagon (fewest wagons for the tonnage); containers + // favor the deepest stock so the consist drains evenly. Ties keep config order. + const chosen = [...inStock].sort((a, b) => + kind === 'BULK' + ? Number(b.capacityTons) - Number(a.capacityTons) || + (remaining.get(b.id) ?? 0) - (remaining.get(a.id) ?? 0) + : (remaining.get(b.id) ?? 0) - (remaining.get(a.id) ?? 0), + )[0]; + remaining.set(chosen.id, (remaining.get(chosen.id) ?? 0) - 1); + const open: OpenSlot = { + slot: slotFromWagonType(chosen, kind), + teuUsed: 0, + kind, + cargoTypeId, + freeCapacityTons: Number(chosen.capacityTons), + }; + openSlots.push(open); + return open; + }; + + const tryPlaceBooking = (booking: Booking): PlacementProblem | null => { + if (booking.freightType === 'CONTAINER') { + const units = expandBookingContainerUnits([booking]); + if (!units.length) { + // Degenerate container booking with no lines still reserves one wagon + // (legacy behavior) — but there is no container type to resolve against. + return { + kind: 'config', + message: `Booking ${booking.reference} has no container lines to plan`, + }; + } + for (const unit of units) { + const candidates = allowed.byContainerTypeId.get(unit.containerTypeId) ?? []; + if (!candidates.length) { + return { + kind: 'config', + message: `Container type "${unit.containerTypeCode}" has no wagon types configured — set them in its configuration before scheduling.`, + }; + } + const allowedIds = new Set(candidates.map((wt) => wt.id)); + const teu = unit.teuSlots ?? teuSlotsForSizeFt(unit.sizeFt ?? 20); + let target = openSlots.find( + (open) => + open.kind === 'CONTAINER' && + allowedIds.has(open.slot.wagonTypeId) && + open.teuUsed + teu <= MAX_TEU_SLOTS_PER_WAGON, + ); + if (!target) { + const openedSlot = openSlot(candidates, 'CONTAINER', null); + if ('message' in openedSlot) return openedSlot; + target = openedSlot; + } + addAllocation( + target.slot, + unit.bookingId, + unit.bookingReference, + unit.grossWeightTons, + AllocationLoadType.Container, + ); + target.teuUsed += teu; + } + return null; + } + + // BULK — weight-based, one cargo type per wagon. + const cargoTypeId = booking.cargoTypeId ?? booking.cargoType?.id ?? null; + const candidates = cargoTypeId ? (allowed.byCargoTypeId.get(cargoTypeId) ?? []) : []; + if (!candidates.length) { + return { + kind: 'config', + message: `Cargo type "${booking.cargoType?.cargoTypeName ?? booking.cargoType?.code ?? 'unknown'}" has no wagon types configured — set them in its configuration before scheduling.`, + }; + } + const allowedIds = new Set(candidates.map((wt) => wt.id)); + let remainingWeight = roundTons(Number(booking.cargoTotalWeightVgm ?? 0)); + let placedAnywhere = false; + + // Top off wagons already carrying THIS cargo type before opening new ones. + for (const open of openSlots) { + if (remainingWeight <= 0) break; + if (open.kind !== 'BULK') continue; + if (open.cargoTypeId !== cargoTypeId) continue; + if (!allowedIds.has(open.slot.wagonTypeId)) continue; + if (open.freeCapacityTons <= 0) continue; + const take = roundTons(Math.min(open.freeCapacityTons, remainingWeight)); + addAllocation( + open.slot, + booking.id, + booking.reference, + take, + AllocationLoadType.Bulk, + ); + open.freeCapacityTons = roundTons(open.freeCapacityTons - take); + remainingWeight = roundTons(remainingWeight - take); + placedAnywhere = true; + } + + while (remainingWeight > 0 || !placedAnywhere) { + const openedSlot = openSlot(candidates, 'BULK', cargoTypeId); + if ('message' in openedSlot) return openedSlot; + const take = roundTons(Math.min(openedSlot.freeCapacityTons, remainingWeight)); + addAllocation( + openedSlot.slot, + booking.id, + booking.reference, + take, + AllocationLoadType.Bulk, + ); + openedSlot.freeCapacityTons = roundTons(openedSlot.freeCapacityTons - take); + remainingWeight = roundTons(remainingWeight - take); + placedAnywhere = true; + } + return null; + }; + + for (const booking of sortBookingsForScheduling(bookings)) { + // Snapshot so a booking that doesn't fully fit leaves no half-placed wagons. + const remainingSnapshot = new Map(remaining); + const slotCountSnapshot = openSlots.length; + const slotStateSnapshot = openSlots.map((open) => ({ + teuUsed: open.teuUsed, + freeCapacityTons: open.freeCapacityTons, + assignedWeightTons: open.slot.assignedWeightTons, + allocationCount: open.slot.allocations.length, + allocationWeights: open.slot.allocations.map((a) => a.allocatedWeightTons), + })); + + const problem = tryPlaceBooking(booking); + if (!problem) { + fitting.push(booking); + continue; + } + + // Roll back this booking's partial placements. + remaining.clear(); + for (const [key, value] of remainingSnapshot) remaining.set(key, value); + openSlots.length = slotCountSnapshot; + openSlots.forEach((open, index) => { + const snap = slotStateSnapshot[index]; + if (!snap) return; + open.teuUsed = snap.teuUsed; + open.freeCapacityTons = snap.freeCapacityTons; + open.slot.assignedWeightTons = snap.assignedWeightTons; + open.slot.allocations.length = snap.allocationCount; + snap.allocationWeights.forEach((weight, allocationIndex) => { + open.slot.allocations[allocationIndex].allocatedWeightTons = weight; + }); + }); + + if (problem.kind === 'config') configIssues.add(problem.message); + deferred.push({ id: booking.id, reference: booking.reference, reason: problem.message }); + } + + return { + plan: openSlots.map((open, index) => ({ ...open.slot, sequenceNo: index + 1 })), + fitting, + deferred, + configIssues: [...configIssues], + }; +} + +/** Unbounded stock — used to compute pure demand for availability reporting. */ +export function unboundedStock(allowed: AllowedWagonTypeMap): WagonStock { + const remainingByTypeId = new Map(); + const codesByTypeId = new Map(); + for (const list of [ + ...allowed.byContainerTypeId.values(), + ...allowed.byCargoTypeId.values(), + ]) { + for (const wagonType of list) { + remainingByTypeId.set(wagonType.id, Number.MAX_SAFE_INTEGER); + codesByTypeId.set(wagonType.id, wagonType.code); + } + } + return { mode: 'YARD', remainingByTypeId, codesByTypeId }; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts index 2af606cdc..78cd8bf54 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts @@ -514,7 +514,7 @@ export function validateTrainLimits( */ export function validateMixedTrainLimits( wagonPlan: WagonPlanSlot[], - wagonTypes: WagonType[], + wagonTypes: Array>, limits?: TrainLimitConfig, ): string[] { const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS; diff --git a/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts b/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts index fde6d75c6..c82cfd2eb 100644 --- a/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts +++ b/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts @@ -2,6 +2,7 @@ import { BaseEntity } from '@edr/api-common'; import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm'; import { Locomotive } from '../../locomotives/entities/locomotive.entity'; +import { Train } from '../../trains/entities/train.entity'; import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; import { TrainSetLocomotive } from './train-set-locomotive.entity'; import { TrainSetWagon } from './train-set-wagon.entity'; @@ -32,6 +33,14 @@ export class TrainSet extends BaseEntity { @OneToMany(() => TrainSetLocomotive, (link) => link.trainSet) locomotives?: TrainSetLocomotive[]; + /** Built fleet train this set was formed from (Train Builder), when scheduled by train. */ + @Column({ name: 'train_id', type: 'uuid', nullable: true }) + trainId!: string | null; + + @ManyToOne(() => Train, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'train_id' }) + train?: Train | null; + @Column({ name: 'total_weight_tons', type: 'numeric', precision: 10, scale: 3 }) totalWeightTons!: number; diff --git a/apps/edr-freight-api/src/modules/trains/dto/assign-train-wagons.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/assign-train-wagons.dto.ts new file mode 100644 index 000000000..e9f150d40 --- /dev/null +++ b/apps/edr-freight-api/src/modules/trains/dto/assign-train-wagons.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ArrayMinSize, IsArray, IsUUID } from 'class-validator'; + +export class AssignTrainWagonsDto { + @ApiProperty({ + type: [String], + format: 'uuid', + description: 'Wagons to append to the consist, in order. Each must be AVAILABLE in the train\'s yard.', + }) + @IsArray() + @ArrayMinSize(1) + @IsUUID('all', { each: true }) + wagonIds!: string[]; +} diff --git a/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts new file mode 100644 index 000000000..c44be8786 --- /dev/null +++ b/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts @@ -0,0 +1,51 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + ArrayMinSize, + IsArray, + IsOptional, + IsString, + IsUUID, + MaxLength, +} from 'class-validator'; + +export class BuildTrainDto { + @ApiProperty({ example: '81001', description: 'Operator-assigned train code (unique)' }) + @IsString() + @MaxLength(32) + code!: string; + + @ApiProperty({ format: 'uuid', description: 'Yard the train is built in' }) + @IsUUID() + currentYardId!: string; + + @ApiProperty({ + type: [String], + format: 'uuid', + description: 'Locomotives pulling the train (minimum 2 — front and back), in consist order', + }) + @IsArray() + @ArrayMinSize(2, { message: 'A train must be pulled by at least two locomotives' }) + @IsUUID('all', { each: true }) + locomotiveIds!: string[]; + + @ApiPropertyOptional({ + type: [String], + format: 'uuid', + description: 'Wagons to attach at build time, in consist order (must sit in the same yard)', + }) + @IsOptional() + @IsArray() + @IsUUID('all', { each: true }) + wagonIds?: string[]; + + @ApiPropertyOptional({ maxLength: 100 }) + @IsOptional() + @IsString() + @MaxLength(100) + trainName?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/trains/dto/list-built-trains-query.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/list-built-trains-query.dto.ts new file mode 100644 index 000000000..4c2e259a8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/trains/dto/list-built-trains-query.dto.ts @@ -0,0 +1,22 @@ +import { Freight } from '@edr/types'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsIn, IsOptional, IsUUID } from 'class-validator'; + +import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto'; + +export class ListBuiltTrainsQueryDto extends PaginationQueryDto { + @ApiPropertyOptional({ enum: Freight.TrainStatus }) + @IsOptional() + @IsEnum(Freight.TrainStatus) + status?: Freight.TrainStatus; + + @ApiPropertyOptional({ format: 'uuid', description: 'Only trains sitting in this yard' }) + @IsOptional() + @IsUUID() + currentYardId?: string; + + @ApiPropertyOptional({ enum: ['code', 'trainName', 'status', 'createdAt'] }) + @IsOptional() + @IsIn(['code', 'trainName', 'status', 'createdAt']) + sortBy?: 'code' | 'trainName' | 'status' | 'createdAt'; +} diff --git a/apps/edr-freight-api/src/modules/trains/dto/reorder-train-wagons.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/reorder-train-wagons.dto.ts new file mode 100644 index 000000000..15e5020ca --- /dev/null +++ b/apps/edr-freight-api/src/modules/trains/dto/reorder-train-wagons.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ArrayMinSize, IsArray, IsUUID } from 'class-validator'; + +export class ReorderTrainWagonsDto { + @ApiProperty({ + type: [String], + format: 'uuid', + description: 'Every wagon of the train, in the new consist order', + }) + @IsArray() + @ArrayMinSize(1) + @IsUUID('all', { each: true }) + wagonIds!: string[]; +} diff --git a/apps/edr-freight-api/src/modules/trains/dto/update-train-locomotives.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/update-train-locomotives.dto.ts new file mode 100644 index 000000000..36562e970 --- /dev/null +++ b/apps/edr-freight-api/src/modules/trains/dto/update-train-locomotives.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ArrayMinSize, IsArray, IsUUID } from 'class-validator'; + +export class UpdateTrainLocomotivesDto { + @ApiProperty({ + type: [String], + format: 'uuid', + description: 'Full replacement locomotive set (minimum 2), in consist order', + }) + @IsArray() + @ArrayMinSize(2, { message: 'A train must be pulled by at least two locomotives' }) + @IsUUID('all', { each: true }) + locomotiveIds!: string[]; +} diff --git a/apps/edr-freight-api/src/modules/trains/entities/train-locomotive.entity.ts b/apps/edr-freight-api/src/modules/trains/entities/train-locomotive.entity.ts new file mode 100644 index 000000000..681b39a55 --- /dev/null +++ b/apps/edr-freight-api/src/modules/trains/entities/train-locomotive.entity.ts @@ -0,0 +1,34 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Locomotive } from '../../locomotives/entities/locomotive.entity'; +import { Train } from './train.entity'; + +/** + * Link row joining a built train to one of its locomotives. A train must be + * pulled by at least two locomotives (front + back); `sequenceNo` is the order + * in the consist — 0 is the lead locomotive. + * + * Mirrors `train_set_locomotives`, but for the persistent fleet `Train` built + * in the Train Builder rather than the per-departure operational train set. + */ +@Entity({ schema: 'freight', name: 'train_locomotives' }) +@Index(['trainId', 'locomotiveId'], { unique: true }) +export class TrainLocomotive extends BaseEntity { + @Column({ name: 'train_id', type: 'uuid' }) + trainId!: string; + + @ManyToOne(() => Train, (train) => train.locomotives, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'train_id' }) + train?: Train; + + @Column({ name: 'locomotive_id', type: 'uuid' }) + locomotiveId!: string; + + @ManyToOne(() => Locomotive) + @JoinColumn({ name: 'locomotive_id' }) + locomotive?: Locomotive; + + @Column({ name: 'sequence_no', type: 'int', default: 0 }) + sequenceNo!: number; +} diff --git a/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts b/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts index ab6b49b1d..078b07a20 100644 --- a/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts +++ b/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts @@ -1,12 +1,16 @@ // apps/edr-freight-api/src/modules/trains/entities/train.entity.ts import { BaseEntity } from '@edr/api-common'; import { Freight } from '@edr/types'; -import { Column, Entity, OneToMany } from 'typeorm'; +import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; +import { Yard } from '../../rule-engine/entities/yard.entity'; import { Wagon } from '../../wagons/entities/wagon.entity'; +import { TrainLocomotive } from './train-locomotive.entity'; /** - * Fleet master data — named wagon consist in inventory (POST /trains). - * Operational departures use train_schedules + locomotives; scheduling never creates trains rows. + * Fleet master data — a train built in the Train Builder: a coded consist + * (e.g. 81001) of 2+ locomotives and ordered wagons, assembled in one yard. + * Operational departures reference it through `train_sets.train_id`; the + * schedule's own composition still lives on the train set. */ @Entity({ schema: 'freight', name: 'trains' }) export class Train extends BaseEntity { @@ -56,7 +60,19 @@ export class Train extends BaseEntity { @Column({ name: 'remarks', type: 'text', nullable: true }) remarks?: string; + /** Yard where the train currently sits (set at build, moved on schedule arrival). */ + @Column({ name: 'current_yard_id', type: 'uuid', nullable: true }) + currentYardId!: string | null; + + @ManyToOne(() => Yard, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'current_yard_id' }) + currentYard?: Yard | null; + // --- relationships --- - @OneToMany(() => Wagon, (wagon) => wagon.train) - wagons!: Wagon[]; // fixed typo: was 'wagens' + @OneToMany(() => Wagon, (wagon) => wagon.train) + wagons!: Wagon[]; + + /** Locomotives pulling this train (minimum 2), ordered by sequenceNo. */ + @OneToMany(() => TrainLocomotive, (link) => link.train) + locomotives?: TrainLocomotive[]; } \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts new file mode 100644 index 000000000..7281aca31 --- /dev/null +++ b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts @@ -0,0 +1,91 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Post, + Put, + Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { FleetManage, FleetView } from '../../common/booking-guards'; +import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto'; +import { BuildTrainDto } from './dto/build-train.dto'; +import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto'; +import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto'; +import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto'; +import { TrainBuilderService } from './train-builder.service'; + +@ApiTags('train-builder') +@ApiBearerAuth() +@Controller('train-builder') +@FleetView() +export class TrainBuilderController { + constructor(private readonly trainBuilderService: TrainBuilderService) {} + + @Post() + @FleetManage() + @ApiOperation({ summary: 'Build a train: code + yard + 2+ locomotives (+ optional wagons)' }) + build(@Body() dto: BuildTrainDto) { + return this.trainBuilderService.buildTrain(dto); + } + + @Get() + @ApiOperation({ summary: 'Paginated built trains with composition summary' }) + list(@Query() query: ListBuiltTrainsQueryDto) { + return this.trainBuilderService.listBuilt(query); + } + + @Get(':id') + @ApiOperation({ summary: 'Full train composition: locomotives, ordered wagons, totals vs. limits' }) + composition(@Param('id', ParseUUIDPipe) id: string) { + return this.trainBuilderService.getComposition(id); + } + + @Put(':id/locomotives') + @FleetManage() + @ApiOperation({ summary: 'Replace the locomotive set (minimum 2, same yard)' }) + setLocomotives( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateTrainLocomotivesDto, + ) { + return this.trainBuilderService.setLocomotives(id, dto); + } + + @Post(':id/wagons') + @FleetManage() + @ApiOperation({ summary: "Append AVAILABLE wagons from the train's yard to the consist" }) + assignWagons(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignTrainWagonsDto) { + return this.trainBuilderService.assignWagons(id, dto); + } + + @Delete(':id/wagons/:wagonId') + @FleetManage() + @ApiOperation({ summary: 'Detach one wagon from the consist' }) + removeWagon( + @Param('id', ParseUUIDPipe) id: string, + @Param('wagonId', ParseUUIDPipe) wagonId: string, + ) { + return this.trainBuilderService.removeWagon(id, wagonId); + } + + @Post(':id/reorder-wagons') + @FleetManage() + @ApiOperation({ summary: 'Persist a drag-reorder of the full consist' }) + reorderWagons(@Param('id', ParseUUIDPipe) id: string, @Body() dto: ReorderTrainWagonsDto) { + return this.trainBuilderService.reorderWagons(id, dto); + } + + @Delete(':id') + @FleetManage() + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Disband the train (release wagons and locomotives)' }) + disband(@Param('id', ParseUUIDPipe) id: string) { + return this.trainBuilderService.disband(id); + } +} diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts new file mode 100644 index 000000000..c28873234 --- /dev/null +++ b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts @@ -0,0 +1,507 @@ +import { Freight, WagonStatus } from '@edr/types'; +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { DataSource, EntityManager, ILike, In } from 'typeorm'; + +import { Locomotive } from '../locomotives/entities/locomotive.entity'; +import { Yard } from '../rule-engine/entities/yard.entity'; +import { minLocomotiveLimits } from '../train-scheduling/train-capacity.util'; +import { Wagon } from '../wagons/entities/wagon.entity'; +import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto'; +import { BuildTrainDto } from './dto/build-train.dto'; +import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto'; +import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto'; +import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto'; +import { TrainLocomotive } from './entities/train-locomotive.entity'; +import { Train } from './entities/train.entity'; +import { + buildPaginationMeta, + normalizePagination, +} from '../../common/utils/pagination.util'; + +const round = (value: unknown) => Math.round((Number(value) || 0) * 100) / 100; + +/** + * Train Builder — assembles persistent fleet trains (code + 2+ locomotives + + * ordered wagons, all in one yard) that scheduling can later reference as a + * unit instead of hand-picking locomotives per departure. + * + * Resource rules: + * - Locomotive double-use is prevented through the `train_locomotives` link + * table (a locomotive rides at most one built train); its `status` column + * keeps its operational meaning (ASSIGNED = out on a dispatched train). + * - Wagons attached to a train are flipped to ASSIGNED (same semantic the + * legacy assign-train flow uses), so no other train or schedule grabs them. + */ +@Injectable() +export class TrainBuilderService { + constructor(private readonly dataSource: DataSource) {} + + async buildTrain(dto: BuildTrainDto) { + const locomotiveIds = [...new Set(dto.locomotiveIds)]; + if (locomotiveIds.length < 2) { + throw new BadRequestException('A train must be pulled by at least two locomotives'); + } + + const trainId = await this.dataSource.transaction(async (manager) => { + const code = dto.code.trim(); + const existing = await manager.getRepository(Train).findOne({ where: { code } }); + if (existing) { + throw new ConflictException(`Train code ${code} is already in use`); + } + + const yard = await manager.getRepository(Yard).findOne({ where: { id: dto.currentYardId } }); + if (!yard) throw new NotFoundException(`Yard ${dto.currentYardId} not found`); + + const locomotives = await this.validateAndLockLocomotives( + manager, + locomotiveIds, + yard, + null, + ); + + // Effective haul capacity is capped by the weakest locomotive in the set. + const limits = minLocomotiveLimits(locomotives); + const train = await manager.getRepository(Train).save( + manager.getRepository(Train).create({ + code, + currentYardId: yard.id, + capacityTons: round(limits?.maxPullWeightTons ?? 0), + status: Freight.TrainStatus.Available, + trainName: dto.trainName?.trim() || undefined, + notes: dto.notes?.trim() || undefined, + }), + ); + + await this.replaceLocomotiveLinks(manager, train.id, locomotiveIds); + + if (dto.wagonIds?.length) { + await this.attachWagons(manager, train, dto.wagonIds, 0); + } + return train.id; + }); + + return this.getComposition(trainId); + } + + /** Paginated builder list with a composition summary per train. */ + async listBuilt(query: ListBuiltTrainsQueryDto) { + const { page, pageSize, skip, take } = normalizePagination(query); + const search = query.search?.trim(); + const filters = { + ...(query.status ? { status: query.status } : {}), + ...(query.currentYardId ? { currentYardId: query.currentYardId } : {}), + }; + const where = search + ? [ + { ...filters, code: ILike(`%${search}%`) }, + { ...filters, trainName: ILike(`%${search}%`) }, + ] + : filters; + + const [trains, total] = await this.dataSource.getRepository(Train).findAndCount({ + where, + relations: { + currentYard: true, + locomotives: { locomotive: true }, + wagons: { wagonType: true }, + }, + order: { [query.sortBy ?? 'createdAt']: query.sortOrder ?? 'DESC' }, + skip, + take, + }); + + return { + items: trains.map((train) => this.mapSummary(train)), + meta: buildPaginationMeta(total, page, pageSize), + }; + } + + /** Full consist: yard, ordered locomotives + wagons, totals vs. haul limits. */ + async getComposition(id: string) { + const train = await this.dataSource.getRepository(Train).findOne({ + where: { id }, + relations: { + currentYard: true, + locomotives: { locomotive: { currentYard: true } }, + wagons: { wagonType: true, currentYard: true }, + }, + order: { + locomotives: { sequenceNo: 'ASC' }, + wagons: { sequenceNumber: 'ASC' }, + }, + }); + if (!train) throw new NotFoundException(`Train ${id} not found`); + + const schedules: { id: string; status: string; reference: string | null }[] = + await this.dataSource.query( + `SELECT ts.id, ts.status, ts.reference + FROM freight.train_schedules ts + JOIN freight.train_sets tset ON tset.id = ts.train_set_id + WHERE tset.train_id = $1 + AND ts.deleted_at IS NULL + AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED') + ORDER BY ts.scheduled_departure_date ASC`, + [id], + ); + + const locomotives = (train.locomotives ?? []) + .filter((link) => link.locomotive) + .map((link, index) => ({ + id: link.locomotive!.id, + code: link.locomotive!.code, + name: link.locomotive!.name ?? null, + locomotiveType: link.locomotive!.locomotiveType, + status: link.locomotive!.status, + sequenceNo: link.sequenceNo, + role: index === 0 ? 'LEAD' : 'ASSIST', + currentYardId: link.locomotive!.currentYardId ?? null, + currentYard: link.locomotive!.currentYard + ? { + id: link.locomotive!.currentYard.id, + code: link.locomotive!.currentYard.code, + label: link.locomotive!.currentYard.label, + } + : null, + maxPullWeightTons: round(link.locomotive!.maxPullWeightTons), + maxTrainLengthMeters: round(link.locomotive!.maxTrainLengthMeters), + })); + + const wagons = (train.wagons ?? []).map((wagon) => ({ + id: wagon.id, + wagonNumber: wagon.wagonNumber, + sequenceNumber: wagon.sequenceNumber, + status: wagon.status, + wagonType: wagon.wagonType + ? { + id: wagon.wagonType.id, + code: wagon.wagonType.code, + name: wagon.wagonType.name, + capacityTons: round(wagon.wagonType.capacityTons), + tareWeightTons: round(wagon.wagonType.tareWeightTons), + lengthMeters: round(wagon.wagonType.lengthMeters), + } + : null, + })); + + const limits = minLocomotiveLimits( + (train.locomotives ?? []) + .map((link) => link.locomotive) + .filter((loco): loco is Locomotive => Boolean(loco)), + ); + const totalTareTons = round( + wagons.reduce((sum, w) => sum + (w.wagonType?.tareWeightTons ?? 0), 0), + ); + const totalCapacityTons = round( + wagons.reduce((sum, w) => sum + (w.wagonType?.capacityTons ?? 0), 0), + ); + const totalLengthMeters = round( + wagons.reduce((sum, w) => sum + (w.wagonType?.lengthMeters ?? 0), 0), + ); + const maxGrossTons = round(totalTareTons + totalCapacityTons); + const maxPullWeightTons = round(limits?.maxPullWeightTons ?? 0); + const maxTrainLengthMeters = round(limits?.maxTrainLengthMeters ?? 0); + + return { + id: train.id, + code: train.code, + trainName: train.trainName ?? null, + status: train.status, + notes: train.notes ?? null, + createdAt: train.createdAt, + currentYard: train.currentYard + ? { id: train.currentYard.id, code: train.currentYard.code, label: train.currentYard.label } + : null, + locomotives, + wagons, + totals: { + wagonCount: wagons.length, + totalTareTons, + totalCapacityTons, + maxGrossTons, + totalLengthMeters, + maxPullWeightTons, + maxTrainLengthMeters, + // Fully loaded gross vs. what the weakest locomotive can haul. + weightUtilizationPct: maxPullWeightTons + ? round((maxGrossTons / maxPullWeightTons) * 100) + : null, + lengthUtilizationPct: maxTrainLengthMeters + ? round((totalLengthMeters / maxTrainLengthMeters) * 100) + : null, + }, + activeSchedules: schedules, + // Composition is frozen while the train is out on a dispatched run. + editable: !schedules.some((s) => s.status === 'DISPATCHED'), + }; + } + + /** Replace the locomotive set (still minimum 2, same-yard rule applies). */ + async setLocomotives(id: string, dto: UpdateTrainLocomotivesDto) { + const locomotiveIds = [...new Set(dto.locomotiveIds)]; + if (locomotiveIds.length < 2) { + throw new BadRequestException('A train must be pulled by at least two locomotives'); + } + await this.dataSource.transaction(async (manager) => { + const train = await this.getEditableTrain(manager, id); + const yard = await manager + .getRepository(Yard) + .findOne({ where: { id: train.currentYardId ?? '' } }); + if (!yard) { + throw new BadRequestException('Train has no yard; set the yard before changing locomotives'); + } + const locomotives = await this.validateAndLockLocomotives( + manager, + locomotiveIds, + yard, + train.id, + ); + await this.replaceLocomotiveLinks(manager, train.id, locomotiveIds); + const limits = minLocomotiveLimits(locomotives); + await manager + .getRepository(Train) + .update(train.id, { capacityTons: round(limits?.maxPullWeightTons ?? 0) }); + }); + return this.getComposition(id); + } + + /** Append AVAILABLE wagons from the train's own yard to the consist. */ + async assignWagons(id: string, dto: AssignTrainWagonsDto) { + await this.dataSource.transaction(async (manager) => { + const train = await this.getEditableTrain(manager, id); + const currentCount = await manager + .getRepository(Wagon) + .count({ where: { trainId: train.id } }); + await this.attachWagons(manager, train, dto.wagonIds, currentCount); + }); + return this.getComposition(id); + } + + /** Detach one wagon and close the sequence gap it leaves. */ + async removeWagon(id: string, wagonId: string) { + await this.dataSource.transaction(async (manager) => { + const train = await this.getEditableTrain(manager, id); + const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } }); + if (!wagon || wagon.trainId !== train.id) { + throw new NotFoundException(`Wagon ${wagonId} is not part of this train`); + } + if (wagon.currentTrainScheduleId) { + throw new ConflictException( + `Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`, + ); + } + await manager.getRepository(Wagon).update(wagon.id, { + trainId: null, + sequenceNumber: null, + status: WagonStatus.Available, + }); + await this.resequenceWagons(manager, train.id); + }); + return this.getComposition(id); + } + + /** Persist a drag-reorder: `wagonIds` is the full consist in its new order. */ + async reorderWagons(id: string, dto: ReorderTrainWagonsDto) { + await this.dataSource.transaction(async (manager) => { + const train = await this.getEditableTrain(manager, id); + const wagons = await manager + .getRepository(Wagon) + .find({ where: { trainId: train.id } }); + const current = new Set(wagons.map((w) => w.id)); + const incoming = new Set(dto.wagonIds); + if (current.size !== incoming.size || [...current].some((wid) => !incoming.has(wid))) { + throw new BadRequestException('Reorder must include every wagon of the train exactly once'); + } + for (let i = 0; i < dto.wagonIds.length; i++) { + await manager.getRepository(Wagon).update(dto.wagonIds[i], { sequenceNumber: i + 1 }); + } + }); + return this.getComposition(id); + } + + /** Disband the train: release wagons and locomotives, then delete it. */ + async disband(id: string): Promise { + await this.dataSource.transaction(async (manager) => { + const train = await manager.getRepository(Train).findOne({ where: { id } }); + if (!train) throw new NotFoundException(`Train ${id} not found`); + const active: { count: string }[] = await manager.query( + `SELECT COUNT(*)::text AS count + FROM freight.train_schedules ts + JOIN freight.train_sets tset ON tset.id = ts.train_set_id + WHERE tset.train_id = $1 + AND ts.deleted_at IS NULL + AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')`, + [id], + ); + if (Number(active[0]?.count ?? 0) > 0) { + throw new ConflictException( + 'Train has active schedules; cancel them before disbanding the train', + ); + } + await manager + .getRepository(Wagon) + .update( + { trainId: train.id }, + { trainId: null, sequenceNumber: null, status: WagonStatus.Available }, + ); + await manager.getRepository(TrainLocomotive).delete({ trainId: train.id }); + await manager.getRepository(Train).remove(train); + }); + } + + // ---------------------------------------------------------------- internals + + private mapSummary(train: Train) { + const locomotives = [...(train.locomotives ?? [])] + .sort((a, b) => a.sequenceNo - b.sequenceNo) + .map((link) => link.locomotive) + .filter((loco): loco is Locomotive => Boolean(loco)); + const wagons = train.wagons ?? []; + const maxGrossTons = round( + wagons.reduce( + (sum, w) => + sum + (Number(w.wagonType?.tareWeightTons) || 0) + (Number(w.wagonType?.capacityTons) || 0), + 0, + ), + ); + return { + id: train.id, + code: train.code, + trainName: train.trainName ?? null, + status: train.status, + createdAt: train.createdAt, + currentYard: train.currentYard + ? { id: train.currentYard.id, code: train.currentYard.code, label: train.currentYard.label } + : null, + locomotives: locomotives.map((loco) => ({ id: loco.id, code: loco.code, name: loco.name ?? null })), + wagonCount: wagons.length, + maxGrossTons, + totalLengthMeters: round( + wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0), + ), + maxPullWeightTons: round(train.capacityTons), + }; + } + + /** Load + freeze the train row for edit; block edits while it is out on a run. */ + private async getEditableTrain(manager: EntityManager, id: string): Promise { + const train = await manager.getRepository(Train).findOne({ + where: { id }, + lock: { mode: 'pessimistic_write' }, + }); + if (!train) throw new NotFoundException(`Train ${id} not found`); + if (train.status === Freight.TrainStatus.InService) { + throw new ConflictException( + `Train ${train.code} is out on a dispatched run; its composition is frozen until arrival`, + ); + } + return train; + } + + /** + * Lock and validate the locomotives for a build/replace: each must exist, be + * serviceable, sit in the train's yard, and not ride another built train. + */ + private async validateAndLockLocomotives( + manager: EntityManager, + locomotiveIds: string[], + yard: Yard, + ownTrainId: string | null, + ): Promise { + const locomotives: Locomotive[] = []; + for (const locomotiveId of locomotiveIds) { + const locked = await manager.getRepository(Locomotive).findOne({ + where: { id: locomotiveId }, + lock: { mode: 'pessimistic_write' }, + }); + if (!locked) throw new NotFoundException(`Locomotive ${locomotiveId} not found`); + if (locked.status === 'OUT_OF_SERVICE' || locked.status === 'MAINTENANCE') { + throw new ConflictException(`Locomotive ${locked.code} is ${locked.status.toLowerCase().replace('_', ' ')}`); + } + if (locked.currentYardId !== yard.id) { + throw new BadRequestException( + `Locomotive ${locked.code} is not in yard ${yard.label ?? yard.code}; a train can only be built from locomotives in its own yard`, + ); + } + locomotives.push(locked); + } + + const taken = await manager.getRepository(TrainLocomotive).find({ + where: { locomotiveId: In(locomotiveIds) }, + relations: { train: true }, + }); + const conflict = taken.find((link) => link.trainId !== ownTrainId); + if (conflict) { + const loco = locomotives.find((l) => l.id === conflict.locomotiveId); + throw new ConflictException( + `Locomotive ${loco?.code ?? conflict.locomotiveId} is already coupled to train ${conflict.train?.code ?? conflict.trainId}`, + ); + } + return locomotives; + } + + private async replaceLocomotiveLinks( + manager: EntityManager, + trainId: string, + locomotiveIds: string[], + ): Promise { + await manager.getRepository(TrainLocomotive).delete({ trainId }); + await manager.getRepository(TrainLocomotive).save( + locomotiveIds.map((locomotiveId, index) => + manager.getRepository(TrainLocomotive).create({ trainId, locomotiveId, sequenceNo: index }), + ), + ); + } + + private async attachWagons( + manager: EntityManager, + train: Train, + wagonIds: string[], + startCount: number, + ): Promise { + const uniqueIds = [...new Set(wagonIds)]; + let sequence = startCount; + for (const wagonId of uniqueIds) { + const wagon = await manager.getRepository(Wagon).findOne({ + where: { id: wagonId }, + lock: { mode: 'pessimistic_write' }, + }); + if (!wagon) throw new NotFoundException(`Wagon ${wagonId} not found`); + if (wagon.trainId === train.id) continue; + if (wagon.trainId) { + throw new ConflictException(`Wagon ${wagon.wagonNumber} is already on another train`); + } + if (wagon.status !== WagonStatus.Available) { + throw new ConflictException(`Wagon ${wagon.wagonNumber} is not available (${wagon.status})`); + } + if (wagon.currentYardId !== train.currentYardId) { + throw new BadRequestException( + `Wagon ${wagon.wagonNumber} is not in the train's yard; only wagons in the same yard can be attached`, + ); + } + sequence += 1; + await manager.getRepository(Wagon).update(wagon.id, { + trainId: train.id, + sequenceNumber: sequence, + status: WagonStatus.Assigned, + }); + } + } + + /** Compact wagon sequence numbers back to 1..n after a removal. */ + private async resequenceWagons(manager: EntityManager, trainId: string): Promise { + const wagons = await manager.getRepository(Wagon).find({ + where: { trainId }, + order: { sequenceNumber: 'ASC' }, + }); + for (let i = 0; i < wagons.length; i++) { + if (wagons[i].sequenceNumber !== i + 1) { + await manager.getRepository(Wagon).update(wagons[i].id, { sequenceNumber: i + 1 }); + } + } + } +} diff --git a/apps/edr-freight-api/src/modules/trains/trains.module.ts b/apps/edr-freight-api/src/modules/trains/trains.module.ts index 61098ff40..0c2ce8ded 100644 --- a/apps/edr-freight-api/src/modules/trains/trains.module.ts +++ b/apps/edr-freight-api/src/modules/trains/trains.module.ts @@ -1,14 +1,17 @@ // apps/edr-freight-api/src/modules/trains/trains.module.ts import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { TrainLocomotive } from './entities/train-locomotive.entity'; import { Train } from './entities/train.entity'; +import { TrainBuilderController } from './train-builder.controller'; +import { TrainBuilderService } from './train-builder.service'; import { TrainsController } from './trains.controller'; import { TrainsService } from './trains.service'; @Module({ - imports: [TypeOrmModule.forFeature([Train])], - controllers: [TrainsController], - providers: [TrainsService], - exports: [TrainsService], // if other modules need it + imports: [TypeOrmModule.forFeature([Train, TrainLocomotive])], + controllers: [TrainsController, TrainBuilderController], + providers: [TrainsService, TrainBuilderService], + exports: [TrainsService, TrainBuilderService], }) -export class TrainsModule {} \ No newline at end of file +export class TrainsModule {} diff --git a/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts new file mode 100644 index 000000000..4dd9f0f75 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts @@ -0,0 +1,28 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator'; + +/** + * A count-only wagon-transfer request. The requester picks source yard, wagon + * type, destination yard and HOW MANY — never the specific wagons; OCC hand-picks + * those at fulfilment. + */ +export class CreateTransferRequestDto { + @IsUUID() + fromYardId!: string; + + @IsUUID() + toYardId!: string; + + @IsUUID() + wagonTypeId!: string; + + @IsInt() + @Min(1) + @Max(1000) + quantity!: number; + + @ApiPropertyOptional({ description: 'Optional note for the fulfilling staff' }) + @IsOptional() + @IsString() + note?: string; +} diff --git a/apps/edr-freight-api/src/modules/wagons/dto/fulfill-transfer-request.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/fulfill-transfer-request.dto.ts new file mode 100644 index 000000000..565156304 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/dto/fulfill-transfer-request.dto.ts @@ -0,0 +1,13 @@ +import { ArrayNotEmpty, IsArray, IsUUID } from 'class-validator'; + +/** + * OCC fulfilment: the specific wagons hand-picked to satisfy a transfer request. + * The service validates they all sit in the request's source yard, match its + * wagon type, and number exactly the requested quantity. + */ +export class FulfillTransferRequestDto { + @IsArray() + @ArrayNotEmpty() + @IsUUID('4', { each: true }) + wagonIds!: string[]; +} diff --git a/apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts b/apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts new file mode 100644 index 000000000..40005de26 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts @@ -0,0 +1,62 @@ +import { BaseEntity } from '@edr/api-common'; +import { WagonTransferRequestStatus } from '@edr/types'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; + +/** + * A two-person wagon relocation request. A requester asks for `quantity` wagons + * of `wagonTypeId` to move from `fromYardId` to `toYardId` — specifying a count + * only, never the physical wagons. OCC staff later open the PENDING request, + * hand-pick the actual wagons in the source yard, and execute the transfer + * (which writes the `wagon_movements` ledger and marks this FULFILLED). + */ +@Entity({ schema: 'freight', name: 'wagon_transfer_requests' }) +@Index(['status', 'fromYardId']) +export class WagonTransferRequest extends BaseEntity { + @Column({ name: 'from_yard_id', type: 'uuid' }) + fromYardId!: string; + + @ManyToOne(() => Yard) + @JoinColumn({ name: 'from_yard_id' }) + fromYard?: Yard | null; + + @Column({ name: 'to_yard_id', type: 'uuid' }) + toYardId!: string; + + @ManyToOne(() => Yard) + @JoinColumn({ name: 'to_yard_id' }) + toYard?: Yard | null; + + @Column({ name: 'wagon_type_id', type: 'uuid' }) + wagonTypeId!: string; + + @ManyToOne(() => WagonType) + @JoinColumn({ name: 'wagon_type_id' }) + wagonType?: WagonType | null; + + /** How many wagons of `wagonTypeId` to move out of `fromYardId`. */ + @Column({ name: 'quantity', type: 'int' }) + quantity!: number; + + @Column({ + name: 'status', + type: 'varchar', + length: 20, + default: WagonTransferRequestStatus.Pending, + }) + status!: WagonTransferRequestStatus; + + @Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true }) + requestedByUserId?: string | null; + + @Column({ name: 'fulfilled_by_user_id', type: 'uuid', nullable: true }) + fulfilledByUserId?: string | null; + + @Column({ name: 'fulfilled_at', type: 'timestamptz', nullable: true }) + fulfilledAt?: Date | null; + + @Column({ name: 'note', type: 'text', nullable: true }) + note?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts new file mode 100644 index 000000000..07557f431 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts @@ -0,0 +1,76 @@ +import { WagonTransferRequestStatus } from '@edr/types'; +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + Query, +} from '@nestjs/common'; +import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '@edr/api-common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; + +import { + FleetManage, + FleetView, + WagonTransferFulfill, + WagonTransferRequest, +} from '../../common/booking-guards'; +import { CreateTransferRequestDto } from './dto/create-transfer-request.dto'; +import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto'; +import { WagonTransferRequestsService } from './wagon-transfer-requests.service'; + +/** + * Two-person wagon-transfer queue. Requester (transfer_request perm) files a + * count-only request; OCC (transfer_fulfill perm) picks the wagons and executes + * the move. Separate top-level path so it never collides with `wagons/:id`. + */ +@ApiTags('wagon-transfer-requests') +@Controller('wagon-transfer-requests') +@FleetView() +export class WagonTransferRequestsController { + constructor(private readonly service: WagonTransferRequestsService) {} + + @Post() + @WagonTransferRequest() + @ApiOperation({ summary: 'File a count-only wagon-transfer request' }) + create( + @Body() dto: CreateTransferRequestDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.createRequest(dto, user?.id); + } + + @Get() + @ApiQuery({ name: 'status', required: false, enum: WagonTransferRequestStatus }) + @ApiOperation({ summary: 'List transfer requests (OCC queue: status=PENDING)' }) + list(@Query('status') status?: WagonTransferRequestStatus) { + return this.service.listRequests(status); + } + + @Get(':id') + @ApiOperation({ summary: 'Get one transfer request' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.service.findById(id); + } + + @Post(':id/fulfill') + @WagonTransferFulfill() + @ApiOperation({ summary: 'OCC: pick wagons and execute the transfer' }) + fulfill( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: FulfillTransferRequestDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.fulfillRequest(id, dto, user?.id); + } + + @Post(':id/cancel') + @FleetManage() + @ApiOperation({ summary: 'Withdraw a pending transfer request' }) + cancel(@Param('id', ParseUUIDPipe) id: string) { + return this.service.cancelRequest(id); + } +} diff --git a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts new file mode 100644 index 000000000..64b408b6a --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts @@ -0,0 +1,153 @@ +import { WagonTransferRequestStatus } from '@edr/types'; +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, Repository } from 'typeorm'; + +import { CreateTransferRequestDto } from './dto/create-transfer-request.dto'; +import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto'; +import { Wagon } from './entities/wagon.entity'; +import { WagonTransferRequest } from './entities/wagon-transfer-request.entity'; +import { WagonsService } from './wagons.service'; + +const REQUEST_RELATIONS = { + fromYard: true, + toYard: true, + wagonType: true, +} as const; + +/** + * Two-person wagon-transfer workflow. A requester records a count-only request + * (see `createRequest`); OCC staff later open the PENDING queue, hand-pick the + * physical wagons, and `fulfillRequest` validates + executes the move. Replaces + * the single-step instant bulk transfer. + */ +@Injectable() +export class WagonTransferRequestsService { + constructor( + @InjectRepository(WagonTransferRequest) + private readonly requestRepo: Repository, + @InjectRepository(Wagon) + private readonly wagonRepo: Repository, + private readonly wagonsService: WagonsService, + ) {} + + /** Record a PENDING request. Count-only — no wagons are picked here. */ + async createRequest( + dto: CreateTransferRequestDto, + userId?: string | null, + ): Promise { + if (dto.fromYardId === dto.toYardId) { + throw new BadRequestException( + 'Source and destination yard must be different', + ); + } + const request = this.requestRepo.create({ + fromYardId: dto.fromYardId, + toYardId: dto.toYardId, + wagonTypeId: dto.wagonTypeId, + quantity: dto.quantity, + status: WagonTransferRequestStatus.Pending, + requestedByUserId: userId ?? null, + note: dto.note ?? null, + }); + const saved = await this.requestRepo.save(request); + return this.findById(saved.id); + } + + /** Requests, newest first, optionally filtered by status (OCC queue = PENDING). */ + async listRequests( + status?: WagonTransferRequestStatus, + ): Promise { + return this.requestRepo.find({ + where: status ? { status } : {}, + relations: REQUEST_RELATIONS, + order: { createdAt: 'DESC' }, + }); + } + + async findById(id: string): Promise { + const request = await this.requestRepo.findOne({ + where: { id }, + relations: REQUEST_RELATIONS, + }); + if (!request) throw new NotFoundException(`Transfer request ${id} not found`); + return request; + } + + /** + * OCC fulfils a PENDING request with hand-picked wagons. Every wagon must sit + * in the request's source yard, match its wagon type, and the count must equal + * the requested quantity — then the transfer runs and the request is marked + * FULFILLED. + */ + async fulfillRequest( + id: string, + dto: FulfillTransferRequestDto, + userId?: string | null, + ): Promise { + const request = await this.findById(id); + if (request.status !== WagonTransferRequestStatus.Pending) { + throw new ConflictException( + `Request is already ${request.status.toLowerCase()}`, + ); + } + + const wagonIds = [...new Set(dto.wagonIds)]; + if (wagonIds.length !== request.quantity) { + throw new BadRequestException( + `Select exactly ${request.quantity} wagon(s); you selected ${wagonIds.length}`, + ); + } + + const wagons = await this.wagonRepo.find({ where: { id: In(wagonIds) } }); + if (wagons.length !== wagonIds.length) { + throw new NotFoundException('One or more selected wagons not found'); + } + const offSource = wagons.filter((w) => w.currentYardId !== request.fromYardId); + if (offSource.length) { + throw new BadRequestException( + `These wagons are not in the source yard: ${offSource + .map((w) => w.wagonNumber) + .join(', ')}`, + ); + } + const wrongType = wagons.filter((w) => w.wagonTypeId !== request.wagonTypeId); + if (wrongType.length) { + throw new BadRequestException( + `These wagons are the wrong type: ${wrongType + .map((w) => w.wagonNumber) + .join(', ')}`, + ); + } + + // Reuse the audited bulk-transfer path (writes wagon_movements ledger rows). + await this.wagonsService.bulkTransfer( + { wagonIds, toYardId: request.toYardId }, + userId, + ); + + request.status = WagonTransferRequestStatus.Fulfilled; + request.fulfilledByUserId = userId ?? null; + request.fulfilledAt = new Date(); + await this.requestRepo.save(request); + return this.findById(id); + } + + /** Withdraw a still-PENDING request. */ + async cancelRequest(id: string): Promise { + const request = await this.findById(id); + if (request.status !== WagonTransferRequestStatus.Pending) { + throw new ConflictException( + `Only pending requests can be cancelled (this one is ${request.status.toLowerCase()})`, + ); + } + request.status = WagonTransferRequestStatus.Cancelled; + await this.requestRepo.save(request); + return this.findById(id); + } +} diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.module.ts b/apps/edr-freight-api/src/modules/wagons/wagons.module.ts index bffe28860..4bb1afd33 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.module.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.module.ts @@ -1,15 +1,22 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Wagon } from './entities/wagon.entity'; +import { WagonTransferRequest } from './entities/wagon-transfer-request.entity'; import { Train } from '../trains/entities/train.entity'; import { Yard } from '../rule-engine/entities/yard.entity'; import { WagonsController, TrainWagonsReorderController } from './wagons.controller'; +import { WagonTransferRequestsController } from './wagon-transfer-requests.controller'; import { WagonsService } from './wagons.service'; +import { WagonTransferRequestsService } from './wagon-transfer-requests.service'; @Module({ - imports: [TypeOrmModule.forFeature([Wagon, Train, Yard])], - controllers: [WagonsController, TrainWagonsReorderController], - providers: [WagonsService], - exports: [WagonsService], + imports: [TypeOrmModule.forFeature([Wagon, WagonTransferRequest, Train, Yard])], + controllers: [ + WagonsController, + TrainWagonsReorderController, + WagonTransferRequestsController, + ], + providers: [WagonsService, WagonTransferRequestsService], + exports: [WagonsService, WagonTransferRequestsService], }) export class WagonsModule {} diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index e281e6464..7c3fe0700 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -175,6 +175,8 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [ perm('e1b00001-0001-4000-8000-000000000002', 'edr_freight_app:wagons:create', 'Create wagon'), perm('e1b00001-0001-4000-8000-000000000003', 'edr_freight_app:wagons:update', 'Update wagon'), perm('e1b00001-0001-4000-8000-000000000004', 'edr_freight_app:wagons:delete', 'Delete wagon'), + perm('e1b00001-0001-4000-8000-000000000005', 'edr_freight_app:wagons:transfer_request', 'Request wagon transfer'), + perm('e1b00001-0001-4000-8000-000000000006', 'edr_freight_app:wagons:transfer_fulfill', 'Fulfil wagon transfer (OCC)'), perm('e1c00001-0001-4000-8000-000000000001', 'edr_freight_app:trains:view', 'View trains'), perm('e1c00001-0001-4000-8000-000000000002', 'edr_freight_app:trains:create', 'Create train'), perm('e1c00001-0001-4000-8000-000000000003', 'edr_freight_app:trains:update', 'Update train'), @@ -440,6 +442,10 @@ export const FREIGHT_PERMS = { create: 'edr_freight_app:wagons:create', update: 'edr_freight_app:wagons:update', delete: 'edr_freight_app:wagons:delete', + // Requester creates a transfer request; OCC fulfils it (picks the wagons and + // executes the move). Distinct keys so OCC can hold fulfil without request. + transferRequest: 'edr_freight_app:wagons:transfer_request', + transferFulfill: 'edr_freight_app:wagons:transfer_fulfill', }, trains: { view: 'edr_freight_app:trains:view', diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 7ba83e50e..3d6278b12 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -4,6 +4,7 @@ import { Container, FileSignature, FileText, + Hammer, LayoutDashboard, LayoutGrid, MapPin, @@ -116,6 +117,8 @@ import ContractValidityPeriodsPage from "./pages/configuration/ContractValidityP import FirstMilePage from "./pages/operations/FirstMilePage"; import LastMilePage from "./pages/operations/LastMilePage"; import TrainDetailPage from "./pages/trains/TrainDetailPage"; +import TrainBuilderDetailPage from "./pages/trainBuilder/TrainBuilderDetailPage"; +import TrainBuilderListPage from "./pages/trainBuilder/TrainBuilderListPage"; import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage"; import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage"; import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage"; @@ -266,6 +269,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.fleet.view, }, + { + label: "Train Builder", + href: "/dashboard/train-builder", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, // { // label: "Wagon types", @@ -1027,6 +1036,22 @@ const App = () => { } /> + + + + } + /> + + + + } + /> { } /> + + + + } + /> + + + + } + /> => { const values: Record = {}; for (const field of fields) { - const raw = record?.[field.name]; - if (raw !== undefined && raw !== null) { + const raw = field.getInitialValue && record + ? field.getInitialValue(record) + : record?.[field.name]; + if (field.type === "multiselect") { + values[field.name] = Array.isArray(raw) ? raw.map(String) : []; + } else if (raw !== undefined && raw !== null) { if (field.type === "date" && typeof raw === "string") { values[field.name] = raw.slice(0, 10); } else if (Array.isArray(raw)) { @@ -197,7 +202,10 @@ const RuleEngineFormDialog = ({ for (const field of visibleFields) { const raw = values[field.name]; - if (field.type === "number") { + if (field.type === "multiselect") { + // Always the full replacement list — the API syncs the relation to it. + payload[field.name] = Array.isArray(raw) ? raw : []; + } else if (field.type === "number") { if (raw === "" || raw === undefined) continue; payload[field.name] = Number(raw); } else if (field.type === "boolean") { @@ -258,6 +266,38 @@ const RuleEngineFormDialog = ({ const label = ; + if (field.type === "multiselect") { + const options = field.optionsFromValues + ? field.optionsFromValues(values) + : (field.options ?? []); + const selected = Array.isArray(values[field.name]) + ? (values[field.name] as string[]) + : []; + return ( + setField(field.name, v)} + disabled={selectOptionsLoading} + data={options + .filter((opt) => opt.value !== "" && opt.value !== RULE_ENGINE_SELECT_NONE) + .map((opt) => ({ label: opt.label, value: opt.value }))} + searchable + clearable + size="md" + radius="md" + styles={inputStyles} + /> + ); + } + if (field.type === "select") { // Dynamic options (e.g. rate unit) resolve from the live form values so // the choices track the other fields the admin has picked. diff --git a/apps/edr-freight-web/backoffice/src/components/trainBuilder/AvailableWagonsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainBuilder/AvailableWagonsPanel.tsx new file mode 100644 index 000000000..a75449a9d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainBuilder/AvailableWagonsPanel.tsx @@ -0,0 +1,152 @@ +import { Freight } from "@edr/types"; +import { + Button, + Checkbox, + Group, + ScrollArea, + Select, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { Plus, Search } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { api } from "@/services/api"; + +/** + * AVAILABLE wagons standing in the train's own yard — the only ones that can + * be coupled. Pick any number and append them to the consist. + */ +export default function AvailableWagonsPanel({ + yardId, + yardLabel, + onAssign, + assigning, +}: AvailableWagonsPanelProps) { + const [search, setSearch] = useState(""); + const [typeFilter, setTypeFilter] = useState("ALL"); + const [selected, setSelected] = useState([]); + + const wagonsQuery = useQuery( + api.wagons.list.queryOptions({ + input: { + filters: { status: Freight.WagonStatus.Available, currentYardId: yardId }, + }, + enabled: Boolean(yardId), + }), + ); + + const wagons = useMemo(() => { + const q = search.trim().toLowerCase(); + return (wagonsQuery.data ?? []).filter((wagon) => { + if (typeFilter !== "ALL" && wagon.wagonTypeId !== typeFilter) return false; + if (q && !wagon.wagonNumber.toLowerCase().includes(q)) return false; + return true; + }); + }, [wagonsQuery.data, search, typeFilter]); + + const typeOptions = useMemo(() => { + const byId = new Map(); + for (const wagon of wagonsQuery.data ?? []) { + if (wagon.wagonType) byId.set(wagon.wagonType.id, wagon.wagonType.name); + } + return [ + { value: "ALL", label: "All types" }, + ...[...byId.entries()].map(([value, label]) => ({ value, label })), + ]; + }, [wagonsQuery.data]); + + const toggle = (wagonId: string, checked: boolean) => { + setSelected((prev) => + checked ? [...prev, wagonId] : prev.filter((id) => id !== wagonId), + ); + }; + + const handleAssign = () => { + if (!selected.length) return; + onAssign(selected); + setSelected([]); + }; + + return ( + + + } + value={search} + onChange={(e) => setSearch(e.currentTarget.value)} + /> + ({ + value: y.id, + label: y.label ?? y.code, + }))} + value={yardId || null} + onChange={(v) => setYardId(v ?? "")} + searchable + /> + 0 && locomotiveIds.length < 2 + ? "Select at least two locomotives" + : undefined + } + nothingFoundMessage={ + yardId ? "No available locomotives in this yard" : "Select a yard first" + } + /> +