import { NotificationAudience, NotificationType, OPEN_WAGON_TRANSFER_STATUSES, PaginatedResponse, WagonStatus, WagonTransferRequestStatus, } from '@edr/types'; import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { In, IsNull, Not, Repository } from 'typeorm'; import { paginateQuery } from '../../common/utils/pagination.util'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { CloseShortTransferRequestDto } from './dto/close-short-transfer-request.dto'; import { CreateTransferRequestDto } from './dto/create-transfer-request.dto'; import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto'; import { ListTransferRequestsQueryDto } from './dto/list-transfer-requests-query.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[]; /** * One pager drives both lists (they are shown side by side), so it carries a * total per list and the page count of the longer one. */ meta: { page: number; pageSize: number; requestsTotal: number; movementsTotal: number; totalPages: number; }; } /** Hard ceiling on a single history page, whatever the client asks for. */ const HISTORY_LIMIT = 100; 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 { private readonly logger = new Logger(WagonTransferRequestsService.name); constructor( @InjectRepository(WagonTransferRequest) private readonly requestRepo: Repository, @InjectRepository(Wagon) private readonly wagonRepo: Repository, @InjectRepository(WagonMovement) private readonly movementRepo: Repository, private readonly wagonsService: WagonsService, private readonly inbox: NotificationInboxService, ) {} /** * Record a PENDING request. Count-only — no wagons are picked here, and the * count is NOT capped by what the source yard holds today: OCC fulfils in * instalments, so asking for 50 while only 20 sit there is a normal, useful * request. 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 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` — what OCC can move * right now. Shown on the desk beside the outstanding count so staff see at a * glance how much of a request the yard can cover today. */ countAvailable(yardId: string, wagonTypeId: string): Promise { return this.wagonRepo.count({ where: { currentYardId: yardId, wagonTypeId, status: WagonStatus.Available, }, }); } /** * The transfer desk list: paginated, newest first, filterable by status (one * or a comma-separated set — the "Open" tab asks for PENDING + * PARTIALLY_FULFILLED), yards and wagon type. Search matches the reason text. */ async listRequests( query: ListTransferRequestsQueryDto, ): Promise> { const qb = this.requestRepo .createQueryBuilder('r') .leftJoinAndSelect('r.fromYard', 'fromYard') .leftJoinAndSelect('r.toYard', 'toYard') .leftJoinAndSelect('r.wagonType', 'wagonType'); const statuses = (query.status ?? '') .split(',') .map((s) => s.trim()) .filter(Boolean); if (statuses.length) { qb.andWhere('r.status IN (:...statuses)', { statuses }); } if (query.fromYardId) { qb.andWhere('r.from_yard_id = :fromYardId', { fromYardId: query.fromYardId }); } if (query.toYardId) { qb.andWhere('r.to_yard_id = :toYardId', { toYardId: query.toYardId }); } if (query.wagonTypeId) { qb.andWhere('r.wagon_type_id = :wagonTypeId', { wagonTypeId: query.wagonTypeId, }); } if (query.search) { qb.andWhere('r.reason ILIKE :search', { search: `%${query.search}%` }); } const sortColumn = query.sortBy === 'quantity' ? 'r.quantity' : query.sortBy === 'status' ? 'r.status' : 'r.created_at'; qb.orderBy(sortColumn, query.sortOrder ?? 'DESC'); return paginateQuery(qb, { page: query.page, pageSize: query.pageSize }); } 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; } /** Wagons still owed on an open request. */ private remainingOn(request: WagonTransferRequest): number { return Math.max(0, request.quantity - (request.fulfilledQuantity ?? 0)); } /** True while OCC can still move wagons against this request. */ private isOpen(request: WagonTransferRequest): boolean { return OPEN_WAGON_TRANSFER_STATUSES.includes(request.status); } /** * OCC moves hand-picked wagons against an open request. Any number from 1 up * to whatever is still owed — the yard rarely has the whole ask at once, so a * request for 50 can be met 20 now, 30 later. Every wagon must sit in the * source yard, match the type and be available. The request completes on its * own once the full count has moved; short of that it stays open as * PARTIALLY_FULFILLED and the requester is told what landed. */ async fulfillRequest( id: string, dto: FulfillTransferRequestDto, userId?: string | null, ): Promise { const request = await this.findById(id); if (!this.isOpen(request)) { throw new ConflictException( `Request is already ${request.status.toLowerCase().replace(/_/g, ' ')}`, ); } const wagonIds = [...new Set(dto.wagonIds)]; const remaining = this.remainingOn(request); if (wagonIds.length > remaining) { throw new BadRequestException( `Only ${remaining} wagon(s) still owed on this request; 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 }, ); await this.recordDelivery(request, wagonIds.length, userId); return this.findById(id); } /** * Book an instalment against a request: bump the delivered count, complete it * when the full ask has landed, and tell the requester what moved. Shared by * the hand-picked and auto-picked (bulk) fulfilment paths. */ private async recordDelivery( request: WagonTransferRequest, moved: number, userId?: string | null, ): Promise { request.fulfilledQuantity = (request.fulfilledQuantity ?? 0) + moved; request.status = request.fulfilledQuantity >= request.quantity ? WagonTransferRequestStatus.Fulfilled : WagonTransferRequestStatus.PartiallyFulfilled; request.fulfilledByUserId = userId ?? null; request.fulfilledAt = new Date(); await this.requestRepo.save(request); this.notifyRequester(request, moved); } /** * Tell the requester what landed. Fire-and-forget: a notification failure must * never undo a transfer that already moved wagons. */ private notifyRequester( request: WagonTransferRequest, moved: number, closedShortNote?: string | null, ): void { if (!request.requestedByUserId) return; const outstanding = this.remainingOn(request); const complete = request.status === WagonTransferRequestStatus.Fulfilled; const closedShort = request.status === WagonTransferRequestStatus.ClosedShort; const title = complete ? `All ${request.quantity} wagon(s) transferred` : closedShort ? `Transfer closed short — ${request.fulfilledQuantity} of ${request.quantity} wagon(s)` : `${moved} of ${request.quantity} wagon(s) transferred`; const body = complete ? `Your wagon transfer request is complete — all ${request.quantity} wagon(s) have arrived.` : closedShort ? `Only ${request.fulfilledQuantity} of the ${request.quantity} wagon(s) you asked for could be supplied` + `${closedShortNote ? `: ${closedShortNote}` : '.'} ` + `Request the remaining ${outstanding} from another yard.` : `${moved} wagon(s) have arrived against your request. ` + `${outstanding} of ${request.quantity} still to come.`; void this.inbox .notify({ recipients: { userIds: [request.requestedByUserId] }, audience: NotificationAudience.BACKOFFICE, type: NotificationType.GENERIC, title, body, link: `/dashboard/wagon-transfers/${request.id}`, data: { transferRequestId: request.id, delivered: request.fulfilledQuantity, requested: request.quantity, outstanding, }, }) .catch((err) => this.logger.warn( `Transfer notification failed for ${request.id}: ${(err as Error).message}`, ), ); } /** * OCC ends a request with fewer wagons than asked for — the source yard has * nothing more to give. What already moved stays moved; the requester is told * the shortfall so they can raise it against another yard. Cancelling is for * requests that never moved anything; this is the close for ones that did. */ async closeShort( id: string, dto: CloseShortTransferRequestDto, userId?: string | null, ): Promise { const request = await this.findById(id); if (!this.isOpen(request)) { throw new ConflictException( `Request is already ${request.status.toLowerCase().replace(/_/g, ' ')}`, ); } if (this.remainingOn(request) === 0) { throw new ConflictException( 'Nothing outstanding — this request is already fully supplied', ); } request.status = WagonTransferRequestStatus.ClosedShort; request.closedShortAt = new Date(); request.closedShortByUserId = userId ?? null; if (dto.note?.trim()) { request.note = dto.note.trim(); } await this.requestRepo.save(request); this.notifyRequester(request, 0, dto.note ?? null); return this.findById(id); } /** * OCC executes a set of open requests in one action, auto-picking AVAILABLE * wagons of the requested type from each source yard (lowest wagon number * first). A yard that cannot cover the whole ask still sends what it has — * the request stays open for the rest rather than being skipped, which is the * whole point of instalments. Only a request with NOTHING available is * skipped, and 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 (!this.isOpen(request)) { skipped.push({ id, reason: `Already ${request.status.toLowerCase().replace(/_/g, ' ')}`, }); continue; } const remaining = this.remainingOn(request); const wagons = await this.wagonRepo.find({ where: { currentYardId: request.fromYardId, wagonTypeId: request.wagonTypeId, status: WagonStatus.Available, }, order: { wagonNumber: 'ASC' }, take: remaining, }); if (wagons.length === 0) { skipped.push({ id, reason: 'No available wagons of this type in the source yard — left open', }); continue; } await this.wagonsService.bulkTransfer( { wagonIds: wagons.map((w) => w.id), toYardId: request.toYardId }, userId, { transferRequestId: request.id }, ); await this.recordDelivery(request, wagons.length, userId); 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, page?: number, pageSize?: number, ): Promise { const take = Math.min(pageSize ?? 20, HISTORY_LIMIT); const skip = ((page ?? 1) - 1) * take; const [requests, requestsTotal] = await this.requestRepo.findAndCount({ where: userId ? [{ requestedByUserId: userId }, { fulfilledByUserId: userId }] : {}, relations: REQUEST_RELATIONS, order: { createdAt: 'DESC' }, skip, take, }); const [movements, movementsTotal] = await this.movementRepo.findAndCount({ // 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' }, skip, take, }); return { requests, movements, meta: { page: page ?? 1, pageSize: take, requestsTotal, movementsTotal, // Whichever list is longer decides how far the pager can go. totalPages: Math.max( 1, Math.ceil(Math.max(requestsTotal, movementsTotal) / take), ), }, }; } /** * Withdraw a request before anything moved. Once wagons have been delivered * the request can only be completed or closed short — cancelling would erase * the fact that a transfer happened. */ async cancelRequest(id: string): Promise { const request = await this.findById(id); if (request.status !== WagonTransferRequestStatus.Pending) { throw new ConflictException( request.status === WagonTransferRequestStatus.PartiallyFulfilled ? 'Wagons have already moved against this request — close it short instead of cancelling' : `Only pending requests can be cancelled (this one is ${request.status.toLowerCase().replace(/_/g, ' ')})`, ); } request.status = WagonTransferRequestStatus.Cancelled; await this.requestRepo.save(request); return this.findById(id); } }