From ad479bdcf2cdd74bdd7cc86590bc820cda4e4aeb Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 19 Aug 2026 10:10:04 +0000 Subject: [PATCH] issue fix --- .../modules/trains/train-builder.service.ts | 63 ++++++++++++++++--- 1 file changed, 53 insertions(+), 10 deletions(-) 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 a27944c3e..827596bb7 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 @@ -577,11 +577,7 @@ export class TrainBuilderService { if (!wagon || wagon.trainId !== train.id) { throw new NotFoundException(`Wagon ${wagonId} is not part of this train`); } - if (await this.isWagonPinnedToLiveSchedule(manager, wagon.id)) { - throw new ConflictException( - `Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`, - ); - } + await this.assertDetachableAndReleaseStaleSlots(manager, wagon); await manager.getRepository(Wagon).update(wagon.id, { trainId: null, sequenceNumber: null, @@ -618,11 +614,7 @@ export class TrainBuilderService { if (!wagon || wagon.trainId !== train.id) { throw new NotFoundException(`Wagon ${wagonId} is not part of this train`); } - if (await this.isWagonPinnedToLiveSchedule(manager, wagon.id)) { - throw new ConflictException( - `Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`, - ); - } + await this.assertDetachableAndReleaseStaleSlots(manager, wagon); const previousStatus = wagon.status; const notes = buildMaintenanceNotes(formatTrainRunLabel(train), note); await manager.getRepository(Wagon).update(wagon.id, { @@ -705,6 +697,57 @@ export class TrainBuilderService { return rows.length > 0; } + /** + * Detach guard for removeWagon / sendWagonToMaintenance. A wagon is truly + * pinned only while a live schedule still NEEDS it: a slot carrying booking + * allocations, or any slot on a DISPATCHED run. An empty (allocation-free) + * slot on a DRAFT/SCHEDULED schedule is a stale reservation — its load was + * moved to another wagon (moveWagonLoad keeps the emptied slot) or its + * booking left through a path that didn't clean up — and used to pin the + * wagon forever. Release those slots here instead of blocking, with the + * same recount removeTrainSetWagonSlot does (wagonCount / totalLengthMeters + * feed the schedule capacity math). + */ + private async assertDetachableAndReleaseStaleSlots( + manager: EntityManager, + wagon: Wagon, + ): Promise { + const rows: { id: string; train_set_id: string; status: string; allocs: string }[] = + await manager.query( + `SELECT tsw.id, tsw.train_set_id, ts.status, + (SELECT count(*) + FROM freight.wagon_booking_allocations a + WHERE a.train_set_wagon_id = tsw.id + AND a.deleted_at IS NULL) AS allocs + FROM freight.train_set_wagons tsw + JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id + WHERE tsw.physical_wagon_id = $1 + AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED') + AND ts.deleted_at IS NULL + AND tsw.deleted_at IS NULL`, + [wagon.id], + ); + if (!rows.length) return; + if (rows.some((r) => Number(r.allocs) > 0 || r.status === 'DISPATCHED')) { + throw new ConflictException( + `Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`, + ); + } + await manager.getRepository(TrainSetWagon).delete(rows.map((r) => r.id)); + for (const trainSetId of [...new Set(rows.map((r) => r.train_set_id))]) { + const remaining = await manager.getRepository(TrainSetWagon).find({ + where: { trainSetId }, + select: { id: true, lengthMeters: true }, + }); + await manager.getRepository(TrainSet).update(trainSetId, { + wagonCount: remaining.length, + totalLengthMeters: round( + remaining.reduce((sum, w) => sum + (Number(w.lengthMeters) || 0), 0), + ), + }); + } + } + /** 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) => {