mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
- Added support for viewing and managing consolidated bookings in BookingRequestDetailPage. - Enhanced BookingRequestsPage to display paired bookings in a single row. - Introduced pairedDecision method in bookings service to handle decisions for both halves of a consolidated pair. - Updated contracts service to include methods for manual consolidation of odd-20ft bookings. - Created new components for selecting and editing consolidation partners. - Added tests for paired decision logic and manual consolidation scenarios. - Updated UI to reflect changes in booking handling and provide user feedback for odd container counts.
133 lines
4.7 KiB
TypeScript
133 lines
4.7 KiB
TypeScript
import { BookingTransitionService } from './booking-transition.service';
|
|
import { Booking } from './entities/booking.entity';
|
|
|
|
/**
|
|
* Staff decisions on a consolidated pair. Two bookings sharing a wagon must move
|
|
* together: accepting one alone would put half a wagon into the approval chain,
|
|
* and cancelling one alone would strand the other on a wagon it can no longer
|
|
* fill. All-or-nothing — if either half throws, neither booking moved.
|
|
*/
|
|
describe('BookingTransitionService — paired staff decisions', () => {
|
|
function makeService(booking: Partial<Booking>) {
|
|
const bookingsService = {
|
|
findById: jest.fn().mockResolvedValue(booking as Booking),
|
|
};
|
|
// Runs the callback so a throw propagates, which is what the all-or-nothing
|
|
// guarantee reduces to from this service's point of view.
|
|
const dataSource = {
|
|
transaction: jest.fn(async (cb: () => Promise<unknown>) => cb()),
|
|
};
|
|
|
|
const service = new BookingTransitionService(
|
|
{} as never, // bookingsRepository
|
|
{} as never, // ruleEngineService
|
|
{} as never, // pricingService
|
|
{} as never, // contractService
|
|
{} as never, // filesService
|
|
{} as never, // fileUploadSettingsService
|
|
{} as never, // bookingBatchService
|
|
bookingsService as never,
|
|
{} as never, // bookingClearanceService
|
|
{} as never, // workflowService
|
|
{} as never, // invoiceService
|
|
{} as never, // containerValidationService
|
|
{} as never, // notifier
|
|
{} as never, // events
|
|
undefined, // milestoneService
|
|
dataSource as never,
|
|
);
|
|
return { service, dataSource };
|
|
}
|
|
|
|
const paired = {
|
|
id: 'b-1',
|
|
reference: 'BK-1',
|
|
consolidationPartnerId: 'b-2',
|
|
} as Booking;
|
|
|
|
it('accepts both halves with the same validity window', async () => {
|
|
const { service } = makeService(paired);
|
|
const accept = jest
|
|
.spyOn(service, 'acceptIntake')
|
|
.mockImplementation(async (id) => ({ id }) as Booking);
|
|
|
|
const result = await service.applyPairedDecision('b-1', 'accept', 'staff-1', {
|
|
validityDays: 30,
|
|
});
|
|
|
|
expect(accept).toHaveBeenCalledTimes(2);
|
|
expect(accept).toHaveBeenNthCalledWith(1, 'b-1', 'staff-1', 30);
|
|
expect(accept).toHaveBeenNthCalledWith(2, 'b-2', 'staff-1', 30);
|
|
expect(result.booking.id).toBe('b-1');
|
|
expect(result.partner.id).toBe('b-2');
|
|
});
|
|
|
|
it('cancels both halves with the same reason', async () => {
|
|
const { service } = makeService(paired);
|
|
const cancel = jest
|
|
.spyOn(service, 'cancel')
|
|
.mockImplementation(async (id) => ({ id }) as Booking);
|
|
|
|
await service.applyPairedDecision('b-1', 'cancel', 'staff-1', {
|
|
reason: 'customer withdrew',
|
|
});
|
|
|
|
expect(cancel).toHaveBeenNthCalledWith(1, 'b-1', 'customer withdrew');
|
|
expect(cancel).toHaveBeenNthCalledWith(2, 'b-2', 'customer withdrew');
|
|
});
|
|
|
|
it('propagates a failure on the second half so neither is committed', async () => {
|
|
const { service, dataSource } = makeService(paired);
|
|
jest
|
|
.spyOn(service, 'cancel')
|
|
.mockImplementationOnce(async (id) => ({ id }) as Booking)
|
|
.mockImplementationOnce(async () => {
|
|
throw new Error('partner is already in transit');
|
|
});
|
|
|
|
await expect(
|
|
service.applyPairedDecision('b-1', 'cancel', 'staff-1', { reason: 'x' }),
|
|
).rejects.toThrow('partner is already in transit');
|
|
|
|
// Both halves ran inside one transaction, so the throw rolls the first back.
|
|
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('refuses a booking that has no partner', async () => {
|
|
const { service } = makeService({
|
|
id: 'b-1',
|
|
consolidationPartnerId: null,
|
|
} as Booking);
|
|
|
|
await expect(
|
|
service.applyPairedDecision('b-1', 'cancel', 'staff-1', { reason: 'x' }),
|
|
).rejects.toThrow(/no consolidation partner/i);
|
|
});
|
|
|
|
it('requires a validity window to accept', async () => {
|
|
const { service } = makeService(paired);
|
|
const accept = jest.spyOn(service, 'acceptIntake');
|
|
|
|
await expect(
|
|
service.applyPairedDecision('b-1', 'accept', 'staff-1', {}),
|
|
).rejects.toThrow(/validity/i);
|
|
expect(accept).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('routes operationAccept through the operation review on both halves', async () => {
|
|
const { service } = makeService(paired);
|
|
const review = jest
|
|
.spyOn(service, 'reviewOperationRequest')
|
|
.mockImplementation(async (id) => ({ id }) as Booking);
|
|
|
|
await service.applyPairedDecision('b-1', 'operationAccept', 'staff-1', {});
|
|
|
|
expect(review).toHaveBeenNthCalledWith(1, 'b-1', 'ACCEPT', 'staff-1', {
|
|
note: undefined,
|
|
});
|
|
expect(review).toHaveBeenNthCalledWith(2, 'b-2', 'ACCEPT', 'staff-1', {
|
|
note: undefined,
|
|
});
|
|
});
|
|
});
|