mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 02:58:11 +00:00
298 lines
11 KiB
TypeScript
298 lines
11 KiB
TypeScript
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);
|
|
}
|
|
}
|