mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 12:58:13 +00:00
gMerge branch 'dev' of github.com:Tria-plc/edr-platform into dev
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
import { ArrayMaxSize, ArrayMinSize, IsArray, IsUUID } from 'class-validator';
|
||||
|
||||
/**
|
||||
* OCC bulk accept-and-execute: the subset of PENDING request ids to execute
|
||||
* now. Requests not listed (or that cannot be executed) stay PENDING.
|
||||
*/
|
||||
export class BulkFulfillTransferRequestsDto {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(200)
|
||||
@IsUUID('all', { each: true })
|
||||
requestIds!: string[];
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { WagonStatus } from '@edr/types';
|
||||
import { ArrayNotEmpty, IsArray, IsEnum, IsUUID } from 'class-validator';
|
||||
|
||||
export class BulkSetWagonStatusDto {
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID('4', { each: true })
|
||||
wagonIds!: string[];
|
||||
|
||||
@IsEnum(WagonStatus)
|
||||
status!: WagonStatus;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ArrayNotEmpty, IsArray, IsUUID } from 'class-validator';
|
||||
|
||||
export class BulkTransferWagonsDto {
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID('4', { each: true })
|
||||
wagonIds!: string[];
|
||||
|
||||
@IsUUID()
|
||||
toYardId!: string;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* A count-only wagon-transfer request. The requester picks source yard, wagon
|
||||
* type, destination yard and HOW MANY — never the specific wagons; OCC hand-picks
|
||||
* those at fulfilment. The quantity may not exceed the AVAILABLE wagons of that
|
||||
* type currently in the source yard, and a reason is mandatory.
|
||||
*/
|
||||
export class CreateTransferRequestDto {
|
||||
@IsUUID()
|
||||
fromYardId!: string;
|
||||
|
||||
@IsUUID()
|
||||
toYardId!: string;
|
||||
|
||||
@IsUUID()
|
||||
wagonTypeId!: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(1000)
|
||||
quantity!: number;
|
||||
|
||||
@ApiProperty({ description: 'Why the wagons are needed — shown on the OCC queue' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(2000)
|
||||
reason!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Optional note for the fulfilling staff' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
}
|
||||
@@ -20,6 +20,16 @@ export class CreateWagonDto {
|
||||
// Tare weight and payload capacity are not accepted here: they belong to the
|
||||
// wagon type and are resolved through wagonTypeId.
|
||||
|
||||
/** EXPORT run number — odd, Ethiopia → Djibouti (e.g. 8001). */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
exportTrainNumber?: string;
|
||||
|
||||
/** IMPORT run number — even, Djibouti → Ethiopia (e.g. 8002). */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
importTrainNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(WagonStatus)
|
||||
status?: WagonStatus;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ArrayNotEmpty, IsArray, IsUUID } from 'class-validator';
|
||||
|
||||
/**
|
||||
* OCC fulfilment: the specific wagons hand-picked to satisfy a transfer request.
|
||||
* The service validates they all sit in the request's source yard, match its
|
||||
* wagon type, and number exactly the requested quantity.
|
||||
*/
|
||||
export class FulfillTransferRequestDto {
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID('4', { each: true })
|
||||
wagonIds!: string[];
|
||||
}
|
||||
@@ -29,6 +29,13 @@ export class ListWagonsQueryDto {
|
||||
@IsUUID()
|
||||
trainId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Filter by run number — matches export OR import run (e.g. 8001).',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
trainNumber?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: 'wagonNumber' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -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),
|
||||
) {}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { Wagon } from './wagon.entity';
|
||||
import { WagonTransferRequest } from './wagon-transfer-request.entity';
|
||||
|
||||
/**
|
||||
* Ledger of every physical wagon relocation between yards — one row per move.
|
||||
@@ -51,6 +52,14 @@ export class WagonMovement extends BaseEntity {
|
||||
@Column({ name: 'moved_by_user_id', type: 'uuid', nullable: true })
|
||||
movedByUserId?: string | null;
|
||||
|
||||
/** The transfer request this move fulfilled, when it came from one. */
|
||||
@Column({ name: 'transfer_request_id', type: 'uuid', nullable: true })
|
||||
transferRequestId?: string | null;
|
||||
|
||||
@ManyToOne(() => WagonTransferRequest, { nullable: true })
|
||||
@JoinColumn({ name: 'transfer_request_id' })
|
||||
transferRequest?: WagonTransferRequest | null;
|
||||
|
||||
@Column({ name: 'occurred_at', type: 'timestamptz' })
|
||||
occurredAt!: Date;
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { WagonTransferRequestStatus } from '@edr/types';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||
|
||||
/**
|
||||
* A two-person wagon relocation request. A requester asks for `quantity` wagons
|
||||
* of `wagonTypeId` to move from `fromYardId` to `toYardId` — specifying a count
|
||||
* only, never the physical wagons. OCC staff later open the PENDING request,
|
||||
* hand-pick the actual wagons in the source yard, and execute the transfer
|
||||
* (which writes the `wagon_movements` ledger and marks this FULFILLED).
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'wagon_transfer_requests' })
|
||||
@Index(['status', 'fromYardId'])
|
||||
export class WagonTransferRequest extends BaseEntity {
|
||||
@Column({ name: 'from_yard_id', type: 'uuid' })
|
||||
fromYardId!: string;
|
||||
|
||||
@ManyToOne(() => Yard)
|
||||
@JoinColumn({ name: 'from_yard_id' })
|
||||
fromYard?: Yard | null;
|
||||
|
||||
@Column({ name: 'to_yard_id', type: 'uuid' })
|
||||
toYardId!: string;
|
||||
|
||||
@ManyToOne(() => Yard)
|
||||
@JoinColumn({ name: 'to_yard_id' })
|
||||
toYard?: Yard | null;
|
||||
|
||||
@Column({ name: 'wagon_type_id', type: 'uuid' })
|
||||
wagonTypeId!: string;
|
||||
|
||||
@ManyToOne(() => WagonType)
|
||||
@JoinColumn({ name: 'wagon_type_id' })
|
||||
wagonType?: WagonType | null;
|
||||
|
||||
/** How many wagons of `wagonTypeId` to move out of `fromYardId`. */
|
||||
@Column({ name: 'quantity', type: 'int' })
|
||||
quantity!: number;
|
||||
|
||||
@Column({
|
||||
name: 'status',
|
||||
type: 'varchar',
|
||||
length: 20,
|
||||
default: WagonTransferRequestStatus.Pending,
|
||||
})
|
||||
status!: WagonTransferRequestStatus;
|
||||
|
||||
@Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true })
|
||||
requestedByUserId?: string | null;
|
||||
|
||||
@Column({ name: 'fulfilled_by_user_id', type: 'uuid', nullable: true })
|
||||
fulfilledByUserId?: string | null;
|
||||
|
||||
@Column({ name: 'fulfilled_at', type: 'timestamptz', nullable: true })
|
||||
fulfilledAt?: Date | null;
|
||||
|
||||
@Column({ name: 'note', type: 'text', nullable: true })
|
||||
note?: string | null;
|
||||
|
||||
/**
|
||||
* Why the wagons are needed — required for every new request and shown on
|
||||
* the OCC queue. Nullable only for rows that predate the requirement.
|
||||
*/
|
||||
@Column({ name: 'reason', type: 'text', nullable: true })
|
||||
reason?: string | null;
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export const WAGON_STATUSES = [
|
||||
WagonStatus.ImportReady,
|
||||
WagonStatus.ExportReady,
|
||||
WagonStatus.Maintenance,
|
||||
WagonStatus.Retired,
|
||||
WagonStatus.Detained,
|
||||
] as const;
|
||||
|
||||
export type WagonStatusType = (typeof WAGON_STATUSES)[number];
|
||||
@@ -43,6 +43,14 @@ export class Wagon extends BaseEntity {
|
||||
// Tare weight and payload capacity are properties of the wagon TYPE — read them
|
||||
// through `wagonType`, never off the individual wagon.
|
||||
|
||||
/** EXPORT run number — odd, Ethiopia → Djibouti (e.g. 8001). Null until set. */
|
||||
@Column({ name: 'export_train_number', type: 'varchar', length: 20, nullable: true })
|
||||
exportTrainNumber!: string | null;
|
||||
|
||||
/** IMPORT run number — even, Djibouti → Ethiopia (e.g. 8002). Null until set. */
|
||||
@Column({ name: 'import_train_number', type: 'varchar', length: 20, nullable: true })
|
||||
importTrainNumber!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: WagonStatus.Available })
|
||||
status!: WagonStatusType;
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { WagonTransferRequestStatus } from '@edr/types';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
import {
|
||||
FleetManage,
|
||||
FleetView,
|
||||
WagonTransferFulfill,
|
||||
WagonTransferHistoryAll,
|
||||
WagonTransferRequest,
|
||||
} from '../../common/booking-guards';
|
||||
import { BulkFulfillTransferRequestsDto } from './dto/bulk-fulfill-transfer-requests.dto';
|
||||
import { CreateTransferRequestDto } from './dto/create-transfer-request.dto';
|
||||
import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto';
|
||||
import { WagonTransferRequestsService } from './wagon-transfer-requests.service';
|
||||
|
||||
/**
|
||||
* Two-person wagon-transfer queue. Requester (transfer_request perm) files a
|
||||
* count-only request; OCC (transfer_fulfill perm) picks the wagons and executes
|
||||
* the move. Separate top-level path so it never collides with `wagons/:id`.
|
||||
*/
|
||||
@ApiTags('wagon-transfer-requests')
|
||||
@Controller('wagon-transfer-requests')
|
||||
@FleetView()
|
||||
export class WagonTransferRequestsController {
|
||||
constructor(private readonly service: WagonTransferRequestsService) {}
|
||||
|
||||
@Post()
|
||||
@WagonTransferRequest()
|
||||
@ApiOperation({ summary: 'File a count-only wagon-transfer request' })
|
||||
create(
|
||||
@Body() dto: CreateTransferRequestDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.service.createRequest(dto, user?.id);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiQuery({ name: 'status', required: false, enum: WagonTransferRequestStatus })
|
||||
@ApiOperation({ summary: 'List transfer requests (OCC queue: status=PENDING)' })
|
||||
list(@Query('status') status?: WagonTransferRequestStatus) {
|
||||
return this.service.listRequests(status);
|
||||
}
|
||||
|
||||
// NOTE: static routes (`history`, `bulk-fulfill`) MUST stay above `@Get(':id')`
|
||||
// — Express matches in declaration order, so they would otherwise be captured
|
||||
// by the `:id` param route (and rejected by ParseUUIDPipe).
|
||||
@Post('bulk-fulfill')
|
||||
@WagonTransferFulfill()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'OCC: accept-and-execute a subset of pending requests (auto-picks available wagons; the rest stay PENDING)',
|
||||
})
|
||||
bulkFulfill(
|
||||
@Body() dto: BulkFulfillTransferRequestsDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.service.bulkFulfill(dto.requestIds, user?.id);
|
||||
}
|
||||
|
||||
// NOTE: the two `history` routes MUST stay above `@Get(':id')` — Express
|
||||
// matches in declaration order, so `/history` would otherwise be captured by
|
||||
// the `:id` param route (and rejected by ParseUUIDPipe).
|
||||
@Get('history')
|
||||
@ApiOperation({
|
||||
summary: "Caller's own transfer history (requests filed/fulfilled + wagons moved)",
|
||||
})
|
||||
myHistory(@CurrentUser() user: TCurrentUser) {
|
||||
// Never fall through to the all-staff view: getHistory(undefined) means
|
||||
// "everyone", so a missing caller id must return empty, not leak scope.
|
||||
if (!user?.id) return { requests: [], movements: [] };
|
||||
return this.service.getHistory(user.id);
|
||||
}
|
||||
|
||||
@Get('history/all')
|
||||
@WagonTransferHistoryAll()
|
||||
@ApiQuery({ name: 'userId', required: false })
|
||||
@ApiOperation({
|
||||
summary: "Admin: any/all staff's transfer history (optional ?userId filter)",
|
||||
})
|
||||
allHistory(@Query('userId') userId?: string) {
|
||||
return this.service.getHistory(userId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get one transfer request' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.findById(id);
|
||||
}
|
||||
|
||||
@Post(':id/fulfill')
|
||||
@WagonTransferFulfill()
|
||||
@ApiOperation({ summary: 'OCC: pick wagons and execute the transfer' })
|
||||
fulfill(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: FulfillTransferRequestDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.service.fulfillRequest(id, dto, user?.id);
|
||||
}
|
||||
|
||||
@Post(':id/cancel')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Withdraw a pending transfer request' })
|
||||
cancel(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.cancelRequest(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import { WagonStatus, WagonTransferRequestStatus } from '@edr/types';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { In, IsNull, Not, Repository } from 'typeorm';
|
||||
|
||||
import { CreateTransferRequestDto } from './dto/create-transfer-request.dto';
|
||||
import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto';
|
||||
import { Wagon } from './entities/wagon.entity';
|
||||
import { WagonMovement } from './entities/wagon-movement.entity';
|
||||
import { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
|
||||
import { WagonsService } from './wagons.service';
|
||||
|
||||
/** Bundled per-user activity: requests they touched + wagons they moved. */
|
||||
export interface TransferHistory {
|
||||
requests: WagonTransferRequest[];
|
||||
movements: WagonMovement[];
|
||||
}
|
||||
|
||||
/** How many ledger rows the history returns at most (newest first). */
|
||||
const HISTORY_LIMIT = 500;
|
||||
|
||||
const REQUEST_RELATIONS = {
|
||||
fromYard: true,
|
||||
toYard: true,
|
||||
wagonType: true,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Two-person wagon-transfer workflow. A requester records a count-only request
|
||||
* (see `createRequest`); OCC staff later open the PENDING queue, hand-pick the
|
||||
* physical wagons, and `fulfillRequest` validates + executes the move. Replaces
|
||||
* the single-step instant bulk transfer.
|
||||
*/
|
||||
@Injectable()
|
||||
export class WagonTransferRequestsService {
|
||||
constructor(
|
||||
@InjectRepository(WagonTransferRequest)
|
||||
private readonly requestRepo: Repository<WagonTransferRequest>,
|
||||
@InjectRepository(Wagon)
|
||||
private readonly wagonRepo: Repository<Wagon>,
|
||||
@InjectRepository(WagonMovement)
|
||||
private readonly movementRepo: Repository<WagonMovement>,
|
||||
private readonly wagonsService: WagonsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Record a PENDING request. Count-only — no wagons are picked here, but the
|
||||
* count is capped at the AVAILABLE wagons of that type currently sitting in
|
||||
* the source yard: staff may only ask for wagons that are actually there to
|
||||
* give. A reason is mandatory and is shown on the OCC queue.
|
||||
*/
|
||||
async createRequest(
|
||||
dto: CreateTransferRequestDto,
|
||||
userId?: string | null,
|
||||
): Promise<WagonTransferRequest> {
|
||||
if (dto.fromYardId === dto.toYardId) {
|
||||
throw new BadRequestException(
|
||||
'Source and destination yard must be different',
|
||||
);
|
||||
}
|
||||
const available = await this.countAvailable(dto.fromYardId, dto.wagonTypeId);
|
||||
if (available < dto.quantity) {
|
||||
throw new BadRequestException(
|
||||
available === 0
|
||||
? 'No available wagons of this type in the source yard'
|
||||
: `Only ${available} available wagon(s) of this type in the source yard — request at most ${available}`,
|
||||
);
|
||||
}
|
||||
const request = this.requestRepo.create({
|
||||
fromYardId: dto.fromYardId,
|
||||
toYardId: dto.toYardId,
|
||||
wagonTypeId: dto.wagonTypeId,
|
||||
quantity: dto.quantity,
|
||||
status: WagonTransferRequestStatus.Pending,
|
||||
requestedByUserId: userId ?? null,
|
||||
reason: dto.reason,
|
||||
note: dto.note ?? null,
|
||||
});
|
||||
const saved = await this.requestRepo.save(request);
|
||||
return this.findById(saved.id);
|
||||
}
|
||||
|
||||
/** AVAILABLE wagons of `wagonTypeId` currently in `yardId`. */
|
||||
private countAvailable(yardId: string, wagonTypeId: string): Promise<number> {
|
||||
return this.wagonRepo.count({
|
||||
where: {
|
||||
currentYardId: yardId,
|
||||
wagonTypeId,
|
||||
status: WagonStatus.Available,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Requests, newest first, optionally filtered by status (OCC queue = PENDING). */
|
||||
async listRequests(
|
||||
status?: WagonTransferRequestStatus,
|
||||
): Promise<WagonTransferRequest[]> {
|
||||
return this.requestRepo.find({
|
||||
where: status ? { status } : {},
|
||||
relations: REQUEST_RELATIONS,
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<WagonTransferRequest> {
|
||||
const request = await this.requestRepo.findOne({
|
||||
where: { id },
|
||||
relations: REQUEST_RELATIONS,
|
||||
});
|
||||
if (!request) throw new NotFoundException(`Transfer request ${id} not found`);
|
||||
return request;
|
||||
}
|
||||
|
||||
/**
|
||||
* OCC fulfils a PENDING request with hand-picked wagons. Every wagon must sit
|
||||
* in the request's source yard, match its wagon type, and the count must equal
|
||||
* the requested quantity — then the transfer runs and the request is marked
|
||||
* FULFILLED.
|
||||
*/
|
||||
async fulfillRequest(
|
||||
id: string,
|
||||
dto: FulfillTransferRequestDto,
|
||||
userId?: string | null,
|
||||
): Promise<WagonTransferRequest> {
|
||||
const request = await this.findById(id);
|
||||
if (request.status !== WagonTransferRequestStatus.Pending) {
|
||||
throw new ConflictException(
|
||||
`Request is already ${request.status.toLowerCase()}`,
|
||||
);
|
||||
}
|
||||
|
||||
const wagonIds = [...new Set(dto.wagonIds)];
|
||||
if (wagonIds.length !== request.quantity) {
|
||||
throw new BadRequestException(
|
||||
`Select exactly ${request.quantity} wagon(s); you selected ${wagonIds.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
const wagons = await this.wagonRepo.find({ where: { id: In(wagonIds) } });
|
||||
if (wagons.length !== wagonIds.length) {
|
||||
throw new NotFoundException('One or more selected wagons not found');
|
||||
}
|
||||
const offSource = wagons.filter((w) => w.currentYardId !== request.fromYardId);
|
||||
if (offSource.length) {
|
||||
throw new BadRequestException(
|
||||
`These wagons are not in the source yard: ${offSource
|
||||
.map((w) => w.wagonNumber)
|
||||
.join(', ')}`,
|
||||
);
|
||||
}
|
||||
const wrongType = wagons.filter((w) => w.wagonTypeId !== request.wagonTypeId);
|
||||
if (wrongType.length) {
|
||||
throw new BadRequestException(
|
||||
`These wagons are the wrong type: ${wrongType
|
||||
.map((w) => w.wagonNumber)
|
||||
.join(', ')}`,
|
||||
);
|
||||
}
|
||||
const notAvailable = wagons.filter((w) => w.status !== WagonStatus.Available);
|
||||
if (notAvailable.length) {
|
||||
throw new BadRequestException(
|
||||
`These wagons are not available: ${notAvailable
|
||||
.map((w) => w.wagonNumber)
|
||||
.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Reuse the audited bulk-transfer path (writes wagon_movements ledger rows,
|
||||
// each stamped with this request's id so history can link them back).
|
||||
await this.wagonsService.bulkTransfer(
|
||||
{ wagonIds, toYardId: request.toYardId },
|
||||
userId,
|
||||
{ transferRequestId: request.id },
|
||||
);
|
||||
|
||||
request.status = WagonTransferRequestStatus.Fulfilled;
|
||||
request.fulfilledByUserId = userId ?? null;
|
||||
request.fulfilledAt = new Date();
|
||||
await this.requestRepo.save(request);
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* OCC accepts AND executes a subset of pending requests in one action. For
|
||||
* each selected request the system auto-picks the required number of
|
||||
* AVAILABLE wagons of the requested type from the source yard (lowest wagon
|
||||
* number first) and runs the audited transfer. A request that cannot be
|
||||
* executed — already decided, or not enough available wagons left after the
|
||||
* ones processed before it — is SKIPPED and simply stays PENDING, visible to
|
||||
* both teams; nothing is rolled back for the others.
|
||||
*/
|
||||
async bulkFulfill(
|
||||
requestIds: string[],
|
||||
userId?: string | null,
|
||||
): Promise<{
|
||||
fulfilled: WagonTransferRequest[];
|
||||
skipped: Array<{ id: string; reason: string }>;
|
||||
}> {
|
||||
const fulfilled: WagonTransferRequest[] = [];
|
||||
const skipped: Array<{ id: string; reason: string }> = [];
|
||||
|
||||
// Sequential on purpose: each executed transfer moves wagons out of the
|
||||
// source yard, and the next request's auto-pick must see that new state.
|
||||
for (const id of [...new Set(requestIds)]) {
|
||||
const request = await this.requestRepo.findOne({ where: { id } });
|
||||
if (!request) {
|
||||
skipped.push({ id, reason: 'Request not found' });
|
||||
continue;
|
||||
}
|
||||
if (request.status !== WagonTransferRequestStatus.Pending) {
|
||||
skipped.push({
|
||||
id,
|
||||
reason: `Already ${request.status.toLowerCase()}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const wagons = await this.wagonRepo.find({
|
||||
where: {
|
||||
currentYardId: request.fromYardId,
|
||||
wagonTypeId: request.wagonTypeId,
|
||||
status: WagonStatus.Available,
|
||||
},
|
||||
order: { wagonNumber: 'ASC' },
|
||||
take: request.quantity,
|
||||
});
|
||||
if (wagons.length < request.quantity) {
|
||||
skipped.push({
|
||||
id,
|
||||
reason: `Only ${wagons.length} of ${request.quantity} wagon(s) available in the source yard — left pending`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
await this.wagonsService.bulkTransfer(
|
||||
{ wagonIds: wagons.map((w) => w.id), toYardId: request.toYardId },
|
||||
userId,
|
||||
{ transferRequestId: request.id },
|
||||
);
|
||||
request.status = WagonTransferRequestStatus.Fulfilled;
|
||||
request.fulfilledByUserId = userId ?? null;
|
||||
request.fulfilledAt = new Date();
|
||||
await this.requestRepo.save(request);
|
||||
fulfilled.push(await this.findById(id));
|
||||
}
|
||||
|
||||
return { fulfilled, skipped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-user transfer history: the requests a user filed OR fulfilled, plus the
|
||||
* individual wagons they physically moved (linked back to their request when
|
||||
* one drove the move). Pass a `userId` to scope to one staffer; pass
|
||||
* `undefined` for the admin all-staff view. Scope is decided by the CALLER
|
||||
* (the controller passes the caller's id unless they hold the history-all
|
||||
* permission) — this method trusts its argument.
|
||||
*/
|
||||
async getHistory(userId?: string | null): Promise<TransferHistory> {
|
||||
const requests = await this.requestRepo.find({
|
||||
where: userId
|
||||
? [{ requestedByUserId: userId }, { fulfilledByUserId: userId }]
|
||||
: {},
|
||||
relations: REQUEST_RELATIONS,
|
||||
order: { createdAt: 'DESC' },
|
||||
take: HISTORY_LIMIT,
|
||||
});
|
||||
|
||||
const movements = await this.movementRepo.find({
|
||||
// Own view: moves I made. All view: every user-attributed move (skip the
|
||||
// system-written loaded/reposition legs that carry no mover).
|
||||
where: userId
|
||||
? { movedByUserId: userId }
|
||||
: { movedByUserId: Not(IsNull()) },
|
||||
relations: { wagon: true, fromYard: true, toYard: true, transferRequest: true },
|
||||
order: { occurredAt: 'DESC' },
|
||||
take: HISTORY_LIMIT,
|
||||
});
|
||||
|
||||
return { requests, movements };
|
||||
}
|
||||
|
||||
/** Withdraw a still-PENDING request. */
|
||||
async cancelRequest(id: string): Promise<WagonTransferRequest> {
|
||||
const request = await this.findById(id);
|
||||
if (request.status !== WagonTransferRequestStatus.Pending) {
|
||||
throw new ConflictException(
|
||||
`Only pending requests can be cancelled (this one is ${request.status.toLowerCase()})`,
|
||||
);
|
||||
}
|
||||
request.status = WagonTransferRequestStatus.Cancelled;
|
||||
await this.requestRepo.save(request);
|
||||
return this.findById(id);
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,16 @@ import {
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||
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 { ReorderWagonsDto } from './dto/reorder-wagons.dto';
|
||||
import { BulkTransferWagonsDto } from './dto/bulk-transfer-wagons.dto';
|
||||
import { BulkSetWagonStatusDto } from './dto/bulk-set-wagon-status.dto';
|
||||
import { WagonsService } from './wagons.service';
|
||||
|
||||
@ApiTags('wagons')
|
||||
@@ -78,6 +82,20 @@ export class WagonsController {
|
||||
unassignFromTrain(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.wagonsService.unassignFromTrain(id);
|
||||
}
|
||||
|
||||
@Post('bulk-transfer')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Transfer multiple wagons to a destination yard' })
|
||||
bulkTransfer(@Body() dto: BulkTransferWagonsDto, @CurrentUser() user: TCurrentUser) {
|
||||
return this.wagonsService.bulkTransfer(dto, user?.id);
|
||||
}
|
||||
|
||||
@Post('bulk-status')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Set the status of multiple wagons' })
|
||||
bulkSetStatus(@Body() dto: BulkSetWagonStatusDto) {
|
||||
return this.wagonsService.bulkSetStatus(dto);
|
||||
}
|
||||
}
|
||||
|
||||
// Separate controller for train‑specific reorder (registered in module)
|
||||
|
||||
@@ -1,15 +1,31 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Wagon } from './entities/wagon.entity';
|
||||
import { WagonMovement } from './entities/wagon-movement.entity';
|
||||
import { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
|
||||
import { Train } from '../trains/entities/train.entity';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { WagonsController, TrainWagonsReorderController } from './wagons.controller';
|
||||
import { WagonTransferRequestsController } from './wagon-transfer-requests.controller';
|
||||
import { WagonsService } from './wagons.service';
|
||||
import { WagonTransferRequestsService } from './wagon-transfer-requests.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Wagon, Train, Yard])],
|
||||
controllers: [WagonsController, TrainWagonsReorderController],
|
||||
providers: [WagonsService],
|
||||
exports: [WagonsService],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
Wagon,
|
||||
WagonMovement,
|
||||
WagonTransferRequest,
|
||||
Train,
|
||||
Yard,
|
||||
]),
|
||||
],
|
||||
controllers: [
|
||||
WagonsController,
|
||||
TrainWagonsReorderController,
|
||||
WagonTransferRequestsController,
|
||||
],
|
||||
providers: [WagonsService, WagonTransferRequestsService],
|
||||
exports: [WagonsService, WagonTransferRequestsService],
|
||||
})
|
||||
export class WagonsModule {}
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
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 } from 'typeorm';
|
||||
import { Repository, DataSource, In } from 'typeorm';
|
||||
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 { ReorderWagonsDto } from './dto/reorder-wagons.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 {
|
||||
@@ -30,26 +38,47 @@ export class WagonsService {
|
||||
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);
|
||||
}
|
||||
|
||||
async findAll(query: ListWagonsQueryDto = {}): Promise<Wagon[]> {
|
||||
const where: FindOptionsWhere<Wagon>[] | FindOptionsWhere<Wagon> = [];
|
||||
const search = query.search?.trim();
|
||||
const trainId = query.trainId?.trim();
|
||||
const wagonTypeId = query.wagonTypeId?.trim();
|
||||
const filters: FindOptionsWhere<Wagon> = {
|
||||
...(query.status ? { status: query.status } : {}),
|
||||
...(query.currentYardId ? { currentYardId: query.currentYardId } : {}),
|
||||
...(trainId ? { trainId } : {}),
|
||||
...(wagonTypeId ? { wagonTypeId } : {}),
|
||||
};
|
||||
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 });
|
||||
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 },
|
||||
);
|
||||
}
|
||||
|
||||
// Search matches the wagon number or either run number.
|
||||
if (search) {
|
||||
where.push({
|
||||
wagonNumber: ILike(`%${search}%`),
|
||||
...filters,
|
||||
});
|
||||
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
|
||||
@@ -65,14 +94,14 @@ export class WagonsService {
|
||||
? (query.sortBy as keyof Wagon)
|
||||
: 'wagonNumber';
|
||||
const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
||||
qb.orderBy(`w.${sortBy}`, sortOrder);
|
||||
|
||||
return this.wagonRepo.find({
|
||||
where: search ? where : filters,
|
||||
relations: { currentYard: true, wagonType: true },
|
||||
order: { [sortBy]: sortOrder } as FindOptionsOrder<Wagon>,
|
||||
skip: query.page && query.limit ? (Number(query.page) - 1) * Number(query.limit) : undefined,
|
||||
take: query.limit ? Number(query.limit) : undefined,
|
||||
});
|
||||
if (query.page && query.limit) {
|
||||
qb.skip((Number(query.page) - 1) * Number(query.limit));
|
||||
}
|
||||
if (query.limit) qb.take(Number(query.limit));
|
||||
|
||||
return qb.getMany();
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Wagon> {
|
||||
@@ -86,6 +115,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
|
||||
@@ -132,42 +175,223 @@ 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;
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
async reorderWagons(_trainId: string, dto: ReorderWagonsDto): Promise<void> {
|
||||
const queryRunner = this.dataSource.createQueryRunner();
|
||||
await queryRunner.connect();
|
||||
|
||||
Reference in New Issue
Block a user