mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 09:30:59 +00:00
fix issue
This commit is contained in:
@@ -1,4 +1,8 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { OmitType, PartialType } from '@nestjs/swagger';
|
||||
import { CreateWagonDto } from './create-wagon.dto';
|
||||
|
||||
export class UpdateWagonDto extends PartialType(CreateWagonDto) {}
|
||||
// `trainId` and `sequenceNumber` are owned by the assign/train-builder flow and
|
||||
// must never be settable through a generic wagon PATCH — omit them here.
|
||||
export class UpdateWagonDto extends PartialType(
|
||||
OmitType(CreateWagonDto, ['trainId', 'sequenceNumber'] as const),
|
||||
) {}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { WagonMovementKind, WagonStatus } from '@edr/types';
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { Freight, WagonMovementKind, WagonStatus } from '@edr/types';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike, In } from 'typeorm';
|
||||
import { CreateWagonDto } from './dto/create-wagon.dto';
|
||||
@@ -89,6 +94,20 @@ export class WagonsService {
|
||||
|
||||
async update(id: string, dto: UpdateWagonDto, userId?: string | null): Promise<Wagon> {
|
||||
const wagon = await this.findById(id);
|
||||
// A wagon coupled to a built train follows the train: its yard and status
|
||||
// are managed through the train-builder flow, not this generic PATCH.
|
||||
if (wagon.trainId != null) {
|
||||
if (dto.currentYardId !== undefined && dto.currentYardId !== wagon.currentYardId) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${wagon.wagonNumber} is coupled to a built train; relocate the train (train-builder) instead of moving the wagon`,
|
||||
);
|
||||
}
|
||||
if (dto.status !== undefined && dto.status !== wagon.status) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${wagon.wagonNumber} is coupled to a built train; detach it via train-builder before changing its status`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const previousYardId = wagon.currentYardId ?? null;
|
||||
Object.assign(wagon, dto);
|
||||
// `findById` eager-loads `currentYard`; when the DTO changes the scalar FK
|
||||
@@ -135,36 +154,96 @@ export class WagonsService {
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const wagon = await this.findById(id);
|
||||
await this.wagonRepo.remove(wagon);
|
||||
// A coupled wagon must be detached via train-builder before it can be
|
||||
// removed, so a built train never silently loses a wagon.
|
||||
if (wagon.trainId != null) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${wagon.wagonNumber} is coupled to a built train; detach it via train-builder before deleting it`,
|
||||
);
|
||||
}
|
||||
if (await this.isWagonPinnedToLiveSchedule(id)) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be deleted`,
|
||||
);
|
||||
}
|
||||
// Soft delete (deleted_at) — hard-deleting would strand ledger/schedule
|
||||
// history that references this wagon.
|
||||
await this.wagonRepo.softRemove(wagon);
|
||||
}
|
||||
|
||||
/**
|
||||
* A wagon is busy when any live (DRAFT/SCHEDULED/DISPATCHED) schedule pins it
|
||||
* to one of its slots — schedule occupancy lives on TrainSetWagon rows, not
|
||||
* on the Wagon entity. Mirrors TrainBuilderService.isWagonPinnedToLiveSchedule.
|
||||
*/
|
||||
private async isWagonPinnedToLiveSchedule(wagonId: string): Promise<boolean> {
|
||||
const rows: { exists: boolean }[] = await this.dataSource.query(
|
||||
`SELECT TRUE AS exists
|
||||
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
|
||||
LIMIT 1`,
|
||||
[wagonId],
|
||||
);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async assignToTrain(wagonId: string, dto: AssignWagonToTrainDto): Promise<Wagon> {
|
||||
const wagon = await this.findById(wagonId);
|
||||
if (wagon.status === WagonStatus.Assigned) {
|
||||
throw new ConflictException('Wagon already assigned to a train');
|
||||
// Mirror train-builder attachWagons: only a truly free, available wagon in
|
||||
// the train's own yard can be coupled, and never onto a dispatched train.
|
||||
if (wagon.trainId != null) {
|
||||
throw new ConflictException(`Wagon ${wagon.wagonNumber} is already on another train`);
|
||||
}
|
||||
if (wagon.status !== WagonStatus.Available) {
|
||||
throw new ConflictException(`Wagon ${wagon.wagonNumber} is not available (${wagon.status})`);
|
||||
}
|
||||
|
||||
const train = await this.trainRepo.findOne({ where: { id: dto.trainId } });
|
||||
if (!train) throw new NotFoundException('Train not found');
|
||||
if (train.status === Freight.TrainStatus.InService) {
|
||||
throw new ConflictException(
|
||||
`Train ${train.code} is out on a dispatched run; its composition is frozen until arrival`,
|
||||
);
|
||||
}
|
||||
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 attached`,
|
||||
);
|
||||
}
|
||||
|
||||
let sequence: number | null = dto.sequenceNumber ?? null;
|
||||
if (sequence === null) {
|
||||
const maxSeq = await this.wagonRepo
|
||||
.createQueryBuilder('w')
|
||||
.select('MAX(w.sequenceNumber)', 'max')
|
||||
.where('w.trainId = :trainId', { trainId: train.id })
|
||||
.getRawOne();
|
||||
sequence = (maxSeq?.max ?? 0) + 1;
|
||||
const maxSeq = await this.wagonRepo
|
||||
.createQueryBuilder('w')
|
||||
.select('MAX(w.sequenceNumber)', 'max')
|
||||
.where('w.trainId = :trainId', { trainId: train.id })
|
||||
.getRawOne();
|
||||
const nextSequence = Number(maxSeq?.max ?? 0) + 1;
|
||||
// An explicit sequence is only honoured when it is the next free slot;
|
||||
// anything else would duplicate a slot or leave a gap.
|
||||
if (dto.sequenceNumber != null && dto.sequenceNumber !== nextSequence) {
|
||||
throw new BadRequestException(
|
||||
`Sequence ${dto.sequenceNumber} is not the next free slot (${nextSequence}) for train ${train.code}`,
|
||||
);
|
||||
}
|
||||
|
||||
wagon.trainId = train.id;
|
||||
wagon.sequenceNumber = sequence;
|
||||
wagon.sequenceNumber = nextSequence;
|
||||
wagon.status = WagonStatus.Assigned;
|
||||
return this.wagonRepo.save(wagon);
|
||||
}
|
||||
|
||||
async unassignFromTrain(wagonId: string): Promise<Wagon> {
|
||||
const wagon = await this.findById(wagonId);
|
||||
// A wagon pinned to a live schedule is still operationally committed even
|
||||
// if the fleet train is being edited — don't free it out from under it.
|
||||
if (await this.isWagonPinnedToLiveSchedule(wagonId)) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be detached`,
|
||||
);
|
||||
}
|
||||
wagon.trainId = null;
|
||||
wagon.sequenceNumber = null;
|
||||
wagon.status = WagonStatus.Available;
|
||||
@@ -201,6 +280,19 @@ export class WagonsService {
|
||||
throw new NotFoundException('One or more wagons not found');
|
||||
}
|
||||
|
||||
// Only free, available wagons can be bulk-relocated; a coupled wagon
|
||||
// moves with its train (train-builder), never on its own here.
|
||||
const blocked = wagons.filter(
|
||||
(w) => w.trainId != null || w.status !== WagonStatus.Available,
|
||||
);
|
||||
if (blocked.length) {
|
||||
throw new ConflictException(
|
||||
`Cannot transfer wagons coupled to a train or not available: ${blocked
|
||||
.map((w) => w.wagonNumber)
|
||||
.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
let moved = 0;
|
||||
for (const wagon of wagons) {
|
||||
const previousYardId = wagon.currentYardId ?? null;
|
||||
@@ -253,6 +345,17 @@ export class WagonsService {
|
||||
throw new NotFoundException('One or more wagons not found');
|
||||
}
|
||||
|
||||
// A coupled wagon's status is owned by the train-builder flow — refuse to
|
||||
// flip status on any wagon that is currently on a built train.
|
||||
const coupled = wagons.filter((w) => w.trainId != null);
|
||||
if (coupled.length) {
|
||||
throw new ConflictException(
|
||||
`Cannot change status of wagons coupled to a built train: ${coupled
|
||||
.map((w) => w.wagonNumber)
|
||||
.join(', ')}. Detach them via train-builder first.`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const wagon of wagons) {
|
||||
wagon.status = status;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user