Files
edr-platform/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.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

193 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { ContractBookingService } from './contract-booking.service';
import { Booking } from '../bookings/entities/booking.entity';
/**
* The GL contract-drawdown path must run wagon consolidation before invoicing.
* A partial-wagon drawdown (e.g. 21× 20FT → one leftover container) parks in
* PENDING_CONSOLIDATION and is NOT finalized (no invoice / milestones) until it
* pairs with a wagon partner. These tests exercise the two new hooks directly.
*/
describe('ContractBookingService — drawdown consolidation gate', () => {
function makeService(overrides: {
consolidationService?: Partial<Record<string, jest.Mock>>;
bookingsRepository?: Partial<Record<string, jest.Mock>>;
invoiceService?: Partial<Record<string, jest.Mock>>;
milestoneService?: Partial<Record<string, jest.Mock>>;
contractsRepository?: Partial<Record<string, jest.Mock>>;
}) {
const consolidationService = {
slotsFromBooking: jest.fn().mockResolvedValue([]),
describePaired: jest.fn().mockReturnValue('paired'),
describePending: jest.fn().mockReturnValue('pending'),
needsConsolidationFromBooking: jest.fn().mockResolvedValue(false),
...overrides.consolidationService,
};
const bookingsRepository = {
findConsolidationPartner: jest.fn().mockResolvedValue(null),
pairConsolidation: jest.fn().mockResolvedValue(undefined),
parkForConsolidation: jest.fn().mockResolvedValue(undefined),
findByIdWithFiles: jest.fn(),
...overrides.bookingsRepository,
};
const invoiceService = {
ensureInvoiceForBooking: jest.fn().mockResolvedValue({ id: 'inv-1' }),
...overrides.invoiceService,
};
const milestoneService = {
seedPostBookingMilestones: jest.fn().mockResolvedValue(undefined),
seedPreBookingMilestonesOnBooking: jest.fn().mockResolvedValue(undefined),
ensureBookingMilestones: jest.fn().mockResolvedValue(undefined),
...overrides.milestoneService,
};
const contractsRepository = {
findByIdWithRelations: jest.fn(),
currentCycle: jest.fn().mockResolvedValue(null),
linkBooking: jest.fn().mockResolvedValue(undefined),
update: jest.fn().mockResolvedValue(undefined),
...overrides.contractsRepository,
};
const service = new ContractBookingService(
contractsRepository as never,
bookingsRepository as never,
{} as never, // bookingPricingService
consolidationService as never,
{} as never, // containerTypesService
{} as never, // ruleEngineService
milestoneService as never,
invoiceService as never,
{
createdToStaff: jest.fn(),
createdByGlForCustomer: jest.fn(),
} as never, // bookingNotifier
{} as never, // dataSource
{} as never, // trainSchedulingService
{} as never, // bookingBatchService
{} as never, // bookingTransitionService
{} as never, // consolidationApprovalService
);
return {
service,
consolidationService,
bookingsRepository,
invoiceService,
milestoneService,
contractsRepository,
};
}
const booking = { id: 'b-1', reference: 'BK-1' } as Booking;
it('parks (not pairs) when no complementary partner exists', async () => {
const { service, bookingsRepository } = makeService({
consolidationService: {
slotsFromBooking: jest
.fn()
.mockResolvedValue([{ containerTypeId: 'ct', slotsNeeded: 1 }]),
},
bookingsRepository: {
findConsolidationPartner: jest.fn().mockResolvedValue(null),
},
});
const result = await (service as never as {
consolidateDrawdown: (b: Booking, s: string) => Promise<{ paired: boolean }>;
}).consolidateDrawdown(booking, 'OPERATION_REQUEST_PENDING');
expect(result.paired).toBe(false);
expect(bookingsRepository.parkForConsolidation).toHaveBeenCalledWith(
'b-1',
'OPERATION_REQUEST_PENDING',
);
expect(bookingsRepository.pairConsolidation).not.toHaveBeenCalled();
});
it('pairs when a complementary partner exists', async () => {
const { service, bookingsRepository } = makeService({
consolidationService: {
slotsFromBooking: jest
.fn()
.mockResolvedValue([{ containerTypeId: 'ct', slotsNeeded: 1 }]),
},
bookingsRepository: {
findConsolidationPartner: jest
.fn()
.mockResolvedValue({ id: 'p-1', reference: 'BK-2' }),
},
});
const result = await (service as never as {
consolidateDrawdown: (b: Booking, s: string) => Promise<{ paired: boolean }>;
}).consolidateDrawdown(booking, 'AWAITING_DOCUMENTS');
expect(result.paired).toBe(true);
expect(bookingsRepository.pairConsolidation).toHaveBeenCalledWith('b-1', 'p-1');
expect(bookingsRepository.parkForConsolidation).not.toHaveBeenCalled();
});
it('onConsolidationPaired finalizes a resumed contract booking (invoice + milestones)', async () => {
const paired = {
id: 'b-1',
reference: 'BK-1',
contractId: 'c-1',
status: 'OPERATION_REQUEST_PENDING',
} as Booking;
const contract = {
id: 'c-1',
contractKind: 'GENERAL',
customsClearingEnabled: true,
tradeDirection: 'EXPORT',
};
const { service, invoiceService, milestoneService } = makeService({
bookingsRepository: {
findByIdWithFiles: jest.fn().mockResolvedValue(paired),
},
contractsRepository: {
findByIdWithRelations: jest.fn().mockResolvedValue(contract),
},
});
await service.onConsolidationPaired({ bookingIds: ['b-1'] });
expect(invoiceService.ensureInvoiceForBooking).toHaveBeenCalledTimes(1);
// GENERAL customs → per-booking milestones, via the idempotent ensure so a
// pairing replay (or an initiated instance's pre-seeded timeline) never
// duplicates rows.
expect(milestoneService.ensureBookingMilestones).toHaveBeenCalledWith(
'b-1',
'EXPORT',
);
});
it('onConsolidationPaired ignores a booking still PENDING_CONSOLIDATION', async () => {
const stillPending = {
id: 'b-1',
contractId: 'c-1',
status: 'PENDING_CONSOLIDATION',
} as Booking;
const { service, invoiceService } = makeService({
bookingsRepository: {
findByIdWithFiles: jest.fn().mockResolvedValue(stillPending),
},
});
await service.onConsolidationPaired({ bookingIds: ['b-1'] });
expect(invoiceService.ensureInvoiceForBooking).not.toHaveBeenCalled();
});
it('onConsolidationPaired ignores a non-contract (direct) booking', async () => {
const direct = { id: 'd-1', status: 'SUBMITTED', contractId: null } as Booking;
const { service, invoiceService, contractsRepository } = makeService({
bookingsRepository: {
findByIdWithFiles: jest.fn().mockResolvedValue(direct),
},
});
await service.onConsolidationPaired({ bookingIds: ['d-1'] });
expect(contractsRepository.findByIdWithRelations).not.toHaveBeenCalled();
expect(invoiceService.ensureInvoiceForBooking).not.toHaveBeenCalled();
});
});