Files
edr-platform/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts
Marshal ba56974e32 feat(train): enhance train history and scheduling features
- Added a reason field to train history entries for detach/maintenance actions.
- Updated TrainHistoryPanel to display the reason for wagon detachments.
- Introduced per-wagon load/unload functionality in ScheduleWorkspacePanel with a modal for managing individual wagons.
- Implemented API endpoints for loading and unloading specific wagons, including the ability to cancel remaining wagons with a reason.
- Refactored detach request handling in TrainBuilderDetailPage to streamline the process and remove the approval flow, requiring a reason for detachments.
- Updated types and services to support new wagon loading/unloading features and booking wagon retrieval.
2026-08-28 07:33:28 +00:00

202 lines
6.7 KiB
TypeScript

import { BadRequestException } from '@nestjs/common';
import { BookingWagonCancellationService } from './booking-wagon-cancellation.service';
import {
bulkTonWagonsRequired,
bulkTonsPerWagonFor,
} from '../train-scheduling/train-capacity.util';
/**
* Sizing of a bulk quantity cut (no DB touched on this branch): a whole-booking
* cut is allowed and takes the exact cargo total; over-cut is rejected; a
* partial cut stays proportional.
*/
describe('BookingWagonCancellationService.resolveRequestedCut (bulk)', () => {
const svc = Object.create(BookingWagonCancellationService.prototype) as {
resolveRequestedCut(booking: unknown, dto: unknown): Promise<{
wagons: number;
weightTons: number;
quantities: { bulkTons?: number };
}>;
};
const booking = {
id: 'b1',
freightType: 'BULK',
wagonsRequired: 4,
cargoTotalWeightVgm: 250.5,
bulkTotalWeightTons: null,
};
it('cancels every wagon with the exact total tonnage', async () => {
const cut = await svc.resolveRequestedCut(booking, { wagons: 4 });
expect(cut).toEqual({ wagons: 4, weightTons: 250.5, quantities: { bulkTons: 250.5 } });
});
it('rejects more wagons than the booking has', async () => {
await expect(svc.resolveRequestedCut(booking, { wagons: 5 })).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('sizes a partial cut proportionally', async () => {
const cut = await svc.resolveRequestedCut(booking, { wagons: 1 });
expect(cut.wagons).toBe(1);
expect(cut.weightTons).toBeCloseTo(62.625, 3);
});
});
/**
* Odd-20ft credit rebook: the rebooked booking shares a wagon again, so GL
* must pick the consolidation partner — no partner, no rebook; a partner
* already paired elsewhere is refused.
*/
describe('BookingWagonCancellationService.rebook (odd-20ft consolidation)', () => {
const units = Array.from({ length: 3 }, (_, i) => ({
containerSize: '20ft',
containerNumber: `CONT${i}`,
sealNumber: null,
vgmTons: 10,
isHazardous: false,
isReefer: false,
}));
const row = {
id: 'wc1',
bookingId: 'b1',
status: 'CREDIT_AVAILABLE',
creditAmount: 100,
cancelledQuantities: { bySize: { '20ft': 3 }, units },
};
const source = {
id: 'b1',
contractId: 'c1',
paymentCurrency: 'USD',
originYardId: 'y1',
destinationYardId: 'y2',
tradeDirection: 'IMPORT',
};
const makeSvc = (partner?: unknown) => {
const svc = Object.create(BookingWagonCancellationService.prototype) as Record<
string,
unknown
> & {
rebook(id: string, dto: unknown): Promise<unknown>;
};
svc.repo = { findById: async () => row };
svc.bookingsRepository = {
findById: async () => source,
findByIdWithFiles: async () => partner ?? null,
};
return svc;
};
it('refuses an odd-20ft rebook without a GL-picked partner', async () => {
await expect(
makeSvc().rebook('wc1', { scheduledDate: '2026-09-01' }),
).rejects.toThrow(/pick a consolidation partner/i);
});
it('refuses a partner that already shares a wagon', async () => {
const paired = {
id: 'p1',
reference: 'BK-1',
status: 'SUBMITTED',
consolidationPartnerId: 'someone-else',
};
await expect(
makeSvc(paired).rebook('wc1', {
scheduledDate: '2026-09-01',
partnerBookingId: 'p1',
}),
).rejects.toThrow(/already shares a wagon/i);
});
});
/**
* A NUMBER_OF_WAGONS booking pins its count in `bulkRequestedWagons`, and
* bulkTonWagonsRequired honours that verbatim. Partial cancel must shrink it
* alongside wagonsRequired/cargoTotalWeightVgm — left stale, the booking
* re-inflates to its pre-cancel count on the next allocation and each wagon
* carries tons / stale-count instead of the real even share.
*/
describe('partial cancel of a NUMBER_OF_WAGONS bulk booking', () => {
// 980T over 14 wagons (70T each), 2 wagons cancelled.
const before = { freightType: 'BULK', cargoTotalWeightVgm: 980, bulkRequestedWagons: 14 };
const droppedWeight = 140;
const wagonsCancelled = 2;
// The decrement applied in applyPaidCut's booking update.
const after = {
...before,
cargoTotalWeightVgm: before.cargoTotalWeightVgm - droppedWeight,
bulkRequestedWagons: Math.max(
0,
Math.floor(before.bulkRequestedWagons - wagonsCancelled),
),
};
it('reallocates at the reduced count, not the pre-cancel one', () => {
expect(bulkTonWagonsRequired(before, undefined, 'nw5', 70)).toBe(14);
expect(bulkTonWagonsRequired(after, undefined, 'nw5', 70)).toBe(12);
});
it('keeps tons-per-wagon at the real even share', () => {
// Stale count would spread 840T over 14 wagons → 60T each.
expect(bulkTonsPerWagonFor(after, undefined, 'nw5', 70)).toBe(70);
});
it('cancelling every wagon leaves no requested count behind', () => {
const all = Math.max(0, Math.floor(before.bulkRequestedWagons - 14));
expect(all).toBe(0);
expect(bulkTonWagonsRequired(
{ ...before, cargoTotalWeightVgm: 0, bulkRequestedWagons: all },
undefined,
'nw5',
70,
)).toBe(0);
});
});
/**
* Rebooking a NUMBER_OF_WAGONS bulk credit: the create path rejects the rebook
* unless the DTO carries a wagon count ("<cargo> is booked by wagons — enter
* the number of wagons needed"), and the quantities snapshot holds tons only.
* The count therefore has to come off the cancellation row itself.
*/
describe('BookingWagonCancellationService.buildRebookDto (bulk wagon count)', () => {
const svc = Object.create(BookingWagonCancellationService.prototype) as {
buildRebookDto(
row: unknown,
scheduledDate: string,
overrides?: unknown,
): { bulkLines?: { cargoWeightTons: number }[]; requestedWagons?: number };
};
it('carries the cancelled wagon count onto the rebook', () => {
const dto = svc.buildRebookDto(
{ wagonsCancelled: 2, weightTons: 140, cancelledQuantities: { bulkTons: 140 } },
'2026-09-10',
);
expect(dto.bulkLines).toEqual([{ cargoWeightTons: 140 }]);
// Without this the create path throws before the booking is ever made.
expect(dto.requestedWagons).toBe(2);
});
it('rounds a fractional cut up to a whole wagon', () => {
const dto = svc.buildRebookDto(
{ wagonsCancelled: 0.5, weightTons: 35, cancelledQuantities: { bulkTons: 35 } },
'2026-09-10',
);
// Flooring would send 0 into a check that demands >= 1.
expect(dto.requestedWagons).toBe(1);
});
it('leaves the count off when nothing was cancelled', () => {
const dto = svc.buildRebookDto(
{ wagonsCancelled: 0, weightTons: 0, cancelledQuantities: { bulkTons: 12 } },
'2026-09-10',
);
expect(dto.requestedWagons).toBeUndefined();
});
});