mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-04 10:13:44 +00:00
feat(clearance): preview charge documents before and after upload
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ArrayMinSize, IsArray, IsUUID } from 'class-validator';
|
||||
|
||||
export class SetTrainWagonsYardDto {
|
||||
@ApiProperty({
|
||||
type: [String],
|
||||
format: 'uuid',
|
||||
description:
|
||||
'Coupled wagons to relocate. All move in one transaction — if any is pinned to a live schedule, none move.',
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsUUID('all', { each: true })
|
||||
wagonIds!: string[];
|
||||
|
||||
@ApiProperty({
|
||||
format: 'uuid',
|
||||
description: 'Yard the selected wagons now sit in. The train itself stays put.',
|
||||
})
|
||||
@IsUUID()
|
||||
currentYardId!: string;
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import { SendWagonToMaintenanceDto } from './dto/send-wagon-to-maintenance.dto';
|
||||
import { UpdateTrainDetailsDto } from './dto/update-train-details.dto';
|
||||
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
|
||||
import { UpdateTrainYardDto } from './dto/update-train-yard.dto';
|
||||
import { SetTrainWagonsYardDto } from './dto/set-train-wagons-yard.dto';
|
||||
import { TrainBuilderService } from './train-builder.service';
|
||||
|
||||
@ApiTags('train-builder')
|
||||
@@ -128,6 +129,25 @@ export class TrainBuilderController {
|
||||
);
|
||||
}
|
||||
|
||||
@Patch(':id/wagons/yard')
|
||||
@FleetManage(FREIGHT_PERMS.trains.changeWagonYard)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Move several coupled wagons to another yard in one transaction — refused outright if any is allocated to a live schedule',
|
||||
})
|
||||
setWagonsYard(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: SetTrainWagonsYardDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.trainBuilderService.setWagonsYard(
|
||||
id,
|
||||
dto.wagonIds,
|
||||
dto.currentYardId,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/wagons')
|
||||
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||
@ApiOperation({ summary: "Append AVAILABLE wagons from the train's yard to the consist" })
|
||||
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { DataSource, EntityManager, ILike, In } from 'typeorm';
|
||||
|
||||
/** Schedule whose FULL flag must be re-derived once the consist edit has committed. */
|
||||
type PendingWindowCheck = { scheduleId: string; wasFull: boolean };
|
||||
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
|
||||
|
||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||
@@ -550,15 +553,79 @@ export class TrainBuilderService {
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move SEVERAL coupled wagons to another yard in one transaction (the train
|
||||
* and the rest of the consist stay put). All-or-nothing: if any wagon is not
|
||||
* coupled here, or is pinned to a live schedule, nothing moves — a partial
|
||||
* relocation would leave the consist split across yards silently. Wagons
|
||||
* already in the target yard are skipped, not an error.
|
||||
*/
|
||||
async setWagonsYard(
|
||||
id: string,
|
||||
wagonIds: string[],
|
||||
currentYardId: string,
|
||||
userId?: string | null,
|
||||
) {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const train = await this.getEditableTrain(manager, id);
|
||||
const yard = await manager.getRepository(Yard).findOne({ where: { id: currentYardId } });
|
||||
if (!yard) throw new NotFoundException(`Yard ${currentYardId} not found`);
|
||||
|
||||
const unique = [...new Set(wagonIds)];
|
||||
const wagons = await manager.getRepository(Wagon).find({ where: unique.map((wid) => ({ id: wid })) });
|
||||
const byId = new Map(wagons.map((w) => [w.id, w]));
|
||||
const missing = unique.filter((wid) => byId.get(wid)?.trainId !== train.id);
|
||||
if (missing.length) {
|
||||
throw new NotFoundException(
|
||||
`${missing.length} of ${unique.length} wagons are not coupled to train ${train.code}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check every wagon before moving any — the whole point of the bulk call.
|
||||
const pinned: string[] = [];
|
||||
for (const wagon of wagons) {
|
||||
if (await this.isWagonPinnedToLiveSchedule(manager, wagon.id)) {
|
||||
pinned.push(wagon.wagonNumber);
|
||||
}
|
||||
}
|
||||
if (pinned.length) {
|
||||
throw new ConflictException(
|
||||
`${pinned.join(', ')} ${pinned.length === 1 ? 'is' : 'are'} allocated to a scheduled or dispatched run; ${
|
||||
pinned.length === 1 ? 'its' : 'their'
|
||||
} yard cannot be changed`,
|
||||
);
|
||||
}
|
||||
|
||||
const moving = wagons.filter((w) => w.currentYardId !== yard.id);
|
||||
if (!moving.length) return;
|
||||
await manager
|
||||
.getRepository(Wagon)
|
||||
.update(moving.map((w) => w.id), { currentYardId: yard.id });
|
||||
await manager.getRepository(WagonMovement).save(
|
||||
moving.map((w) =>
|
||||
manager.getRepository(WagonMovement).create({
|
||||
wagonId: w.id,
|
||||
fromYardId: w.currentYardId ?? null,
|
||||
toYardId: yard.id,
|
||||
kind: WagonMovementKind.Manual,
|
||||
movedByUserId: userId ?? null,
|
||||
occurredAt: new Date(),
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/** Append AVAILABLE, unassigned wagons (any yard) to the consist. */
|
||||
async assignWagons(id: string, dto: AssignTrainWagonsDto, userId?: string | null) {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const pending = await this.dataSource.transaction(async (manager) => {
|
||||
const train = await this.getEditableTrain(manager, id);
|
||||
const currentCount = await manager
|
||||
.getRepository(Wagon)
|
||||
.count({ where: { trainId: train.id } });
|
||||
const attached = await this.attachWagons(manager, train, dto.wagonIds, currentCount);
|
||||
await this.syncLiveScheduleAfterConsistChange(
|
||||
return this.syncLiveScheduleAfterConsistChange(
|
||||
manager,
|
||||
train.id,
|
||||
attached.map((w) => ({ action: 'ADD' as const, wagonId: w.id, wagonNumber: w.wagonNumber })),
|
||||
@@ -566,12 +633,13 @@ export class TrainBuilderService {
|
||||
train.currentYardId ?? null,
|
||||
);
|
||||
});
|
||||
await this.reconcileWindowAfterConsistChange(pending);
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/** Detach one wagon and close the sequence gap it leaves. */
|
||||
async removeWagon(id: string, wagonId: string, userId?: string | null) {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const pending = 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) {
|
||||
@@ -586,7 +654,7 @@ export class TrainBuilderService {
|
||||
exportTrainNumber: null,
|
||||
});
|
||||
await this.resequenceWagons(manager, train.id);
|
||||
await this.syncLiveScheduleAfterConsistChange(
|
||||
return this.syncLiveScheduleAfterConsistChange(
|
||||
manager,
|
||||
train.id,
|
||||
[{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }],
|
||||
@@ -594,6 +662,7 @@ export class TrainBuilderService {
|
||||
wagon.currentYardId ?? train.currentYardId ?? null,
|
||||
);
|
||||
});
|
||||
await this.reconcileWindowAfterConsistChange(pending);
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
@@ -608,7 +677,7 @@ export class TrainBuilderService {
|
||||
userId?: string | null,
|
||||
note?: string | null,
|
||||
) {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const pending = 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) {
|
||||
@@ -663,7 +732,7 @@ export class TrainBuilderService {
|
||||
);
|
||||
}
|
||||
await this.resequenceWagons(manager, train.id);
|
||||
await this.syncLiveScheduleAfterConsistChange(
|
||||
return this.syncLiveScheduleAfterConsistChange(
|
||||
manager,
|
||||
train.id,
|
||||
[{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }],
|
||||
@@ -671,6 +740,7 @@ export class TrainBuilderService {
|
||||
yardId,
|
||||
);
|
||||
});
|
||||
await this.reconcileWindowAfterConsistChange(pending);
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
@@ -1000,8 +1070,8 @@ export class TrainBuilderService {
|
||||
changes: Array<{ action: 'ADD' | 'REMOVE'; wagonId: string; wagonNumber: string }>,
|
||||
userId: string | null,
|
||||
yardId: string | null,
|
||||
): Promise<void> {
|
||||
if (!changes.length) return;
|
||||
): Promise<PendingWindowCheck | null> {
|
||||
if (!changes.length) return null;
|
||||
const trainSet = await manager
|
||||
.getRepository(TrainSet)
|
||||
.findOne({ where: { trainId }, order: { createdAt: 'DESC' } });
|
||||
@@ -1027,7 +1097,7 @@ export class TrainBuilderService {
|
||||
.getRepository(TrainSet)
|
||||
.update(trainSet.id, { wagonCount, totalWeightTons, totalLengthMeters });
|
||||
}
|
||||
if (!schedule) return;
|
||||
if (!schedule) return null;
|
||||
|
||||
await manager.getRepository(TrainSchedule).update(schedule.id, { maxWagons: wagonCount });
|
||||
|
||||
@@ -1047,16 +1117,31 @@ export class TrainBuilderService {
|
||||
),
|
||||
);
|
||||
|
||||
// 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);
|
||||
// The FULL/reopen decision must run AFTER the transaction commits — see
|
||||
// reconcileWindowAfterConsistChange.
|
||||
return { scheduleId: schedule.id, wasFull: schedule.bookingWindowStatus === 'FULL' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Same full/reopen rule as adjustScheduleConsist: freeing a slot on a FULL
|
||||
* schedule reopens its booking window; filling the last one closes it.
|
||||
*
|
||||
* Runs only once the consist transaction has COMMITTED. BookingBatchService
|
||||
* reads through its own connection, so inside the transaction it still saw
|
||||
* the old consist: a wagon coupled onto an empty (FULL) train counted as 0
|
||||
* slots, `nowFull` stayed true and the window was never reopened.
|
||||
*/
|
||||
private async reconcileWindowAfterConsistChange(
|
||||
pending: PendingWindowCheck | null,
|
||||
): Promise<void> {
|
||||
if (!pending) return;
|
||||
const usage = await this.bookingBatchService.scheduleWagonUsage(pending.scheduleId);
|
||||
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');
|
||||
if (pending.wasFull && !nowFull) {
|
||||
await this.bookingBatchService.refreshWindowStatus(pending.scheduleId);
|
||||
} else if (!pending.wasFull && nowFull) {
|
||||
await this.bookingBatchService.setWindow(pending.scheduleId, 'FULL');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user