mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 19:58:11 +00:00
changes
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[];
|
||||
}
|
||||
@@ -1,10 +1,20 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
|
||||
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.
|
||||
* 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()
|
||||
@@ -21,6 +31,12 @@ export class CreateTransferRequestDto {
|
||||
@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()
|
||||
|
||||
@@ -59,4 +59,11 @@ export class WagonTransferRequest extends BaseEntity {
|
||||
|
||||
@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];
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
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';
|
||||
@@ -51,6 +52,22 @@ export class WagonTransferRequestsController {
|
||||
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).
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { WagonTransferRequestStatus } from '@edr/types';
|
||||
import { WagonStatus, WagonTransferRequestStatus } from '@edr/types';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
@@ -48,7 +48,12 @@ export class WagonTransferRequestsService {
|
||||
private readonly wagonsService: WagonsService,
|
||||
) {}
|
||||
|
||||
/** Record a PENDING request. Count-only — no wagons are picked here. */
|
||||
/**
|
||||
* 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,
|
||||
@@ -58,6 +63,14 @@ export class WagonTransferRequestsService {
|
||||
'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,
|
||||
@@ -65,12 +78,24 @@ export class WagonTransferRequestsService {
|
||||
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,
|
||||
@@ -136,6 +161,14 @@ export class WagonTransferRequestsService {
|
||||
.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).
|
||||
@@ -152,6 +185,71 @@ export class WagonTransferRequestsService {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user