mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +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,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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user