mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 13:05:44 +00:00
- 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.
202 lines
6.7 KiB
TypeScript
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();
|
|
});
|
|
});
|