Files
edr-platform/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts
Marshal 40904049cf feat: implement consolidation approval process for shared-wagon bookings
- Add migration for consolidation approvals table and status enum
- Create ConsolidationApprovalService to handle approval logic
- Implement repository for managing consolidation approvals
- Add entity for consolidation approval with necessary fields
- Develop frontend components for displaying and managing consolidation approvals
- Create tests for consolidation approval service to ensure correct behavior
2026-08-18 13:17:55 +00:00

230 lines
7.9 KiB
TypeScript

import { BadRequestException } from '@nestjs/common';
import { ContractBookingService } from './contract-booking.service';
import { Contract } from './entities/contract.entity';
/**
* Contract auto-completion by quantity cap. Once a GENERAL contract's capped
* scope is fully consumed (e.g. a split remainder rebooked), the contract moves
* to CONTRACT_CLOSED even inside its validity window, and further bookings are
* blocked — including while a booking window is open. Released capacity
* (cancelled/expired booking) reopens the contract on the next attempt.
*/
describe('ContractBookingService — quantity-cap completion', () => {
function makeService() {
const contractsRepository = {
findByIdWithRelations: jest.fn(),
update: jest.fn().mockResolvedValue(undefined),
};
const service = new ContractBookingService(
contractsRepository as never,
{} as never, // bookingsRepository
{} as never, // bookingPricingService
{} as never, // consolidationService
{} as never, // containerTypesService
{} as never, // ruleEngineService
{} as never, // milestoneService
{} as never, // invoiceService
{ createdToStaff: jest.fn() } as never, // bookingNotifier
{} as never, // dataSource
{} as never, // trainSchedulingService
{} as never, // bookingBatchService
{} as never, // bookingTransitionService
{} as never, // consolidationApprovalService
);
return { service, contractsRepository };
}
type WithPrivate = {
maybeCompleteContract: (c: Contract) => Promise<void>;
};
const generalContract = (status: string): Contract =>
({
id: 'c-1',
reference: 'CTR-1',
contractKind: 'GENERAL',
status,
}) as Contract;
it('closes a GENERAL contract when every capped line is exhausted', async () => {
const { service, contractsRepository } = makeService();
jest.spyOn(service, 'computeCapacity').mockResolvedValue([
{ containerSize: '20FT', cap: 10, booked: 10, remaining: 0 },
{ containerSize: '40FT', cap: 4, booked: 4, remaining: 0 },
]);
await (service as never as WithPrivate).maybeCompleteContract(
generalContract('CONTRACT_ACTIVE'),
);
expect(contractsRepository.update).toHaveBeenCalledWith('c-1', {
status: 'CONTRACT_CLOSED',
});
});
it('absorbs bulk-ton float dust when judging exhaustion', async () => {
const { service, contractsRepository } = makeService();
jest
.spyOn(service, 'computeCapacity')
.mockResolvedValue([{ cap: 100, booked: 99.9995, remaining: 0.0005 }]);
await (service as never as WithPrivate).maybeCompleteContract(
generalContract('FULLY_EXECUTED'),
);
expect(contractsRepository.update).toHaveBeenCalledWith('c-1', {
status: 'CONTRACT_CLOSED',
});
});
it('keeps the contract open while any capped line has capacity left', async () => {
const { service, contractsRepository } = makeService();
jest.spyOn(service, 'computeCapacity').mockResolvedValue([
{ containerSize: '20FT', cap: 10, booked: 10, remaining: 0 },
{ containerSize: '40FT', cap: 4, booked: 3, remaining: 1 },
]);
await (service as never as WithPrivate).maybeCompleteContract(
generalContract('CONTRACT_ACTIVE'),
);
expect(contractsRepository.update).not.toHaveBeenCalled();
});
it('never closes an uncapped contract', async () => {
const { service, contractsRepository } = makeService();
jest.spyOn(service, 'computeCapacity').mockResolvedValue([]);
await (service as never as WithPrivate).maybeCompleteContract(
generalContract('CONTRACT_ACTIVE'),
);
expect(contractsRepository.update).not.toHaveBeenCalled();
});
it('never closes a ONE_TIME contract (single-slot rule governs it)', async () => {
const { service, contractsRepository } = makeService();
const spy = jest.spyOn(service, 'computeCapacity');
await (service as never as WithPrivate).maybeCompleteContract({
id: 'c-1',
contractKind: 'ONE_TIME',
status: 'FULLY_EXECUTED',
} as Contract);
expect(spy).not.toHaveBeenCalled();
expect(contractsRepository.update).not.toHaveBeenCalled();
});
it('rejects a new booking on a completed contract even inside an open window', async () => {
const { service, contractsRepository } = makeService();
contractsRepository.findByIdWithRelations.mockResolvedValue(
generalContract('CONTRACT_CLOSED'),
);
jest
.spyOn(service, 'computeCapacity')
.mockResolvedValue([{ cap: 10, booked: 10, remaining: 0 }]);
await expect(
service.createUnderContract('c-1', {} as never, null, null),
).rejects.toThrow(BadRequestException);
expect(contractsRepository.update).not.toHaveBeenCalled();
});
describe('completion on booking delivery', () => {
function makeDeliveryService(contract: Partial<Contract>) {
const contractsRepository = {
findById: jest.fn().mockResolvedValue(contract),
update: jest.fn().mockResolvedValue(undefined),
};
const bookingsRepository = {
findById: jest
.fn()
.mockResolvedValue({ id: 'b-1', reference: 'BKG-1', contractId: 'c-1' }),
};
const service = new ContractBookingService(
contractsRepository as never,
bookingsRepository as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{ createdToStaff: jest.fn() } as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never, // consolidationApprovalService
);
return { service, contractsRepository };
}
it('completes a ONE_TIME contract when its booking is delivered', async () => {
const { service, contractsRepository } = makeDeliveryService({
id: 'c-1',
reference: 'CTR-1',
contractKind: 'ONE_TIME',
status: 'CONTRACT_ACTIVE',
});
jest.spyOn(service, 'splitOutstanding').mockResolvedValue(null);
await service.onBookingCompleted({ bookingId: 'b-1' });
expect(contractsRepository.update).toHaveBeenCalledWith('c-1', {
status: 'CONTRACT_CLOSED',
});
});
it('keeps a split ONE_TIME contract open while a remainder is outstanding', async () => {
const { service, contractsRepository } = makeDeliveryService({
id: 'c-1',
reference: 'CTR-1',
contractKind: 'ONE_TIME',
freightType: 'CONTAINER',
status: 'CONTRACT_ACTIVE',
});
jest.spyOn(service, 'splitOutstanding').mockResolvedValue({
bySize: new Map([['20ft', { total: 5, outstanding: 2 }]]),
bulk: null,
});
await service.onBookingCompleted({ bookingId: 'b-1' });
expect(contractsRepository.update).not.toHaveBeenCalled();
});
it('leaves a GENERAL contract alone — it closes on cap or expiry', async () => {
const { service, contractsRepository } = makeDeliveryService({
id: 'c-1',
contractKind: 'GENERAL',
status: 'CONTRACT_ACTIVE',
});
await service.onBookingCompleted({ bookingId: 'b-1' });
expect(contractsRepository.update).not.toHaveBeenCalled();
});
});
it('reopens a completed contract when capacity was released', async () => {
const { service, contractsRepository } = makeService();
contractsRepository.findByIdWithRelations.mockResolvedValue(
generalContract('CONTRACT_CLOSED'),
);
jest
.spyOn(service, 'computeCapacity')
.mockResolvedValue([{ cap: 10, booked: 8, remaining: 2 }]);
// The create path continues past the gate and dies later on the bare mocks —
// only the reopen transition is under test here.
await service.createUnderContract('c-1', {} as never, null, null).catch(() => undefined);
expect(contractsRepository.update).toHaveBeenCalledWith('c-1', {
status: 'CONTRACT_ACTIVE',
});
});
});