feat(clearance): preview charge documents before and after upload

This commit is contained in:
Marshal
2026-08-21 07:04:22 +00:00
parent ce3fde676e
commit 4c549029fe
32 changed files with 1195 additions and 660 deletions

View File

@@ -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;
}

View File

@@ -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" })

View File

@@ -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');
}
}