This commit is contained in:
Marshal
2026-07-14 11:06:49 +00:00
parent 957a185a4d
commit 6d0cf50b4d
64 changed files with 4896 additions and 404 deletions

View File

@@ -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;
}

View File

@@ -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[];
}

View File

@@ -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;
}

View File

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

View File

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

View File

@@ -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 {}