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 d5a5161fb..f4d5eefdb 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 @@ -3718,6 +3718,30 @@ export class TrainSchedulingService { // unload each one by hand. The final station is covered by // arriveSchedule's bulk fallback above. await this.bookingJourneyService.autoUnloadAtYard(scheduleId, station.yardId); + // A pass is also a position fix: the locomotives, every wagon still + // aboard, and the built train are physically AT this yard now — not at + // the origin they departed from. Wagons released at earlier stops no + // longer carry this schedule id and stay where they alighted; the final + // arrival settle still writes the wagon-movement ledger rows. + await this.dataSource.transaction(async (manager) => { + const locoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id); + if (locoIds.length) { + await manager + .getRepository(Locomotive) + .update({ id: In(locoIds) }, { currentYardId: station.yardId }); + } + await manager + .getRepository(Wagon) + .update( + { currentTrainScheduleId: scheduleId }, + { currentYardId: station.yardId }, + ); + if (schedule.trainSet?.trainId) { + await manager + .getRepository(Train) + .update(schedule.trainSet.trainId, { currentYardId: station.yardId }); + } + }); } return this.getScheduleCheckpoints(scheduleId); 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 index 2b74c0253..a5cd1ff5c 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts @@ -13,8 +13,11 @@ import { Query, } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '@edr/api-common'; import { FleetManage, FleetView } from '../../common/booking-guards'; +import type { AuthUserPayload } from '../../common/resolve-auth-user-id'; +import { resolveAuthUserId } from '../../common/resolve-auth-user-id'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto'; import { BuildTrainDto } from './dto/build-train.dto'; @@ -94,8 +97,12 @@ export class TrainBuilderController { @Post(':id/wagons') @FleetManage(FREIGHT_PERMS.trains.assignWagons) @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); + assignWagons( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: AssignTrainWagonsDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.trainBuilderService.assignWagons(id, dto, resolveAuthUserId(user)); } @Delete(':id/wagons/:wagonId') @@ -104,8 +111,9 @@ export class TrainBuilderController { removeWagon( @Param('id', ParseUUIDPipe) id: string, @Param('wagonId', ParseUUIDPipe) wagonId: string, + @CurrentUser() user: AuthUserPayload, ) { - return this.trainBuilderService.removeWagon(id, wagonId); + return this.trainBuilderService.removeWagon(id, wagonId, resolveAuthUserId(user)); } @Post(':id/wagons/:wagonId/maintenance') @@ -114,8 +122,9 @@ export class TrainBuilderController { sendWagonToMaintenance( @Param('id', ParseUUIDPipe) id: string, @Param('wagonId', ParseUUIDPipe) wagonId: string, + @CurrentUser() user: AuthUserPayload, ) { - return this.trainBuilderService.sendWagonToMaintenance(id, wagonId); + return this.trainBuilderService.sendWagonToMaintenance(id, wagonId, resolveAuthUserId(user)); } @Post(':id/reorder-wagons') 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 6aed88f07..abbf887e1 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 @@ -11,7 +11,11 @@ import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity import { Locomotive } from '../locomotives/entities/locomotive.entity'; import { Yard } from '../rule-engine/entities/yard.entity'; +import { BookingBatchService } from '../train-scheduling/booking-batch.service'; import { combinedLocomotiveLimits } from '../train-scheduling/train-capacity.util'; +import { ScheduleWagonAdjustmentLog } from '../train-schedules/entities/schedule-wagon-adjustment-log.entity'; +import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; +import { TrainSet } from '../train-sets/entities/train-set.entity'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { WagonMovement } from '../wagons/entities/wagon-movement.entity'; import { Wagon } from '../wagons/entities/wagon.entity'; @@ -58,7 +62,10 @@ export interface ActiveScheduleRef { export class TrainBuilderService { private readonly logger = new Logger(TrainBuilderService.name); - constructor(private readonly dataSource: DataSource) {} + constructor( + private readonly dataSource: DataSource, + private readonly bookingBatchService: BookingBatchService, + ) {} async buildTrain(dto: BuildTrainDto) { const locomotiveIds = [...new Set(dto.locomotiveIds)]; @@ -467,19 +474,26 @@ export class TrainBuilderService { } /** Append AVAILABLE wagons from the train's own yard to the consist. */ - async assignWagons(id: string, dto: AssignTrainWagonsDto) { + async assignWagons(id: string, dto: AssignTrainWagonsDto, userId?: string | null) { 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); + const attached = await this.attachWagons(manager, train, dto.wagonIds, currentCount); + await this.syncLiveScheduleAfterConsistChange( + manager, + train.id, + attached.map((w) => ({ action: 'ADD' as const, wagonId: w.id, wagonNumber: w.wagonNumber })), + userId ?? null, + train.currentYardId ?? null, + ); }); return this.getComposition(id); } /** Detach one wagon and close the sequence gap it leaves. */ - async removeWagon(id: string, wagonId: string) { + async removeWagon(id: string, wagonId: string, userId?: string | null) { await this.dataSource.transaction(async (manager) => { const train = await this.getEditableTrain(manager, id); const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } }); @@ -497,6 +511,13 @@ export class TrainBuilderService { status: WagonStatus.Available, }); await this.resequenceWagons(manager, train.id); + await this.syncLiveScheduleAfterConsistChange( + manager, + train.id, + [{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }], + userId ?? null, + wagon.currentYardId ?? train.currentYardId ?? null, + ); }); return this.getComposition(id); } @@ -506,7 +527,7 @@ export class TrainBuilderService { * moves to MAINTENANCE status (not AVAILABLE), so it is not re-coupled until * it clears maintenance. The freed sequence gap is closed. */ - async sendWagonToMaintenance(id: string, wagonId: string) { + async sendWagonToMaintenance(id: string, wagonId: string, userId?: string | null) { await this.dataSource.transaction(async (manager) => { const train = await this.getEditableTrain(manager, id); const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } }); @@ -546,6 +567,13 @@ export class TrainBuilderService { ); } await this.resequenceWagons(manager, train.id); + await this.syncLiveScheduleAfterConsistChange( + manager, + train.id, + [{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }], + userId ?? null, + yardId, + ); }); return this.getComposition(id); } @@ -781,6 +809,81 @@ export class TrainBuilderService { }; } + /** + * Train Builder edits a train's physical consist directly on `Wagon.trainId` + * — it never touches `TrainSchedule.maxWagons` / `TrainSet.wagonCount`, so a + * wagon added/removed here (while the train already has a live DRAFT/ + * SCHEDULED schedule) used to leave the schedule's capacity, history, and + * booking-window status silently stale. This mirrors what + * TrainSchedulingService.adjustScheduleConsist does when the SAME edit is + * made from the schedule's own consist editor, so both entry points agree. + */ + private async syncLiveScheduleAfterConsistChange( + manager: EntityManager, + trainId: string, + changes: Array<{ action: 'ADD' | 'REMOVE'; wagonId: string; wagonNumber: string }>, + userId: string | null, + yardId: string | null, + ): Promise { + if (!changes.length) return; + const trainSet = await manager + .getRepository(TrainSet) + .findOne({ where: { trainId }, order: { createdAt: 'DESC' } }); + const schedule = trainSet + ? await manager.getRepository(TrainSchedule).findOne({ + where: { trainSetId: trainSet.id, status: In(['DRAFT', 'SCHEDULED']) }, + }) + : null; + + const consist = await manager.getRepository(Wagon).find({ + where: { trainId }, + relations: { wagonType: true }, + }); + const wagonCount = consist.length; + const totalWeightTons = round( + consist.reduce((sum, w) => sum + (w.wagonType?.tareWeightTons ? Number(w.wagonType.tareWeightTons) : 0), 0), + ); + const totalLengthMeters = round( + consist.reduce((sum, w) => sum + (w.wagonType?.lengthMeters ? Number(w.wagonType.lengthMeters) : 0), 0), + ); + if (trainSet) { + await manager + .getRepository(TrainSet) + .update(trainSet.id, { wagonCount, totalWeightTons, totalLengthMeters }); + } + if (!schedule) return; + + await manager.getRepository(TrainSchedule).update(schedule.id, { maxWagons: wagonCount }); + + const now = new Date(); + await manager.getRepository(ScheduleWagonAdjustmentLog).save( + changes.map((c) => + manager.getRepository(ScheduleWagonAdjustmentLog).create({ + trainScheduleId: schedule.id, + trainId, + action: c.action, + wagonId: c.wagonId, + wagonNumber: c.wagonNumber, + adjustedByUserId: userId, + yardId, + occurredAt: now, + }), + ), + ); + + // Same full/reopen rule as adjustScheduleConsist: freeing a slot on a FULL + // schedule reopens its booking window; filling the last one closes it. + const wasFull = schedule.bookingWindowStatus === 'FULL'; + const usage = await this.bookingBatchService.scheduleWagonUsage(schedule.id); + if (!usage) return; + const nowFull = usage.remainingSlots <= 0; + if (wasFull && !nowFull) { + await this.bookingBatchService.refreshWindowStatus(schedule.id); + } else if (!wasFull && nowFull) { + await this.bookingBatchService.setWindow(schedule.id, 'FULL'); + } + } + /** 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({ @@ -856,7 +959,7 @@ export class TrainBuilderService { train: Train, wagonIds: string[], startCount: number, - ): Promise { + ): Promise { const uniqueIds = [...new Set(wagonIds)]; const wagonRepo = manager.getRepository(Wagon); @@ -883,7 +986,7 @@ export class TrainBuilderService { } toAttach.push(wagon); } - if (!toAttach.length) return; + if (!toAttach.length) return []; await this.assertConsistLengthWithinLimit(manager, train, toAttach); @@ -896,6 +999,7 @@ export class TrainBuilderService { status: WagonStatus.Assigned, }); } + return toAttach; } /** 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 0c2ce8ded..6009ebef7 100644 --- a/apps/edr-freight-api/src/modules/trains/trains.module.ts +++ b/apps/edr-freight-api/src/modules/trains/trains.module.ts @@ -1,6 +1,7 @@ // apps/edr-freight-api/src/modules/trains/trains.module.ts import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; import { TrainLocomotive } from './entities/train-locomotive.entity'; import { Train } from './entities/train.entity'; import { TrainBuilderController } from './train-builder.controller'; @@ -9,7 +10,7 @@ import { TrainsController } from './trains.controller'; import { TrainsService } from './trains.service'; @Module({ - imports: [TypeOrmModule.forFeature([Train, TrainLocomotive])], + imports: [TypeOrmModule.forFeature([Train, TrainLocomotive]), TrainSchedulingModule], controllers: [TrainsController, TrainBuilderController], providers: [TrainsService, TrainBuilderService], exports: [TrainsService, TrainBuilderService], diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/LegCapacityPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/LegCapacityPanel.tsx index 52c206c3c..cfec294bb 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/LegCapacityPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/LegCapacityPanel.tsx @@ -389,10 +389,16 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail } {e.bookings.map((b) => ( - - - + + + {b.reference} + {b.route ? ( + + {" "} + ({b.route}) + + ) : null} {b.unallocated ? ( @@ -401,12 +407,7 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail } ) : null} - - - {b.route ?? "—"} - - - + @@ -416,7 +417,7 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail } - +