mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 07:51:02 +00:00
416 lines
16 KiB
TypeScript
416 lines
16 KiB
TypeScript
import { Freight, PaginatedResponse, WagonMovementKind, WagonStatus } from '@edr/types';
|
|
import {
|
|
BadRequestException,
|
|
Injectable,
|
|
NotFoundException,
|
|
ConflictException,
|
|
} from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { Repository, DataSource, In, SelectQueryBuilder } from 'typeorm';
|
|
import { paginateQuery } from '../../common/utils/pagination.util';
|
|
import { CreateWagonDto } from './dto/create-wagon.dto';
|
|
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
|
|
import { UpdateWagonDto } from './dto/update-wagon.dto';
|
|
import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto';
|
|
import { BulkTransferWagonsDto } from './dto/bulk-transfer-wagons.dto';
|
|
import { BulkSetWagonStatusDto } from './dto/bulk-set-wagon-status.dto';
|
|
import { Wagon } from './entities/wagon.entity';
|
|
import { WagonMovement } from './entities/wagon-movement.entity';
|
|
import { Train } from '../trains/entities/train.entity';
|
|
import { Yard } from '../rule-engine/entities/yard.entity';
|
|
|
|
@Injectable()
|
|
export class WagonsService {
|
|
constructor(
|
|
@InjectRepository(Wagon)
|
|
private readonly wagonRepo: Repository<Wagon>,
|
|
@InjectRepository(Train)
|
|
private readonly trainRepo: Repository<Train>,
|
|
private readonly dataSource: DataSource,
|
|
) {}
|
|
|
|
async create(dto: CreateWagonDto): Promise<Wagon> {
|
|
const wagon = this.wagonRepo.create({
|
|
...dto,
|
|
status: dto.status ?? WagonStatus.Available,
|
|
});
|
|
// Convert undefined to null for nullable fields
|
|
if (dto.trainId === undefined) wagon.trainId = null;
|
|
if (dto.sequenceNumber === undefined) wagon.sequenceNumber = null;
|
|
if (dto.currentYardId === undefined) wagon.currentYardId = null;
|
|
if (dto.exportTrainNumber === undefined) wagon.exportTrainNumber = null;
|
|
if (dto.importTrainNumber === undefined) wagon.importTrainNumber = null;
|
|
return this.wagonRepo.save(wagon);
|
|
}
|
|
|
|
/** Shared filter/sort builder behind `findAll` (array) and `findAllPaged` (envelope). */
|
|
private buildListQuery(query: ListWagonsQueryDto): SelectQueryBuilder<Wagon> {
|
|
const search = query.search?.trim();
|
|
const trainId = query.trainId?.trim();
|
|
const wagonTypeId = query.wagonTypeId?.trim();
|
|
const trainNumber = query.trainNumber?.trim();
|
|
|
|
// QueryBuilder (not find) because both search and the trainNumber filter span
|
|
// two columns each (export/import run) — an OR that FindOptions cannot express
|
|
// without cross-producting into conflicting branches. Soft-deleted rows are
|
|
// still excluded automatically (BaseEntity's @DeleteDateColumn).
|
|
const qb = this.wagonRepo
|
|
.createQueryBuilder('w')
|
|
.leftJoinAndSelect('w.currentYard', 'currentYard')
|
|
.leftJoinAndSelect('w.wagonType', 'wagonType');
|
|
|
|
if (query.status) qb.andWhere('w.status = :status', { status: query.status });
|
|
if (query.currentYardId)
|
|
qb.andWhere('w.currentYardId = :currentYardId', { currentYardId: query.currentYardId });
|
|
if (trainId) qb.andWhere('w.trainId = :trainId', { trainId });
|
|
// Pickers (train-builder, transfer fulfilment) can only take a wagon that is
|
|
// not already coupled to a built train — never offer one the API will reject.
|
|
if (query.unassigned) qb.andWhere('w.trainId IS NULL');
|
|
if (wagonTypeId) qb.andWhere('w.wagonTypeId = :wagonTypeId', { wagonTypeId });
|
|
|
|
// Filter by run: the odd export run identifies the pair, so match either
|
|
// column — a wagon carries export on one, import on the other.
|
|
if (trainNumber) {
|
|
qb.andWhere(
|
|
'(w.exportTrainNumber = :trainNumber OR w.importTrainNumber = :trainNumber)',
|
|
{ trainNumber },
|
|
);
|
|
}
|
|
|
|
// Registration-day range, both ends inclusive (the UI picks whole days).
|
|
if (query.createdFrom) {
|
|
qb.andWhere('w.createdAt >= CAST(:createdFrom AS date)', {
|
|
createdFrom: query.createdFrom,
|
|
});
|
|
}
|
|
if (query.createdTo) {
|
|
qb.andWhere("w.createdAt < CAST(:createdTo AS date) + INTERVAL '1 day'", {
|
|
createdTo: query.createdTo,
|
|
});
|
|
}
|
|
|
|
// Search matches the wagon number or either run number.
|
|
if (search) {
|
|
qb.andWhere(
|
|
'(w.wagonNumber ILIKE :search OR w.exportTrainNumber ILIKE :search OR w.importTrainNumber ILIKE :search)',
|
|
{ search: `%${search}%` },
|
|
);
|
|
}
|
|
|
|
// Spec columns (tare, payload) are no longer sortable here — they live on the
|
|
// wagon type, so sorting by them is sorting by wagonTypeId.
|
|
const sortable: Array<keyof Wagon> = [
|
|
'wagonNumber',
|
|
'status',
|
|
'currentYardId',
|
|
'sequenceNumber',
|
|
'wagonTypeId',
|
|
];
|
|
const sortBy = sortable.includes((query.sortBy ?? '') as keyof Wagon)
|
|
? (query.sortBy as keyof Wagon)
|
|
: 'wagonNumber';
|
|
const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
|
qb.orderBy(`w.${sortBy}`, sortOrder);
|
|
|
|
return qb;
|
|
}
|
|
|
|
/**
|
|
* The wagon list is always a page. Callers that genuinely need every row
|
|
* (yard workspace, coupling pickers) walk the pages client-side — see
|
|
* `wagonService.listAll` in the backoffice.
|
|
*/
|
|
findAll(query: ListWagonsQueryDto = {}): Promise<PaginatedResponse<Wagon>> {
|
|
return paginateQuery(this.buildListQuery(query), query, { defaultPageSize: 10 });
|
|
}
|
|
|
|
async findById(id: string): Promise<Wagon> {
|
|
const wagon = await this.wagonRepo.findOne({
|
|
where: { id },
|
|
relations: { currentYard: true, wagonType: true },
|
|
});
|
|
if (!wagon) throw new NotFoundException(`Wagon ${id} not found`);
|
|
return wagon;
|
|
}
|
|
|
|
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
|
|
// TypeORM otherwise re-derives `current_yard_id` from the STALE relation
|
|
// object (the old yard) on save and silently reverts the change. Drop the
|
|
// relation so the scalar `currentYardId` wins.
|
|
if (dto.currentYardId !== undefined) {
|
|
wagon.currentYard = null;
|
|
}
|
|
await this.wagonRepo.save(wagon);
|
|
// Staff manually relocated the wagon — write the movement ledger row so the
|
|
// wagon's yard history stays auditable (who moved it, from where, when).
|
|
if (
|
|
dto.currentYardId !== undefined &&
|
|
dto.currentYardId !== null &&
|
|
dto.currentYardId !== previousYardId
|
|
) {
|
|
const movementRepo = this.dataSource.getRepository(WagonMovement);
|
|
await movementRepo.save(
|
|
movementRepo.create({
|
|
wagonId: id,
|
|
fromYardId: previousYardId,
|
|
toYardId: dto.currentYardId,
|
|
kind: WagonMovementKind.Manual,
|
|
movedByUserId: userId ?? null,
|
|
occurredAt: new Date(),
|
|
}),
|
|
);
|
|
}
|
|
// Re-read with the relation so the response reflects the new yard label
|
|
// instead of the stale relation object loaded before the assign.
|
|
return this.findById(id);
|
|
}
|
|
|
|
/** Movement ledger for one wagon, newest first (loaded legs, repositions, manual moves). */
|
|
async listMovements(wagonId: string): Promise<WagonMovement[]> {
|
|
await this.findById(wagonId); // 404 on unknown wagon
|
|
return this.dataSource.getRepository(WagonMovement).find({
|
|
where: { wagonId },
|
|
relations: { fromYard: true, toYard: true },
|
|
order: { occurredAt: 'DESC', createdAt: 'DESC' },
|
|
});
|
|
}
|
|
|
|
async remove(id: string): Promise<void> {
|
|
const wagon = await this.findById(id);
|
|
// 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);
|
|
// 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`,
|
|
);
|
|
}
|
|
|
|
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 = 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;
|
|
return this.wagonRepo.save(wagon);
|
|
}
|
|
|
|
/**
|
|
* Relocate many wagons to one destination yard in a single transaction. Each
|
|
* wagon whose yard actually changes gets a `wagon_movements` ledger row (kind
|
|
* `Manual`) so the yard history stays auditable — mirrors the single-wagon
|
|
* `update` path. Wagons already in the destination yard are skipped.
|
|
*/
|
|
async bulkTransfer(
|
|
dto: BulkTransferWagonsDto,
|
|
userId?: string | null,
|
|
opts?: { transferRequestId?: string | null },
|
|
): Promise<{ moved: number }> {
|
|
const { wagonIds, toYardId } = dto;
|
|
if (!wagonIds.length) return { moved: 0 };
|
|
|
|
const yard = await this.dataSource
|
|
.getRepository(Yard)
|
|
.findOne({ where: { id: toYardId } });
|
|
if (!yard) throw new NotFoundException('Destination yard not found');
|
|
|
|
const queryRunner = this.dataSource.createQueryRunner();
|
|
await queryRunner.connect();
|
|
await queryRunner.startTransaction();
|
|
try {
|
|
const wagons = await queryRunner.manager.find(Wagon, {
|
|
where: { id: In(wagonIds) },
|
|
});
|
|
if (wagons.length !== wagonIds.length) {
|
|
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;
|
|
if (previousYardId === toYardId) continue;
|
|
wagon.currentYardId = toYardId;
|
|
// Drop the eager relation so the scalar FK wins on save (see `update`).
|
|
wagon.currentYard = null;
|
|
await queryRunner.manager.save(Wagon, wagon);
|
|
await queryRunner.manager.save(
|
|
queryRunner.manager.create(WagonMovement, {
|
|
wagonId: wagon.id,
|
|
fromYardId: previousYardId,
|
|
toYardId,
|
|
kind: WagonMovementKind.Manual,
|
|
movedByUserId: userId ?? null,
|
|
transferRequestId: opts?.transferRequestId ?? null,
|
|
occurredAt: new Date(),
|
|
}),
|
|
);
|
|
moved++;
|
|
}
|
|
|
|
await queryRunner.commitTransaction();
|
|
return { moved };
|
|
} catch (err) {
|
|
await queryRunner.rollbackTransaction();
|
|
throw err;
|
|
} finally {
|
|
await queryRunner.release();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set the same status on many wagons in one transaction (e.g. flip a batch
|
|
* from Available to Assigned in the yard workspace). Only the `status` column
|
|
* is touched — train assignment is managed through the assign/unassign flow.
|
|
*/
|
|
async bulkSetStatus(dto: BulkSetWagonStatusDto): Promise<{ updated: number }> {
|
|
const { wagonIds, status } = dto;
|
|
if (!wagonIds.length) return { updated: 0 };
|
|
|
|
const queryRunner = this.dataSource.createQueryRunner();
|
|
await queryRunner.connect();
|
|
await queryRunner.startTransaction();
|
|
try {
|
|
const wagons = await queryRunner.manager.find(Wagon, {
|
|
where: { id: In(wagonIds) },
|
|
});
|
|
if (wagons.length !== wagonIds.length) {
|
|
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;
|
|
}
|
|
await queryRunner.manager.save(Wagon, wagons);
|
|
|
|
await queryRunner.commitTransaction();
|
|
return { updated: wagons.length };
|
|
} catch (err) {
|
|
await queryRunner.rollbackTransaction();
|
|
throw err;
|
|
} finally {
|
|
await queryRunner.release();
|
|
}
|
|
}
|
|
|
|
}
|