mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-02 18:33:39 +00:00
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.
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
/**
|
||||
* OCC ends a transfer request with fewer wagons than asked for. The note is
|
||||
* carried into the requester's notification — it is what tells them WHY the
|
||||
* yard could not give the rest.
|
||||
*/
|
||||
export class CloseShortTransferRequestDto {
|
||||
@ApiPropertyOptional({
|
||||
description: 'Why the source yard cannot supply the remainder',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
note?: string;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { WagonTransferRequestStatus } from '@edr/types';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsIn, IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||
|
||||
const SORT_FIELDS = ['createdAt', 'quantity', 'status'] as const;
|
||||
|
||||
/**
|
||||
* Transfer-desk list query. `status` accepts a comma-separated list so the
|
||||
* "Open" tab can ask for PENDING + PARTIALLY_FULFILLED in one call.
|
||||
*/
|
||||
export class ListTransferRequestsQueryDto extends PaginationQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
description: 'One status or a comma-separated list',
|
||||
enum: WagonTransferRequestStatus,
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' && value.trim() ? value.trim() : undefined,
|
||||
)
|
||||
status?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Source yard' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
fromYardId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Destination yard' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
toYardId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Wagon type' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
wagonTypeId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: SORT_FIELDS, default: 'createdAt' })
|
||||
@IsOptional()
|
||||
@IsIn([...SORT_FIELDS])
|
||||
sortBy?: (typeof SORT_FIELDS)[number];
|
||||
}
|
||||
@@ -40,6 +40,14 @@ export class WagonTransferRequest extends BaseEntity {
|
||||
@Column({ name: 'quantity', type: 'int' })
|
||||
quantity!: number;
|
||||
|
||||
/**
|
||||
* How many have actually moved so far. OCC sends what the yard can spare,
|
||||
* whenever it can — the request stays open until this reaches `quantity` or
|
||||
* OCC closes it short.
|
||||
*/
|
||||
@Column({ name: 'fulfilled_quantity', type: 'int', default: 0 })
|
||||
fulfilledQuantity!: number;
|
||||
|
||||
@Column({
|
||||
name: 'status',
|
||||
type: 'varchar',
|
||||
@@ -54,9 +62,17 @@ export class WagonTransferRequest extends BaseEntity {
|
||||
@Column({ name: 'fulfilled_by_user_id', type: 'uuid', nullable: true })
|
||||
fulfilledByUserId?: string | null;
|
||||
|
||||
/** When the LAST transfer against this request ran (not necessarily the full count). */
|
||||
@Column({ name: 'fulfilled_at', type: 'timestamptz', nullable: true })
|
||||
fulfilledAt?: Date | null;
|
||||
|
||||
/** Set when OCC ended the request with fewer wagons than asked for. */
|
||||
@Column({ name: 'closed_short_at', type: 'timestamptz', nullable: true })
|
||||
closedShortAt?: Date | null;
|
||||
|
||||
@Column({ name: 'closed_short_by_user_id', type: 'uuid', nullable: true })
|
||||
closedShortByUserId?: string | null;
|
||||
|
||||
@Column({ name: 'note', type: 'text', nullable: true })
|
||||
note?: string | null;
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { WagonTransferRequestStatus } from '@edr/types';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@@ -13,26 +12,36 @@ import { CurrentUser } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
import {
|
||||
FleetManage,
|
||||
FleetView,
|
||||
WagonTransferCancel,
|
||||
WagonTransferCloseShort,
|
||||
WagonTransferFulfill,
|
||||
WagonTransferHistoryAll,
|
||||
WagonTransferRequest,
|
||||
WagonTransferView,
|
||||
} from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { BulkFulfillTransferRequestsDto } from './dto/bulk-fulfill-transfer-requests.dto';
|
||||
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 { WagonTransferRequestsService } from './wagon-transfer-requests.service';
|
||||
|
||||
/** Query-string number, or undefined when absent/garbage (service defaults it). */
|
||||
const toInt = (value?: string): number | undefined => {
|
||||
const n = Number.parseInt(String(value ?? ''), 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Two-person wagon-transfer queue. Requester (transfer_request perm) files a
|
||||
* count-only request; OCC (transfer_fulfill perm) picks the wagons and executes
|
||||
* the move. Separate top-level path so it never collides with `wagons/:id`.
|
||||
* The wagon-transfer desk. A requester (transfer_request) files a count-only
|
||||
* request; OCC (transfer_fulfill) moves wagons against it in as many
|
||||
* instalments as the source yard allows, and closes it short
|
||||
* (transfer_close_short) when the yard has no more to give. Separate top-level
|
||||
* path so it never collides with `wagons/:id`.
|
||||
*/
|
||||
@ApiTags('wagon-transfer-requests')
|
||||
@Controller('wagon-transfer-requests')
|
||||
@FleetView(FREIGHT_PERMS.wagons.view)
|
||||
@WagonTransferView()
|
||||
export class WagonTransferRequestsController {
|
||||
constructor(private readonly service: WagonTransferRequestsService) {}
|
||||
|
||||
@@ -47,10 +56,12 @@ export class WagonTransferRequestsController {
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiQuery({ name: 'status', required: false, enum: WagonTransferRequestStatus })
|
||||
@ApiOperation({ summary: 'List transfer requests (OCC queue: status=PENDING)' })
|
||||
list(@Query('status') status?: WagonTransferRequestStatus) {
|
||||
return this.service.listRequests(status);
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Transfer desk list — paginated, filterable by status (comma-separated), yards and wagon type',
|
||||
})
|
||||
list(@Query() query: ListTransferRequestsQueryDto) {
|
||||
return this.service.listRequests(query);
|
||||
}
|
||||
|
||||
// NOTE: static routes (`history`, `bulk-fulfill`) MUST stay above `@Get(':id')`
|
||||
@@ -73,24 +84,48 @@ export class WagonTransferRequestsController {
|
||||
// matches in declaration order, so `/history` would otherwise be captured by
|
||||
// the `:id` param route (and rejected by ParseUUIDPipe).
|
||||
@Get('history')
|
||||
@ApiQuery({ name: 'page', required: false })
|
||||
@ApiQuery({ name: 'pageSize', required: false })
|
||||
@ApiOperation({
|
||||
summary: "Caller's own transfer history (requests filed/fulfilled + wagons moved)",
|
||||
})
|
||||
myHistory(@CurrentUser() user: TCurrentUser) {
|
||||
myHistory(
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
// Never fall through to the all-staff view: getHistory(undefined) means
|
||||
// "everyone", so a missing caller id must return empty, not leak scope.
|
||||
if (!user?.id) return { requests: [], movements: [] };
|
||||
return this.service.getHistory(user.id);
|
||||
if (!user?.id) {
|
||||
return {
|
||||
requests: [],
|
||||
movements: [],
|
||||
meta: {
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
requestsTotal: 0,
|
||||
movementsTotal: 0,
|
||||
totalPages: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
return this.service.getHistory(user.id, toInt(page), toInt(pageSize));
|
||||
}
|
||||
|
||||
@Get('history/all')
|
||||
@WagonTransferHistoryAll()
|
||||
@ApiQuery({ name: 'userId', required: false })
|
||||
@ApiQuery({ name: 'page', required: false })
|
||||
@ApiQuery({ name: 'pageSize', required: false })
|
||||
@ApiOperation({
|
||||
summary: "Admin: any/all staff's transfer history (optional ?userId filter)",
|
||||
})
|
||||
allHistory(@Query('userId') userId?: string) {
|
||||
return this.service.getHistory(userId);
|
||||
allHistory(
|
||||
@Query('userId') userId?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.service.getHistory(userId, toInt(page), toInt(pageSize));
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@@ -110,9 +145,26 @@ export class WagonTransferRequestsController {
|
||||
return this.service.fulfillRequest(id, dto, user?.id);
|
||||
}
|
||||
|
||||
@Post(':id/close-short')
|
||||
@WagonTransferCloseShort()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'OCC: end the request with fewer wagons than asked for — what moved stays, the requester is told the shortfall',
|
||||
})
|
||||
closeShort(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: CloseShortTransferRequestDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.service.closeShort(id, dto, user?.id);
|
||||
}
|
||||
|
||||
@Post(':id/cancel')
|
||||
@FleetManage(FREIGHT_PERMS.wagons.transferRequest)
|
||||
@ApiOperation({ summary: 'Withdraw a pending transfer request' })
|
||||
@WagonTransferCancel()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Withdraw a request that has not moved any wagon yet (use close-short once wagons have moved)',
|
||||
})
|
||||
cancel(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.cancelRequest(id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import { WagonTransferRequestStatus } from '@edr/types';
|
||||
import { ConflictException, BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { WagonTransferRequestsService } from './wagon-transfer-requests.service';
|
||||
import type { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
|
||||
|
||||
/**
|
||||
* Instalment fulfilment: a request for 50 wagons is met with whatever the source
|
||||
* yard can spare, whenever it can spare it. It stays open until the full count
|
||||
* lands or OCC closes it short — which is what tells the requester to go ask
|
||||
* another yard.
|
||||
*/
|
||||
describe('WagonTransferRequestsService — partial fulfilment', () => {
|
||||
const request = (over: Partial<WagonTransferRequest> = {}): WagonTransferRequest =>
|
||||
({
|
||||
id: 'req-1',
|
||||
fromYardId: 'yard-a',
|
||||
toYardId: 'yard-b',
|
||||
wagonTypeId: 'type-1',
|
||||
quantity: 50,
|
||||
fulfilledQuantity: 0,
|
||||
status: WagonTransferRequestStatus.Pending,
|
||||
requestedByUserId: 'user-1',
|
||||
...over,
|
||||
}) as WagonTransferRequest;
|
||||
|
||||
let requestRepo: {
|
||||
findOne: jest.Mock;
|
||||
find: jest.Mock;
|
||||
save: jest.Mock;
|
||||
create: jest.Mock;
|
||||
createQueryBuilder: jest.Mock;
|
||||
};
|
||||
let wagonRepo: { find: jest.Mock; count: jest.Mock };
|
||||
let wagonsService: { bulkTransfer: jest.Mock };
|
||||
let inbox: { notify: jest.Mock };
|
||||
let service: WagonTransferRequestsService;
|
||||
let stored: WagonTransferRequest;
|
||||
|
||||
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
const build = (row: WagonTransferRequest) => {
|
||||
stored = row;
|
||||
requestRepo.findOne.mockImplementation(async () => stored);
|
||||
requestRepo.save.mockImplementation(async (r: WagonTransferRequest) => {
|
||||
stored = r;
|
||||
return r;
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
requestRepo = {
|
||||
findOne: jest.fn(),
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
save: jest.fn(),
|
||||
create: jest.fn((r) => r),
|
||||
createQueryBuilder: jest.fn(),
|
||||
};
|
||||
wagonRepo = { find: jest.fn().mockResolvedValue([]), count: jest.fn() };
|
||||
wagonsService = { bulkTransfer: jest.fn().mockResolvedValue(undefined) };
|
||||
inbox = { notify: jest.fn().mockResolvedValue(undefined) };
|
||||
service = new WagonTransferRequestsService(
|
||||
requestRepo as never,
|
||||
wagonRepo as never,
|
||||
{ find: jest.fn(), findAndCount: jest.fn() } as never,
|
||||
wagonsService as never,
|
||||
inbox as never,
|
||||
);
|
||||
build(request());
|
||||
});
|
||||
|
||||
const availableWagons = (n: number) =>
|
||||
Array.from({ length: n }, (_, i) => ({
|
||||
id: `w-${i}`,
|
||||
wagonNumber: `100${i}`,
|
||||
currentYardId: 'yard-a',
|
||||
wagonTypeId: 'type-1',
|
||||
status: 'AVAILABLE',
|
||||
}));
|
||||
|
||||
describe('fulfillRequest', () => {
|
||||
it('books an instalment and keeps the request open', async () => {
|
||||
wagonRepo.find.mockResolvedValue(availableWagons(20));
|
||||
|
||||
await service.fulfillRequest('req-1', {
|
||||
wagonIds: availableWagons(20).map((w) => w.id),
|
||||
});
|
||||
|
||||
expect(stored.fulfilledQuantity).toBe(20);
|
||||
expect(stored.status).toBe(WagonTransferRequestStatus.PartiallyFulfilled);
|
||||
expect(wagonsService.bulkTransfer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('completes the request when the last instalment lands', async () => {
|
||||
build(request({ fulfilledQuantity: 30, status: WagonTransferRequestStatus.PartiallyFulfilled }));
|
||||
wagonRepo.find.mockResolvedValue(availableWagons(20));
|
||||
|
||||
await service.fulfillRequest('req-1', {
|
||||
wagonIds: availableWagons(20).map((w) => w.id),
|
||||
});
|
||||
|
||||
expect(stored.fulfilledQuantity).toBe(50);
|
||||
expect(stored.status).toBe(WagonTransferRequestStatus.Fulfilled);
|
||||
});
|
||||
|
||||
it('refuses to move more than is still owed', async () => {
|
||||
build(request({ fulfilledQuantity: 45, status: WagonTransferRequestStatus.PartiallyFulfilled }));
|
||||
wagonRepo.find.mockResolvedValue(availableWagons(10));
|
||||
|
||||
await expect(
|
||||
service.fulfillRequest('req-1', {
|
||||
wagonIds: availableWagons(10).map((w) => w.id),
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(wagonsService.bulkTransfer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses to touch a request that is already closed', async () => {
|
||||
build(request({ status: WagonTransferRequestStatus.ClosedShort, fulfilledQuantity: 20 }));
|
||||
|
||||
await expect(
|
||||
service.fulfillRequest('req-1', { wagonIds: ['w-0'] }),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('tells the requester what landed and what is still owed', async () => {
|
||||
wagonRepo.find.mockResolvedValue(availableWagons(20));
|
||||
|
||||
await service.fulfillRequest('req-1', {
|
||||
wagonIds: availableWagons(20).map((w) => w.id),
|
||||
});
|
||||
await flush();
|
||||
|
||||
const sent = inbox.notify.mock.calls[0][0];
|
||||
expect(sent.recipients).toEqual({ userIds: ['user-1'] });
|
||||
expect(sent.body).toContain('20 wagon(s) have arrived');
|
||||
expect(sent.body).toContain('30 of 50 still to come');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulkFulfill', () => {
|
||||
it('sends what the yard has instead of skipping a short request', async () => {
|
||||
wagonRepo.find.mockResolvedValue(availableWagons(20));
|
||||
|
||||
const result = await service.bulkFulfill(['req-1']);
|
||||
|
||||
expect(stored.fulfilledQuantity).toBe(20);
|
||||
expect(stored.status).toBe(WagonTransferRequestStatus.PartiallyFulfilled);
|
||||
expect(result.skipped).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('skips only when the yard has nothing to give', async () => {
|
||||
wagonRepo.find.mockResolvedValue([]);
|
||||
|
||||
const result = await service.bulkFulfill(['req-1']);
|
||||
|
||||
expect(wagonsService.bulkTransfer).not.toHaveBeenCalled();
|
||||
expect(result.skipped[0].reason).toContain('No available wagons');
|
||||
});
|
||||
});
|
||||
|
||||
describe('closeShort', () => {
|
||||
it('ends the request and tells the requester to ask another yard', async () => {
|
||||
build(request({ fulfilledQuantity: 20, status: WagonTransferRequestStatus.PartiallyFulfilled }));
|
||||
|
||||
await service.closeShort('req-1', { note: 'Yard is empty until Friday' });
|
||||
await flush();
|
||||
|
||||
expect(stored.status).toBe(WagonTransferRequestStatus.ClosedShort);
|
||||
expect(stored.closedShortAt).toBeInstanceOf(Date);
|
||||
const sent = inbox.notify.mock.calls[0][0];
|
||||
expect(sent.body).toContain('Only 20 of the 50');
|
||||
expect(sent.body).toContain('Yard is empty until Friday');
|
||||
expect(sent.body).toContain('Request the remaining 30');
|
||||
});
|
||||
|
||||
it('refuses when the request is already fully supplied', async () => {
|
||||
build(request({ fulfilledQuantity: 50, status: WagonTransferRequestStatus.PartiallyFulfilled }));
|
||||
|
||||
await expect(service.closeShort('req-1', {})).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancelRequest', () => {
|
||||
it('withdraws a request that never moved a wagon', async () => {
|
||||
await service.cancelRequest('req-1');
|
||||
expect(stored.status).toBe(WagonTransferRequestStatus.Cancelled);
|
||||
});
|
||||
|
||||
it('refuses once wagons have moved — close it short instead', async () => {
|
||||
build(request({ fulfilledQuantity: 20, status: WagonTransferRequestStatus.PartiallyFulfilled }));
|
||||
|
||||
await expect(service.cancelRequest('req-1')).rejects.toThrow(
|
||||
/close it short/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createRequest', () => {
|
||||
it('accepts a count larger than what the yard holds today', async () => {
|
||||
wagonRepo.count.mockResolvedValue(20);
|
||||
|
||||
await service.createRequest(
|
||||
{
|
||||
fromYardId: 'yard-a',
|
||||
toYardId: 'yard-b',
|
||||
wagonTypeId: 'type-1',
|
||||
quantity: 50,
|
||||
reason: 'Grain campaign',
|
||||
},
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(requestRepo.save).toHaveBeenCalled();
|
||||
expect(stored.quantity).toBe(50);
|
||||
});
|
||||
|
||||
it('still refuses a same-yard move', async () => {
|
||||
await expect(
|
||||
service.createRequest(
|
||||
{
|
||||
fromYardId: 'yard-a',
|
||||
toYardId: 'yard-a',
|
||||
wagonTypeId: 'type-1',
|
||||
quantity: 5,
|
||||
reason: 'x',
|
||||
},
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,27 @@
|
||||
import { WagonStatus, WagonTransferRequestStatus } from '@edr/types';
|
||||
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';
|
||||
@@ -19,10 +31,21 @@ import { WagonsService } from './wagons.service';
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
/** How many ledger rows the history returns at most (newest first). */
|
||||
const HISTORY_LIMIT = 500;
|
||||
/** Hard ceiling on a single history page, whatever the client asks for. */
|
||||
const HISTORY_LIMIT = 100;
|
||||
|
||||
const REQUEST_RELATIONS = {
|
||||
fromYard: true,
|
||||
@@ -38,6 +61,8 @@ const REQUEST_RELATIONS = {
|
||||
*/
|
||||
@Injectable()
|
||||
export class WagonTransferRequestsService {
|
||||
private readonly logger = new Logger(WagonTransferRequestsService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(WagonTransferRequest)
|
||||
private readonly requestRepo: Repository<WagonTransferRequest>,
|
||||
@@ -46,13 +71,14 @@ export class WagonTransferRequestsService {
|
||||
@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, 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.
|
||||
* 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,
|
||||
@@ -63,14 +89,6 @@ export class WagonTransferRequestsService {
|
||||
'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,
|
||||
@@ -85,8 +103,12 @@ export class WagonTransferRequestsService {
|
||||
return this.findById(saved.id);
|
||||
}
|
||||
|
||||
/** AVAILABLE wagons of `wagonTypeId` currently in `yardId`. */
|
||||
private countAvailable(yardId: string, wagonTypeId: string): Promise<number> {
|
||||
/**
|
||||
* 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,
|
||||
@@ -96,15 +118,51 @@ export class WagonTransferRequestsService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Requests, newest first, optionally filtered by status (OCC queue = PENDING). */
|
||||
/**
|
||||
* 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(
|
||||
status?: WagonTransferRequestStatus,
|
||||
): Promise<WagonTransferRequest[]> {
|
||||
return this.requestRepo.find({
|
||||
where: status ? { status } : {},
|
||||
relations: REQUEST_RELATIONS,
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
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> {
|
||||
@@ -116,11 +174,23 @@ export class WagonTransferRequestsService {
|
||||
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 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.
|
||||
* 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,
|
||||
@@ -128,16 +198,17 @@ export class WagonTransferRequestsService {
|
||||
userId?: string | null,
|
||||
): Promise<WagonTransferRequest> {
|
||||
const request = await this.findById(id);
|
||||
if (request.status !== WagonTransferRequestStatus.Pending) {
|
||||
if (!this.isOpen(request)) {
|
||||
throw new ConflictException(
|
||||
`Request is already ${request.status.toLowerCase()}`,
|
||||
`Request is already ${request.status.toLowerCase().replace(/_/g, ' ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const wagonIds = [...new Set(dto.wagonIds)];
|
||||
if (wagonIds.length !== request.quantity) {
|
||||
const remaining = this.remainingOn(request);
|
||||
if (wagonIds.length > remaining) {
|
||||
throw new BadRequestException(
|
||||
`Select exactly ${request.quantity} wagon(s); you selected ${wagonIds.length}`,
|
||||
`Only ${remaining} wagon(s) still owed on this request; you selected ${wagonIds.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -178,21 +249,124 @@ export class WagonTransferRequestsService {
|
||||
{ transferRequestId: request.id },
|
||||
);
|
||||
|
||||
request.status = WagonTransferRequestStatus.Fulfilled;
|
||||
request.fulfilledByUserId = userId ?? null;
|
||||
request.fulfilledAt = new Date();
|
||||
await this.requestRepo.save(request);
|
||||
await this.recordDelivery(request, wagonIds.length, userId);
|
||||
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.
|
||||
* 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[],
|
||||
@@ -212,13 +386,14 @@ export class WagonTransferRequestsService {
|
||||
skipped.push({ id, reason: 'Request not found' });
|
||||
continue;
|
||||
}
|
||||
if (request.status !== WagonTransferRequestStatus.Pending) {
|
||||
if (!this.isOpen(request)) {
|
||||
skipped.push({
|
||||
id,
|
||||
reason: `Already ${request.status.toLowerCase()}`,
|
||||
reason: `Already ${request.status.toLowerCase().replace(/_/g, ' ')}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const remaining = this.remainingOn(request);
|
||||
const wagons = await this.wagonRepo.find({
|
||||
where: {
|
||||
currentYardId: request.fromYardId,
|
||||
@@ -226,12 +401,12 @@ export class WagonTransferRequestsService {
|
||||
status: WagonStatus.Available,
|
||||
},
|
||||
order: { wagonNumber: 'ASC' },
|
||||
take: request.quantity,
|
||||
take: remaining,
|
||||
});
|
||||
if (wagons.length < request.quantity) {
|
||||
if (wagons.length === 0) {
|
||||
skipped.push({
|
||||
id,
|
||||
reason: `Only ${wagons.length} of ${request.quantity} wagon(s) available in the source yard — left pending`,
|
||||
reason: 'No available wagons of this type in the source yard — left open',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -240,10 +415,7 @@ export class WagonTransferRequestsService {
|
||||
userId,
|
||||
{ transferRequestId: request.id },
|
||||
);
|
||||
request.status = WagonTransferRequestStatus.Fulfilled;
|
||||
request.fulfilledByUserId = userId ?? null;
|
||||
request.fulfilledAt = new Date();
|
||||
await this.requestRepo.save(request);
|
||||
await this.recordDelivery(request, wagons.length, userId);
|
||||
fulfilled.push(await this.findById(id));
|
||||
}
|
||||
|
||||
@@ -258,17 +430,25 @@ export class WagonTransferRequestsService {
|
||||
* (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({
|
||||
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' },
|
||||
take: HISTORY_LIMIT,
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
|
||||
const movements = await this.movementRepo.find({
|
||||
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
|
||||
@@ -276,18 +456,39 @@ export class WagonTransferRequestsService {
|
||||
: { movedByUserId: Not(IsNull()) },
|
||||
relations: { wagon: true, fromYard: true, toYard: true, transferRequest: true },
|
||||
order: { occurredAt: 'DESC' },
|
||||
take: HISTORY_LIMIT,
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
|
||||
return { requests, movements };
|
||||
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 still-PENDING request. */
|
||||
/**
|
||||
* 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(
|
||||
`Only pending requests can be cancelled (this one is ${request.status.toLowerCase()})`,
|
||||
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;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { WagonMovement } from './entities/wagon-movement.entity';
|
||||
import { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
|
||||
import { Train } from '../trains/entities/train.entity';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
|
||||
import { WagonsController } from './wagons.controller';
|
||||
import { WagonTransferRequestsController } from './wagon-transfer-requests.controller';
|
||||
import { WagonsService } from './wagons.service';
|
||||
@@ -19,6 +20,8 @@ import { WagonTransferRequestsService } from './wagon-transfer-requests.service'
|
||||
Train,
|
||||
Yard,
|
||||
]),
|
||||
// The transfer desk notifies the requester as instalments land.
|
||||
NotificationInboxModule,
|
||||
],
|
||||
controllers: [
|
||||
WagonsController,
|
||||
|
||||
Reference in New Issue
Block a user