feat(wagons): enforce wagon availability limits in transfer requests

This commit is contained in:
marshalyordanos
2026-08-10 23:30:18 +03:00
parent c743750ef6
commit 997998dcb2
5 changed files with 127 additions and 18 deletions

View File

@@ -14,7 +14,8 @@ import {
* A count-only wagon-transfer request. The requester picks source yard, wagon
* type, destination yard and HOW MANY — never the specific wagons; OCC hand-picks
* those at fulfilment. The quantity may not exceed the AVAILABLE wagons of that
* type currently in the source yard, and a reason is mandatory.
* type currently in the source yard (enforced in the service, which is the only
* layer that can count them), and a reason is mandatory.
*/
export class CreateTransferRequestDto {
@IsUUID()

View File

@@ -199,7 +199,7 @@ describe('WagonTransferRequestsService — partial fulfilment', () => {
});
describe('createRequest', () => {
it('accepts a count larger than what the yard holds today', async () => {
it('accepts a count up to what the yard holds today', async () => {
wagonRepo.count.mockResolvedValue(20);
await service.createRequest(
@@ -207,14 +207,50 @@ describe('WagonTransferRequestsService — partial fulfilment', () => {
fromYardId: 'yard-a',
toYardId: 'yard-b',
wagonTypeId: 'type-1',
quantity: 50,
quantity: 20,
reason: 'Grain campaign',
},
'user-1',
);
expect(requestRepo.save).toHaveBeenCalled();
expect(stored.quantity).toBe(50);
expect(stored.quantity).toBe(20);
});
it('refuses a count larger than what the yard holds today', async () => {
wagonRepo.count.mockResolvedValue(20);
await expect(
service.createRequest(
{
fromYardId: 'yard-a',
toYardId: 'yard-b',
wagonTypeId: 'type-1',
quantity: 50,
reason: 'Grain campaign',
},
'user-1',
),
).rejects.toThrow(/only 20 wagon\(s\).*available/i);
expect(requestRepo.save).not.toHaveBeenCalled();
});
it('refuses when the yard has nothing of that type available', async () => {
wagonRepo.count.mockResolvedValue(0);
await expect(
service.createRequest(
{
fromYardId: 'yard-a',
toYardId: 'yard-b',
wagonTypeId: 'type-1',
quantity: 1,
reason: 'Grain campaign',
},
'user-1',
),
).rejects.toThrow(/no available wagons/i);
expect(requestRepo.save).not.toHaveBeenCalled();
});
it('still refuses a same-yard move', async () => {

View File

@@ -75,10 +75,11 @@ export class WagonTransferRequestsService {
) {}
/**
* 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.
* Record a PENDING request. Count-only — no wagons are picked here, but the
* count IS capped by what the source yard can hand over right now: a request
* may not exceed the AVAILABLE, uncoupled wagons of that type in the source
* yard (the same number the yard desk shows). A reason is mandatory and is
* shown on the OCC queue.
*/
async createRequest(
dto: CreateTransferRequestDto,
@@ -89,6 +90,20 @@ export class WagonTransferRequestsService {
'Source and destination yard must be different',
);
}
const available = await this.countAvailable(
dto.fromYardId,
dto.wagonTypeId,
);
if (available === 0) {
throw new BadRequestException(
'No available wagons of this type in the source yard',
);
}
if (dto.quantity > available) {
throw new BadRequestException(
`Only ${available} wagon(s) of this type are available in the source yard — cannot request ${dto.quantity}`,
);
}
const request = this.requestRepo.create({
fromYardId: dto.fromYardId,
toYardId: dto.toYardId,