Files
edr-platform/apps/edr-freight-api/src/modules/wagons/wagons.service.ts

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();
}
}
}