mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
- 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.
499 lines
17 KiB
TypeScript
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);
|
|
}
|
|
}
|