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, @InjectRepository(Wagon) private readonly wagonRepo: Repository, @InjectRepository(WagonMovement) private readonly movementRepo: Repository, 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 { 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 { 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 { return this.requestRepo.find({ where: status ? { status } : {}, relations: REQUEST_RELATIONS, order: { createdAt: 'DESC' }, }); } async findById(id: string): Promise { 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 { 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 { 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 { 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); } }