From 5cffe2860cd279c9c01d6efed8b28fd8422dc73b Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 14 Jul 2026 13:49:39 +0000 Subject: [PATCH] fix train builder and consolidation --- ...70000000000-ScheduleWagonAdjustmentLogs.ts | 50 +++ .../schedule-wagon-adjustment-log.entity.ts | 38 ++ .../dto/adjust-schedule-consist.dto.ts | 26 ++ .../train-scheduling.controller.ts | 29 ++ .../train-scheduling.service.ts | 315 +++++++++++++- .../modules/trains/train-builder.service.ts | 71 +++- .../trainScheduling/AdjustConsistModal.tsx | 396 ++++++++++++++++++ .../src/pages/bookings/NewBookingPage.tsx | 32 +- .../trainBuilder/TrainBuilderDetailPage.tsx | 4 +- .../BatchScheduleDetailPage.tsx | 19 + .../TrainScheduleV2DetailPage.tsx | 20 + .../backoffice/src/services/api.ts | 26 ++ .../src/services/trainBuilder.service.ts | 67 +++ .../new-booking-form/step5-cargo-details.tsx | 16 +- 14 files changed, 1092 insertions(+), 17 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2170000000000-ScheduleWagonAdjustmentLogs.ts create mode 100644 apps/edr-freight-api/src/modules/train-schedules/entities/schedule-wagon-adjustment-log.entity.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/dto/adjust-schedule-consist.dto.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/trainScheduling/AdjustConsistModal.tsx diff --git a/apps/edr-freight-api/src/migrations/2170000000000-ScheduleWagonAdjustmentLogs.ts b/apps/edr-freight-api/src/migrations/2170000000000-ScheduleWagonAdjustmentLogs.ts new file mode 100644 index 000000000..7fecfccbb --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2170000000000-ScheduleWagonAdjustmentLogs.ts @@ -0,0 +1,50 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Consist adjustments from a schedule: staff can trim free wagons off a built + * train when their tare pushes gross weight over the locomotives' pull limit + * (incl. overage tolerance), or couple extra yard wagons on while weight and + * length headroom remain. Each add/remove is logged here so the schedule keeps + * an auditable history; the built train itself is updated in place. + * + * Plain columns (no FKs) so the history survives wagon/train deletion. + * + * 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 ScheduleWagonAdjustmentLogs2170000000000 implements MigrationInterface { + name = 'ScheduleWagonAdjustmentLogs2170000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.schedule_wagon_adjustment_logs ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + train_schedule_id uuid NOT NULL, + train_id uuid NOT NULL, + action varchar(10) NOT NULL, + wagon_id uuid NOT NULL, + wagon_number varchar(50) NOT NULL, + adjusted_by_user_id uuid, + occurred_at timestamptz NOT NULL DEFAULT now(), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT "PK_schedule_wagon_adjustment_logs" PRIMARY KEY (id) + ); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_swal_train_schedule_id" + ON freight.schedule_wagon_adjustment_logs (train_schedule_id); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_swal_train_id" + ON freight.schedule_wagon_adjustment_logs (train_id); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_swal_train_id";`); + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_swal_train_schedule_id";`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.schedule_wagon_adjustment_logs;`); + } +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/schedule-wagon-adjustment-log.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/schedule-wagon-adjustment-log.entity.ts new file mode 100644 index 000000000..485d29452 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/schedule-wagon-adjustment-log.entity.ts @@ -0,0 +1,38 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +export const WAGON_ADJUSTMENT_ACTIONS = ['ADD', 'REMOVE'] as const; +export type WagonAdjustmentAction = (typeof WAGON_ADJUSTMENT_ACTIONS)[number]; + +/** + * History row for a consist adjustment made from a schedule: staff coupled a + * wagon onto (ADD) or detached one from (REMOVE) the schedule's built train — + * e.g. trimming free wagons whose tare pushed gross weight over the + * locomotives' pull limit. Plain columns (no FK relations) so the history + * survives the wagon or train being deleted later. + */ +@Entity({ schema: 'freight', name: 'schedule_wagon_adjustment_logs' }) +@Index(['trainScheduleId']) +@Index(['trainId']) +export class ScheduleWagonAdjustmentLog extends BaseEntity { + @Column({ name: 'train_schedule_id', type: 'uuid' }) + trainScheduleId!: string; + + @Column({ name: 'train_id', type: 'uuid' }) + trainId!: string; + + @Column({ name: 'action', type: 'varchar', length: 10 }) + action!: WagonAdjustmentAction; + + @Column({ name: 'wagon_id', type: 'uuid' }) + wagonId!: string; + + @Column({ name: 'wagon_number', type: 'varchar', length: 50 }) + wagonNumber!: string; + + @Column({ name: 'adjusted_by_user_id', type: 'uuid', nullable: true }) + adjustedByUserId!: string | null; + + @Column({ name: 'occurred_at', type: 'timestamptz', default: () => 'now()' }) + occurredAt!: Date; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/adjust-schedule-consist.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/adjust-schedule-consist.dto.ts new file mode 100644 index 000000000..e031bf9f0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/adjust-schedule-consist.dto.ts @@ -0,0 +1,26 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsArray, IsOptional, IsUUID } from 'class-validator'; + +export class AdjustScheduleConsistDto { + @ApiPropertyOptional({ + type: [String], + format: 'uuid', + description: + "AVAILABLE wagons from the train's current yard to couple onto the built train (blocked when they push gross weight or length past the locomotive limits incl. tolerance).", + }) + @IsOptional() + @IsArray() + @IsUUID('all', { each: true }) + addWagonIds?: string[]; + + @ApiPropertyOptional({ + type: [String], + format: 'uuid', + description: + 'Free (unloaded) wagons to detach permanently from the built train — e.g. trimming tare when gross weight exceeds the pull limit.', + }) + @IsOptional() + @IsArray() + @IsUUID('all', { each: true }) + removeWagonIds?: string[]; +} 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 f8cc7e3d1..c6e22589f 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 { AdjustScheduleConsistDto } from "./dto/adjust-schedule-consist.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"; @@ -166,6 +167,34 @@ export class TrainSchedulingController { ); } + @Get("schedules/:id/consist") + @TrainSchedulingView() + @ApiOperation({ + summary: + "Built-train consist snapshot for a schedule: gross weight/length vs locomotive limits (incl. tolerance), trimmable + addable wagons, adjustment history", + }) + getScheduleConsist(@Param("id", ParseUUIDPipe) id: string) { + return this.trainSchedulingService.getScheduleConsist(id); + } + + @Post("schedules/:id/adjust-consist") + @TrainSchedulingManage() + @ApiOperation({ + summary: + "Permanently trim free wagons off / couple yard wagons onto the schedule's built train (weight & length limits incl. tolerance enforced, every change logged)", + }) + adjustScheduleConsist( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: AdjustScheduleConsistDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.trainSchedulingService.adjustScheduleConsist( + id, + dto, + resolveAuthUserId(user), + ); + } + @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.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 7e0720238..04583c777 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 @@ -25,6 +25,7 @@ import { FindOptionsWhere, ILike, In, + IsNull, Not, QueryFailedError, Raw, @@ -48,6 +49,7 @@ 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'; +import { ScheduleWagonAdjustmentLog } from '../train-schedules/entities/schedule-wagon-adjustment-log.entity'; import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity'; @@ -61,6 +63,7 @@ import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-book import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { WagonTypesRepository } from '../wagon-types/wagon-types.repository'; import { Wagon } from '../wagons/entities/wagon.entity'; +import { AdjustScheduleConsistDto } from './dto/adjust-schedule-consist.dto'; import { AssignBookingsDto } from './dto/assign-bookings.dto'; import { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto'; import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto'; @@ -1331,11 +1334,25 @@ export class TrainSchedulingService { limitLoco.maxPullWeightTons + (Number(limitLoco.overageToleranceTons) || 0); const lengthCapWithOverage = limitLoco.maxTrainLengthMeters + (Number(limitLoco.overageToleranceMeters) || 0); - // The locomotives pull GROSS weight: the customers' cargo plus the empty - // weight of every planned wagon — cargo-only comparison understates the load. - const planTareTons = roundTons( - wagonPlan.reduce((sum, slot) => sum + Number(slot.tareWeightTons ?? 0), 0), - ); + // The locomotives pull GROSS weight: the customers' cargo plus wagon tare. + // A built train hauls EVERY coupled wagon's tare — empty ones included — + // so train-bound schedules count the full consist, not just planned slots. + const consistWagons = schedule.trainSet?.trainId + ? await this.dataSource.getRepository(Wagon).find({ + where: { trainId: schedule.trainSet.trainId }, + relations: { wagonType: true }, + }) + : null; + const planTareTons = consistWagons + ? roundTons( + consistWagons.reduce( + (sum, wagon) => sum + Number(wagon.wagonType?.tareWeightTons ?? 0), + 0, + ), + ) + : roundTons( + wagonPlan.reduce((sum, slot) => sum + Number(slot.tareWeightTons ?? 0), 0), + ); const grossWeightTons = roundTons(totalWeightTons + planTareTons); if (!dto.forceAssign && weightCapWithOverage < grossWeightTons) { throw new BadRequestException( @@ -4626,6 +4643,294 @@ export class TrainSchedulingService { }); } + /** + * Consist snapshot for the adjust-consist UI: the built train's wagons with + * loaded/removable flags, gross weight (cargo + FULL consist tare) and length + * against the locomotive limits incl. overage tolerance, addable yard wagons, + * and the adjustment history. + */ + async getScheduleConsist(scheduleId: string) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`); + const builtTrain = schedule.trainSet?.train; + if (!builtTrain) { + throw new BadRequestException( + 'This schedule was not created from a built train — its consist cannot be adjusted here', + ); + } + + const wagons = await this.dataSource.getRepository(Wagon).find({ + where: { trainId: builtTrain.id }, + relations: { wagonType: true }, + order: { sequenceNumber: 'ASC' }, + }); + const addableWagons = await this.dataSource.getRepository(Wagon).find({ + where: { + trainId: IsNull(), + status: WagonStatus.Available, + currentYardId: builtTrain.currentYardId ?? undefined, + }, + relations: { wagonType: true }, + order: { wagonNumber: 'ASC' }, + }); + const adjustments = await this.dataSource + .getRepository(ScheduleWagonAdjustmentLog) + .find({ where: { trainScheduleId: scheduleId }, order: { occurredAt: 'DESC' }, take: 30 }); + + // Slots with cargo aboard — their physical wagons are "loaded" and can + // never be trimmed. + const loadedWagonIds = new Set( + (schedule.trainSet?.wagons ?? []) + .filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0) + .map((slot) => slot.physicalWagonId as string), + ); + + const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet)); + const maxPullWeightTons = roundTons(Number(limits?.maxPullWeightTons ?? 0)); + const overageToleranceTons = roundTons(Number(limits?.overageToleranceTons) || 0); + const maxTrainLengthMeters = roundTons(Number(limits?.maxTrainLengthMeters ?? 0)); + const overageToleranceMeters = roundTons(Number(limits?.overageToleranceMeters) || 0); + + const cargoTons = roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0)); + const consistTareTons = roundTons( + wagons.reduce((sum, w) => sum + Number(w.wagonType?.tareWeightTons ?? 0), 0), + ); + const consistLengthMeters = roundTons( + wagons.reduce((sum, w) => sum + Number(w.wagonType?.lengthMeters ?? 0), 0), + ); + + const mapWagon = (wagon: Wagon) => ({ + id: wagon.id, + wagonNumber: wagon.wagonNumber, + sequenceNumber: wagon.sequenceNumber, + wagonType: wagon.wagonType + ? { + id: wagon.wagonType.id, + code: wagon.wagonType.code, + tareWeightTons: roundTons(Number(wagon.wagonType.tareWeightTons ?? 0)), + capacityTons: roundTons(Number(wagon.wagonType.capacityTons ?? 0)), + lengthMeters: roundTons(Number(wagon.wagonType.lengthMeters ?? 0)), + } + : null, + }); + + return { + schedule: { id: schedule.id, reference: schedule.reference ?? null, status: schedule.status }, + train: { + id: builtTrain.id, + code: builtTrain.code, + trainName: builtTrain.trainName ?? null, + currentYardId: builtTrain.currentYardId ?? null, + }, + limits: { + maxPullWeightTons, + overageToleranceTons, + pullCapTons: roundTons(maxPullWeightTons + overageToleranceTons), + maxTrainLengthMeters, + overageToleranceMeters, + lengthCapMeters: roundTons(maxTrainLengthMeters + overageToleranceMeters), + }, + totals: { + wagonCount: wagons.length, + cargoTons, + consistTareTons, + grossTons: roundTons(cargoTons + consistTareTons), + consistLengthMeters, + }, + wagons: wagons.map((wagon) => ({ + ...mapWagon(wagon), + loaded: loadedWagonIds.has(wagon.id), + // Free = not pinned to any run; only free wagons can be trimmed. + removable: wagon.currentTrainScheduleId == null && !loadedWagonIds.has(wagon.id), + })), + addableWagons: addableWagons.map(mapWagon), + adjustments: adjustments.map((log) => ({ + id: log.id, + action: log.action, + wagonId: log.wagonId, + wagonNumber: log.wagonNumber, + adjustedByUserId: log.adjustedByUserId, + occurredAt: log.occurredAt, + })), + editable: ['DRAFT', 'SCHEDULED'].includes(schedule.status), + }; + } + + /** + * Permanently adjust the built train's consist from a schedule: trim free + * wagons (their tare no longer rides — the usual fix when gross weight beats + * the pull limit) and/or couple extra AVAILABLE yard wagons while weight and + * length headroom remain (limits incl. overage tolerance). The built train + * updates in place, the schedule's wagon cap follows, and every change is + * logged for the schedule's history. + */ + async adjustScheduleConsist( + scheduleId: string, + dto: AdjustScheduleConsistDto, + userId?: string | null, + ) { + const addWagonIds = [...new Set(dto.addWagonIds ?? [])]; + const removeWagonIds = [...new Set(dto.removeWagonIds ?? [])]; + if (!addWagonIds.length && !removeWagonIds.length) { + throw new BadRequestException('Nothing to adjust — pass wagons to add and/or remove'); + } + const overlap = addWagonIds.filter((id) => removeWagonIds.includes(id)); + if (overlap.length) { + throw new BadRequestException('A wagon cannot be added and removed in the same adjustment'); + } + + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`); + if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) { + throw new BadRequestException( + 'The consist is frozen once the train is dispatched — adjust before departure', + ); + } + const builtTrainRef = schedule.trainSet?.train; + if (!builtTrainRef) { + throw new BadRequestException( + 'This schedule was not created from a built train — its consist cannot be adjusted here', + ); + } + const loadedWagonIds = new Set( + (schedule.trainSet?.wagons ?? []) + .filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0) + .map((slot) => slot.physicalWagonId as string), + ); + const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet)); + const pullCapTons = roundTons( + Number(limits?.maxPullWeightTons ?? 0) + (Number(limits?.overageToleranceTons) || 0), + ); + const lengthCapMeters = roundTons( + Number(limits?.maxTrainLengthMeters ?? 0) + (Number(limits?.overageToleranceMeters) || 0), + ); + + await this.dataSource.transaction(async (manager) => { + const train = await manager.getRepository(Train).findOne({ + where: { id: builtTrainRef.id }, + lock: { mode: 'pessimistic_write' }, + }); + if (!train) throw new NotFoundException(`Train ${builtTrainRef.id} not found`); + + const consist = await manager.getRepository(Wagon).find({ + where: { trainId: train.id }, + relations: { wagonType: true }, + order: { sequenceNumber: 'ASC' }, + }); + const consistById = new Map(consist.map((w) => [w.id, w])); + + // --- validate removals: must be coupled and free (no cargo, no pin) --- + const removed: Wagon[] = []; + for (const wagonId of removeWagonIds) { + const wagon = consistById.get(wagonId); + if (!wagon) { + throw new NotFoundException(`Wagon ${wagonId} is not coupled to train ${train.code}`); + } + if (loadedWagonIds.has(wagon.id) || wagon.currentTrainScheduleId != null) { + throw new ConflictException( + `Wagon ${wagon.wagonNumber} is loaded/pinned on a schedule and cannot be trimmed`, + ); + } + removed.push(wagon); + } + + // --- validate additions: AVAILABLE, loose, standing in the train's yard --- + const added: Wagon[] = []; + for (const wagonId of addWagonIds) { + const wagon = await manager.getRepository(Wagon).findOne({ + where: { id: wagonId }, + relations: { wagonType: true }, + lock: { mode: 'pessimistic_write' }, + }); + if (!wagon) throw new NotFoundException(`Wagon ${wagonId} not found`); + if (wagon.trainId) { + throw new ConflictException(`Wagon ${wagon.wagonNumber} is already on a 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 coupled`, + ); + } + added.push(wagon); + } + + // --- headroom check (only additions can push the train over a cap) --- + const removedIds = new Set(removed.map((w) => w.id)); + const finalConsist = [...consist.filter((w) => !removedIds.has(w.id)), ...added]; + const tareOf = (w: Wagon) => Number(w.wagonType?.tareWeightTons ?? 0); + const lengthOf = (w: Wagon) => Number(w.wagonType?.lengthMeters ?? 0); + const finalTareTons = roundTons(finalConsist.reduce((s, w) => s + tareOf(w), 0)); + const finalLengthMeters = roundTons(finalConsist.reduce((s, w) => s + lengthOf(w), 0)); + const cargoTons = roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0)); + const finalGrossTons = roundTons(cargoTons + finalTareTons); + if (added.length && pullCapTons > 0 && finalGrossTons > pullCapTons) { + throw new BadRequestException( + `Adding these wagons puts gross weight at ${finalGrossTons}T (${cargoTons}T cargo + ${finalTareTons}T tare), over the locomotives' ${pullCapTons}T limit incl. tolerance`, + ); + } + if (added.length && lengthCapMeters > 0 && finalLengthMeters > lengthCapMeters) { + throw new BadRequestException( + `Adding these wagons puts consist length at ${finalLengthMeters}m, over the locomotives' ${lengthCapMeters}m limit incl. tolerance`, + ); + } + + // --- apply: detach trims, couple additions, compact the sequence --- + for (const wagon of removed) { + await manager.getRepository(Wagon).update(wagon.id, { + trainId: null, + sequenceNumber: null, + status: WagonStatus.Available, + }); + } + const remaining = consist.filter((w) => !removedIds.has(w.id)); + for (let i = 0; i < remaining.length; i++) { + if (remaining[i].sequenceNumber !== i + 1) { + await manager.getRepository(Wagon).update(remaining[i].id, { sequenceNumber: i + 1 }); + } + } + let sequence = remaining.length; + for (const wagon of added) { + sequence += 1; + await manager.getRepository(Wagon).update(wagon.id, { + trainId: train.id, + sequenceNumber: sequence, + status: WagonStatus.Assigned, + }); + } + + // The schedule is full when every consist wagon is allocated. + await manager + .getRepository(TrainSchedule) + .update(scheduleId, { maxWagons: finalConsist.length }); + + const logRepo = manager.getRepository(ScheduleWagonAdjustmentLog); + const now = new Date(); + await logRepo.save( + [ + ...removed.map((wagon) => ({ action: 'REMOVE' as const, wagon })), + ...added.map((wagon) => ({ action: 'ADD' as const, wagon })), + ].map(({ action, wagon }) => + logRepo.create({ + trainScheduleId: scheduleId, + trainId: train.id, + action, + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + adjustedByUserId: userId ?? null, + occurredAt: now, + }), + ), + ); + }); + + return this.getScheduleConsist(scheduleId); + } + /** * Re-derive a built train's lifecycle status from its schedules after one of * them changes: any DISPATCHED schedule → IN_SERVICE; any DRAFT/SCHEDULED → 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 index b92ab21d6..16ff29d38 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts @@ -10,6 +10,7 @@ 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 { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { WagonMovement } from '../wagons/entities/wagon-movement.entity'; import { Wagon } from '../wagons/entities/wagon.entity'; import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto'; @@ -511,9 +512,13 @@ export class TrainBuilderService { startCount: number, ): Promise { const uniqueIds = [...new Set(wagonIds)]; - let sequence = startCount; + const wagonRepo = manager.getRepository(Wagon); + + // First pass: lock + validate every wagon so the length gate below sees + // the full incoming set before any row is written. + const toAttach: Wagon[] = []; for (const wagonId of uniqueIds) { - const wagon = await manager.getRepository(Wagon).findOne({ + const wagon = await wagonRepo.findOne({ where: { id: wagonId }, lock: { mode: 'pessimistic_write' }, }); @@ -530,8 +535,16 @@ export class TrainBuilderService { `Wagon ${wagon.wagonNumber} is not in the train's yard; only wagons in the same yard can be attached`, ); } + toAttach.push(wagon); + } + if (!toAttach.length) return; + + await this.assertConsistLengthWithinLimit(manager, train, toAttach); + + let sequence = startCount; + for (const wagon of toAttach) { sequence += 1; - await manager.getRepository(Wagon).update(wagon.id, { + await wagonRepo.update(wagon.id, { trainId: train.id, sequenceNumber: sequence, status: WagonStatus.Assigned, @@ -539,6 +552,58 @@ export class TrainBuilderService { } } + /** + * The consist (already-attached wagons + the incoming ones) must fit the + * train's locomotive length limit — the weakest locomotive of the set caps + * the train, mirroring how scheduling derives capacity. + */ + private async assertConsistLengthWithinLimit( + manager: EntityManager, + train: Train, + incoming: Wagon[], + ): Promise { + const links = await manager.getRepository(TrainLocomotive).find({ + where: { trainId: train.id }, + relations: { locomotive: true }, + }); + const limits = minLocomotiveLimits( + links + .map((link) => link.locomotive) + .filter((loco): loco is Locomotive => Boolean(loco)), + ); + const maxLengthMeters = Number(limits?.maxTrainLengthMeters ?? 0); + if (!Number.isFinite(maxLengthMeters) || maxLengthMeters <= 0) return; + + const existing = await manager.getRepository(Wagon).find({ + where: { trainId: train.id }, + relations: { wagonType: true }, + }); + const currentLength = existing.reduce( + (sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), + 0, + ); + + const typeIds = [...new Set(incoming.map((w) => w.wagonTypeId).filter(Boolean))]; + const types = typeIds.length + ? await manager.getRepository(WagonType).find({ where: { id: In(typeIds) } }) + : []; + const lengthByType = new Map(types.map((t) => [t.id, Number(t.lengthMeters) || 0])); + const addedLength = incoming.reduce( + (sum, w) => sum + (lengthByType.get(w.wagonTypeId) ?? 0), + 0, + ); + + const totalLength = currentLength + addedLength; + if (totalLength > maxLengthMeters) { + throw new BadRequestException( + `Cannot attach wagons — train length would be ${round(totalLength)} m ` + + `(current ${round(currentLength)} m + ${round(addedLength)} m added), ` + + `over the locomotive limit of ${round(maxLengthMeters)} m. ` + + 'Remove wagons from the consist or use locomotives with a higher length limit.', + ); + } + } + /** 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({ diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/AdjustConsistModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/AdjustConsistModal.tsx new file mode 100644 index 000000000..1ca91e01f --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/AdjustConsistModal.tsx @@ -0,0 +1,396 @@ +import { + Alert, + Badge, + Button, + Checkbox, + Divider, + Grid, + Group, + Modal, + Progress, + ScrollArea, + Stack, + Text, +} from "@mantine/core"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { isAxiosError } from "axios"; +import { AlertTriangle, History, Minus, Plus } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; + +import { api } from "@/services/api"; +import type { ConsistWagonRef } from "@/services/trainBuilder.service"; +import { useToast } from "@/hooks/use-toast"; + +const parseError = (error: unknown, fallback: string) => { + if (isAxiosError(error)) { + const message = error.response?.data?.message; + if (Array.isArray(message)) return message.join(", "); + if (typeof message === "string") return message; + } + return fallback; +}; + +const tareOf = (w: ConsistWagonRef) => w.wagonType?.tareWeightTons ?? 0; +const lengthOf = (w: ConsistWagonRef) => w.wagonType?.lengthMeters ?? 0; +const round2 = (v: number) => Math.round(v * 100) / 100; + +/** + * Adjust the built train's consist from a schedule: trim free wagons (their + * tare no longer rides — the fix when gross weight beats the pull limit) or + * couple extra yard wagons while weight/length headroom remains. Changes are + * permanent on the train and logged on the schedule. + */ +export default function AdjustConsistModal({ + scheduleId, + opened, + onClose, +}: AdjustConsistModalProps) { + const { toast } = useToast(); + const [removeIds, setRemoveIds] = useState([]); + const [addIds, setAddIds] = useState([]); + + const consistQuery = useQuery( + api.trainScheduling.scheduleConsist.queryOptions({ + input: { scheduleId }, + enabled: opened && Boolean(scheduleId), + }), + ); + const adjust = useMutation(api.trainScheduling.adjustConsist.mutationOptions()); + const data = consistQuery.data; + + useEffect(() => { + if (opened) { + setRemoveIds([]); + setAddIds([]); + } + }, [opened]); + + // Live projection: gross = cargo + tare of (consist − trims + adds). + const projection = useMemo(() => { + if (!data) return null; + const removed = new Set(removeIds); + const keptTare = data.wagons + .filter((w) => !removed.has(w.id)) + .reduce((s, w) => s + tareOf(w), 0); + const keptLength = data.wagons + .filter((w) => !removed.has(w.id)) + .reduce((s, w) => s + lengthOf(w), 0); + const addedWagons = data.addableWagons.filter((w) => addIds.includes(w.id)); + const tare = keptTare + addedWagons.reduce((s, w) => s + tareOf(w), 0); + const length = keptLength + addedWagons.reduce((s, w) => s + lengthOf(w), 0); + const gross = round2(data.totals.cargoTons + tare); + return { + wagonCount: data.totals.wagonCount - removeIds.length + addIds.length, + tare: round2(tare), + gross, + length: round2(length), + grossPct: data.limits.pullCapTons + ? Math.round((gross / data.limits.pullCapTons) * 100) + : null, + lengthPct: data.limits.lengthCapMeters + ? Math.round((length / data.limits.lengthCapMeters) * 100) + : null, + overWeight: data.limits.pullCapTons > 0 && gross > data.limits.pullCapTons, + overLength: + data.limits.lengthCapMeters > 0 && length > data.limits.lengthCapMeters, + }; + }, [data, removeIds, addIds]); + + const toggle = (setter: typeof setRemoveIds) => (id: string, checked: boolean) => + setter((prev) => (checked ? [...prev, id] : prev.filter((x) => x !== id))); + + const handleSubmit = async () => { + if (!removeIds.length && !addIds.length) return; + try { + await adjust.mutateAsync({ + scheduleId, + payload: { + ...(addIds.length ? { addWagonIds: addIds } : {}), + ...(removeIds.length ? { removeWagonIds: removeIds } : {}), + }, + }); + toast({ + title: `Consist updated — ${removeIds.length ? `${removeIds.length} trimmed` : ""}${ + removeIds.length && addIds.length ? ", " : "" + }${addIds.length ? `${addIds.length} added` : ""}`, + }); + setRemoveIds([]); + setAddIds([]); + } catch (err) { + toast({ + title: "Adjustment failed", + description: parseError(err, "Could not adjust the consist"), + variant: "destructive", + }); + } + }; + + return ( + + Adjust consist{data ? ` — train ${data.train.code}` : ""} + + } + radius="lg" + size={860} + centered + > + {consistQuery.isLoading || !data ? ( + + {consistQuery.isError + ? "This schedule has no built train to adjust." + : "Loading consist…"} + + ) : ( + + {!data.editable ? ( + }> + The consist is frozen once the train is dispatched. + + ) : null} + + + + + + + + + + + + + + + + + Trim coupled wagons ({data.totals.wagonCount}) + + + + Only free (unloaded, unpinned) wagons can be detached. Detaching is + permanent — the wagon returns to the yard as available. + + + + {data.wagons.map((wagon) => ( + + ))} + + + + + + + + + + Couple yard wagons ({data.addableWagons.length} available) + + + + AVAILABLE wagons standing in the train's yard. Blocked when they push + gross weight or length past the locomotive limits incl. tolerance. + + + + {data.addableWagons.length ? ( + data.addableWagons.map((wagon) => ( + + )) + ) : ( + + No available wagons in this yard + + )} + + + + + + + {data.adjustments.length ? ( + <> + + + + + + Adjustment history + + + + + {data.adjustments.map((log) => ( + + + {log.action === "ADD" ? "Added" : "Trimmed"} + + + {log.wagonNumber} + + + {new Date(log.occurredAt).toLocaleString()} + + + ))} + + + + + ) : null} + + + + Projected consist: {projection?.wagonCount} wagons + + + + + + + + )} + + ); +} + +export interface AdjustConsistModalProps { + scheduleId: string; + opened: boolean; + onClose: () => void; +} + +function LimitGauge({ + label, + detail, + pct, + over, +}: { + label: string; + detail: string; + pct: number | null; + over: boolean; +}) { + return ( + + + + {label} + + + {pct != null ? `${pct}%` : "—"} + + + 85 ? "yellow" : "edr-green"} + striped={over} + animated={over} + /> + + {detail} + + + ); +} + +function WagonRow({ + wagon, + checked, + disabled, + badge, + onToggle, +}: { + wagon: ConsistWagonRef; + checked: boolean; + disabled: boolean; + badge: string | null; + onToggle: (id: string, checked: boolean) => void; +}) { + return ( + + onToggle(wagon.id, e.currentTarget.checked)} + aria-label={`Select wagon ${wagon.wagonNumber}`} + /> + + + {wagon.wagonNumber} + + + {wagon.wagonType + ? `${wagon.wagonType.code} · ${wagon.wagonType.tareWeightTons}T tare · ${wagon.wagonType.lengthMeters}m` + : "Unknown type"} + + + {badge ? ( + + {badge} + + ) : null} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx index 0344e99cb..07a6c72cd 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx @@ -1,5 +1,6 @@ import { ActionIcon, + Alert, Badge, Box, Button, @@ -324,6 +325,21 @@ export default function NewBookingPage() { const containerWeight = lines.reduce((s, l) => s + (l.quantity || 0) * (l.vgmPerUnitTons || 0), 0); const cargoTotalWeightVgm = freightType === "CONTAINER" ? containerWeight : bulkWeight; + // 20ft containers ride two per wagon, so a booking must hold an even number + // of them — odd counts would leave half a wagon waiting on a co-loader + // (cross-booking consolidation is disabled for now). + const twentyFtCount = useMemo(() => { + const sizeById = new Map(); + for (const group of refData?.containers ?? []) { + for (const type of group.types) sizeById.set(type.id, group.size); + } + return lines.reduce((sum, l) => { + const size = l.containerTypeId ? (sizeById.get(l.containerTypeId) ?? "") : ""; + return String(size).includes("20") ? sum + (l.quantity || 0) : sum; + }, 0); + }, [refData?.containers, lines]); + const hasOdd20ft = freightType === "CONTAINER" && twentyFtCount % 2 === 1; + // ---- validation ---- const lineValid = (l: ContainerLine) => Boolean(l.containerTypeId) && l.quantity >= 1 && l.vgmPerUnitTons > 0; @@ -344,7 +360,7 @@ export default function NewBookingPage() { (isGovernment ? Boolean(govCompanyId && govProfileId) : Boolean(companyId)) && (freightType === "BULK" ? Boolean(cargoTypeId) && bulkWeight > 0 - : allLinesValid); + : allLinesValid && !hasOdd20ft); const updateLine = (key: string, patch: Partial) => setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l))); @@ -842,6 +858,20 @@ export default function NewBookingPage() { ) : null} + {hasOdd20ft ? ( + } radius="md" mt="md"> + + Odd number of 20ft containers ({twentyFtCount}) + + + 20ft containers travel two per wagon, so they must be booked in + even numbers. Add one more 20ft container or remove one — e.g. + book {twentyFtCount + 1} or {twentyFtCount - 1} instead of{" "} + {twentyFtCount}. + + + ) : null} + + {data.train && ["DRAFT", "SCHEDULED"].includes(data.status) ? ( + + ) : null} {data.windowPhase === "DOC_REVIEW" ? ( ) : null} + {schedule.train && ["DRAFT", "SCHEDULED"].includes(schedule.status) ? ( + + ) : null} {gatepassApplies ? ( gatepassSecured ? (