mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 00:38:11 +00:00
train
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsInt, IsOptional, IsString, IsUUID, Max, 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.
|
||||
*/
|
||||
export class CreateTransferRequestDto {
|
||||
@IsUUID()
|
||||
fromYardId!: string;
|
||||
|
||||
@IsUUID()
|
||||
toYardId!: string;
|
||||
|
||||
@IsUUID()
|
||||
wagonTypeId!: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(1000)
|
||||
quantity!: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Optional note for the fulfilling staff' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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,
|
||||
WagonTransferRequest,
|
||||
} from '../../common/booking-guards';
|
||||
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);
|
||||
}
|
||||
|
||||
@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,153 @@
|
||||
import { WagonTransferRequestStatus } from '@edr/types';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { In, 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 { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
|
||||
import { WagonsService } from './wagons.service';
|
||||
|
||||
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>,
|
||||
private readonly wagonsService: WagonsService,
|
||||
) {}
|
||||
|
||||
/** Record a PENDING request. Count-only — no wagons are picked here. */
|
||||
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 request = this.requestRepo.create({
|
||||
fromYardId: dto.fromYardId,
|
||||
toYardId: dto.toYardId,
|
||||
wagonTypeId: dto.wagonTypeId,
|
||||
quantity: dto.quantity,
|
||||
status: WagonTransferRequestStatus.Pending,
|
||||
requestedByUserId: userId ?? null,
|
||||
note: dto.note ?? null,
|
||||
});
|
||||
const saved = await this.requestRepo.save(request);
|
||||
return this.findById(saved.id);
|
||||
}
|
||||
|
||||
/** 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(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Reuse the audited bulk-transfer path (writes wagon_movements ledger rows).
|
||||
await this.wagonsService.bulkTransfer(
|
||||
{ wagonIds, toYardId: request.toYardId },
|
||||
userId,
|
||||
);
|
||||
|
||||
request.status = WagonTransferRequestStatus.Fulfilled;
|
||||
request.fulfilledByUserId = userId ?? null;
|
||||
request.fulfilledAt = new Date();
|
||||
await this.requestRepo.save(request);
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,22 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Wagon } from './entities/wagon.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, WagonTransferRequest, Train, Yard])],
|
||||
controllers: [
|
||||
WagonsController,
|
||||
TrainWagonsReorderController,
|
||||
WagonTransferRequestsController,
|
||||
],
|
||||
providers: [WagonsService, WagonTransferRequestsService],
|
||||
exports: [WagonsService, WagonTransferRequestsService],
|
||||
})
|
||||
export class WagonsModule {}
|
||||
|
||||
Reference in New Issue
Block a user