Files
edr-platform/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts
Marshal 9b13fa2ac6 feat: implement wagon transfer management modals and page
- Add TransferFulfillModal for fulfilling wagon transfer requests.
- Create TransferRequestFormModal for filing new wagon transfer requests.
- Introduce TransferCloseShortModal for closing requests that cannot be fully fulfilled.
- Develop WagonTransfersPage to manage and display wagon transfer requests.
- Implement utility functions for handling wagon transfer request data and UI components.
- Enhance UI with Mantine components for better user experience.
2026-07-26 15:11:50 +00:00

499 lines
17 KiB
TypeScript

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<WagonTransferRequest>,
@InjectRepository(Wagon)
private readonly wagonRepo: Repository<Wagon>,
@InjectRepository(WagonMovement)
private readonly movementRepo: Repository<WagonMovement>,
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<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,
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<number> {
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<PaginatedResponse<WagonTransferRequest>> {
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<WagonTransferRequest> {
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<WagonTransferRequest> {
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<void> {
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<WagonTransferRequest> {
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<TransferHistory> {
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<WagonTransferRequest> {
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);
}
}