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:
Marshal
2026-07-26 15:11:50 +00:00
parent 9a1c8e5603
commit 9b13fa2ac6
40 changed files with 2584 additions and 809 deletions

View File

@@ -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);
});
});
});