mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 12:41:04 +00:00
- Added functionality to cancel contracts, allowing users to provide a reason for cancellation. - Updated contract statuses to include SUSPENDED and changed CLOSED to COMPLETED. - Enhanced the UI to reflect the new cancellation option and updated messaging for contract statuses. - Refactored contract booking actions to accommodate changes in booking logic for ONE_TIME and GENERAL contracts. - Removed clearance document management from the contract detail page, as it is now handled per booking. - Introduced a SQL script to reset bookings and train schedules for development purposes.
216 lines
8.5 KiB
TypeScript
216 lines
8.5 KiB
TypeScript
import { BadRequestException, ConflictException } from '@nestjs/common';
|
|
import { BookingTransitionService } from './booking-transition.service';
|
|
|
|
/**
|
|
* Operation-request review for general-contract drawdown orders:
|
|
* - ACCEPT a train order → FULLY_EXECUTED with the invoice ensured; import/
|
|
* domestic bookings wait for their booking-day window cycle (no immediate
|
|
* batch enqueue at accept time).
|
|
* - ACCEPT a road order → ROAD_DISPATCH_PENDING, never enters the train batch.
|
|
* - REQUEST_CHANGES requires a note → OPERATION_CHANGES_REQUESTED.
|
|
*/
|
|
describe('BookingTransitionService — operation review', () => {
|
|
function makeService(serviceTypeCode: string) {
|
|
const booking = {
|
|
id: 'b-1',
|
|
reference: 'BKG-1',
|
|
status: 'OPERATION_REQUEST_PENDING',
|
|
originYardId: 'o-1',
|
|
destinationYardId: 'd-1',
|
|
scheduledDate: new Date('2026-07-01T00:00:00.000Z'),
|
|
serviceType: { code: serviceTypeCode },
|
|
};
|
|
const bookingsRepository = {
|
|
update: jest.fn().mockResolvedValue({ id: 'b-1' }),
|
|
createReviewNote: jest.fn().mockResolvedValue(undefined),
|
|
};
|
|
const bookingsService = {
|
|
findById: jest.fn().mockResolvedValue(booking),
|
|
};
|
|
const bookingBatchService = {
|
|
enqueueRouteDayProcessing: jest.fn(),
|
|
pickExportSchedule: jest.fn(),
|
|
acceptExportBooking: jest.fn(),
|
|
};
|
|
const invoiceService = {
|
|
ensureInvoiceForBooking: jest
|
|
.fn()
|
|
.mockResolvedValue({ id: 'inv-1', invoiceNumber: 'INV-0001' }),
|
|
updateStatus: jest.fn().mockResolvedValue(undefined),
|
|
};
|
|
|
|
const service = new BookingTransitionService(
|
|
bookingsRepository as never,
|
|
{} as never, // ruleEngineService
|
|
{} as never, // pricingService
|
|
{} as never, // contractService
|
|
{} as never, // filesService
|
|
{} as never, // fileUploadSettingsService
|
|
bookingBatchService as never,
|
|
bookingsService as never,
|
|
{ isPhasedCustomsBooking: () => false } as never,
|
|
{} as never, // workflowService
|
|
invoiceService as never,
|
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
|
{
|
|
accepted: jest.fn(),
|
|
approved: jest.fn(),
|
|
rejected: jest.fn(),
|
|
changesRequested: jest.fn(),
|
|
documentQueried: jest.fn(),
|
|
clearanceReady: jest.fn(),
|
|
operationChangesRequested: jest.fn(),
|
|
operationAccepted: jest.fn(),
|
|
inTransit: jest.fn(),
|
|
completed: jest.fn(),
|
|
cancelled: jest.fn(),
|
|
submittedToStaff: jest.fn(),
|
|
customerSignedToStaff: jest.fn(),
|
|
operationRequestedToStaff: jest.fn(),
|
|
clearanceDocsUploadedToStaff: jest.fn(),
|
|
dutySlipUploadedToStaff: jest.fn(),
|
|
} as never, // notifier
|
|
{ emit: jest.fn() } as never, // events
|
|
);
|
|
return { service, bookingsRepository, bookingBatchService, invoiceService };
|
|
}
|
|
|
|
it('ACCEPT of a train order → FULLY_EXECUTED, invoice ensured, batch waits for window cycle', async () => {
|
|
const { service, bookingsRepository, bookingBatchService, invoiceService } =
|
|
makeService('RAIL_CONTAINER');
|
|
await service.reviewOperationRequest('b-1', 'ACCEPT', 'staff-1');
|
|
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
|
'b-1',
|
|
expect.objectContaining({ status: 'FULLY_EXECUTED' }),
|
|
);
|
|
expect(invoiceService.ensureInvoiceForBooking).toHaveBeenCalledTimes(1);
|
|
// Import/domestic train bookings are batched by the window cycle later —
|
|
// never enqueued directly at accept time.
|
|
expect(bookingBatchService.enqueueRouteDayProcessing).not.toHaveBeenCalled();
|
|
expect(bookingBatchService.acceptExportBooking).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('ACCEPT of a road order → ROAD_DISPATCH_PENDING and does NOT enter the batch', async () => {
|
|
const { service, bookingsRepository, bookingBatchService, invoiceService } =
|
|
makeService('ROAD_CONTAINER');
|
|
await service.reviewOperationRequest('b-1', 'ACCEPT', 'staff-1');
|
|
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
|
'b-1',
|
|
expect.objectContaining({ status: 'ROAD_DISPATCH_PENDING' }),
|
|
);
|
|
expect(invoiceService.ensureInvoiceForBooking).toHaveBeenCalledTimes(1);
|
|
expect(bookingBatchService.enqueueRouteDayProcessing).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('REQUEST_CHANGES requires a note → OPERATION_CHANGES_REQUESTED', async () => {
|
|
const { service, bookingsRepository } = makeService('RAIL_CONTAINER');
|
|
await expect(
|
|
service.reviewOperationRequest('b-1', 'REQUEST_CHANGES', 'staff-1', {}),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
|
|
await service.reviewOperationRequest('b-1', 'REQUEST_CHANGES', 'staff-1', {
|
|
note: 'Fix the schedule',
|
|
});
|
|
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
|
'b-1',
|
|
expect.objectContaining({ status: 'OPERATION_CHANGES_REQUESTED' }),
|
|
);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Export over-book gate at the customer's requestOperation step: export never
|
|
* splits, so the free-space check runs the moment the customer commits to a
|
|
* shipment day. When no single export train that day can carry the whole
|
|
* booking, `pickExportSchedule` throws and the request is refused BEFORE the
|
|
* booking moves to OPERATION_REQUEST_PENDING. Import bookings are never gated
|
|
* here (they are batched + splittable later).
|
|
*/
|
|
describe('BookingTransitionService — requestOperation export space gate', () => {
|
|
function makeService(tradeDirection: 'EXPORT' | 'IMPORT', overbook: boolean) {
|
|
const booking = {
|
|
id: 'b-1',
|
|
reference: 'BKG-1',
|
|
status: 'CLEARANCE_READY',
|
|
tradeDirection,
|
|
originYardId: 'o-1',
|
|
destinationYardId: 'd-1',
|
|
totalAmount: 1000,
|
|
contractId: null,
|
|
serviceType: { code: 'RAIL_CONTAINER' },
|
|
};
|
|
const bookingsRepository = {
|
|
update: jest.fn().mockResolvedValue({ id: 'b-1' }),
|
|
};
|
|
const bookingsService = {
|
|
findById: jest.fn().mockResolvedValue(booking),
|
|
checkDayCompatibilityForBooking: jest
|
|
.fn()
|
|
.mockResolvedValue({ hasDeparture: true, hasCompatible: true }),
|
|
};
|
|
const bookingBatchService = {
|
|
// Over-book → the export gate rejects; otherwise it returns a schedule id.
|
|
pickExportSchedule: overbook
|
|
? jest.fn().mockRejectedValue(new ConflictException('Not enough train space'))
|
|
: jest.fn().mockResolvedValue('sched-1'),
|
|
};
|
|
const notifier = { operationRequestedToStaff: jest.fn() };
|
|
|
|
const service = new BookingTransitionService(
|
|
bookingsRepository as never,
|
|
{} as never, // ruleEngineService
|
|
{} as never, // pricingService
|
|
{} as never, // contractService
|
|
{} as never, // filesService
|
|
{} as never, // fileUploadSettingsService
|
|
bookingBatchService as never,
|
|
bookingsService as never,
|
|
{ isPhasedCustomsBooking: () => false } as never,
|
|
{} as never, // workflowService
|
|
{} as never, // invoiceService
|
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
|
notifier as never,
|
|
{ emit: jest.fn() } as never, // events
|
|
);
|
|
return { service, bookingsRepository, bookingBatchService };
|
|
}
|
|
|
|
it('rejects an over-booked export request and does NOT advance the booking', async () => {
|
|
const { service, bookingsRepository, bookingBatchService } = makeService(
|
|
'EXPORT',
|
|
true,
|
|
);
|
|
await expect(
|
|
service.requestOperation('b-1', '2026-07-20T00:00:00.000Z'),
|
|
).rejects.toBeInstanceOf(ConflictException);
|
|
expect(bookingBatchService.pickExportSchedule).toHaveBeenCalledTimes(1);
|
|
expect(bookingsRepository.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('lets an export request through when a train fits the whole booking', async () => {
|
|
const { service, bookingsRepository, bookingBatchService } = makeService(
|
|
'EXPORT',
|
|
false,
|
|
);
|
|
await service.requestOperation('b-1', '2026-07-20T00:00:00.000Z');
|
|
expect(bookingBatchService.pickExportSchedule).toHaveBeenCalledTimes(1);
|
|
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
|
'b-1',
|
|
expect.objectContaining({ status: 'OPERATION_REQUEST_PENDING' }),
|
|
);
|
|
});
|
|
|
|
it('never runs the export gate for an import request', async () => {
|
|
const { service, bookingsRepository, bookingBatchService } = makeService(
|
|
'IMPORT',
|
|
true, // would reject IF called — proves it is not called
|
|
);
|
|
await service.requestOperation('b-1', '2026-07-20T00:00:00.000Z');
|
|
expect(bookingBatchService.pickExportSchedule).not.toHaveBeenCalled();
|
|
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
|
'b-1',
|
|
expect.objectContaining({ status: 'OPERATION_REQUEST_PENDING' }),
|
|
);
|
|
});
|
|
});
|