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

217 lines
7.6 KiB
TypeScript

import { ContractBookingService } from './contract-booking.service';
import { Booking } from '../bookings/entities/booking.entity';
/**
* Manual (GL-driven) odd-20ft consolidation. On a customs contract GL completes
* the booking, so GL also picks who shares its wagon: two bookings each carrying
* an odd 20ft count are completed together onto one wagon.
*
* The two invariants that matter are that the pair is all-or-nothing (a failure
* on either half must leave NEITHER booking completed and no link written) and
* that the two bookings stay financially separate — one completion each, so one
* price and one invoice each.
*/
describe('ContractBookingService — manual odd-20ft consolidation', () => {
function makeService(overrides: {
bookingsRepository?: Partial<Record<string, jest.Mock>>;
dataSource?: unknown;
}) {
const bookingsRepository = {
findByIdWithFiles: jest.fn(),
findManualConsolidationCandidates: jest.fn().mockResolvedValue([]),
linkConsolidationPartners: jest.fn().mockResolvedValue(undefined),
...overrides.bookingsRepository,
};
// A transaction that simply runs the callback — enough to assert the
// all-or-nothing contract: whatever throws inside propagates out, and the
// caller observes no link written.
const dataSource = overrides.dataSource ?? {
transaction: jest.fn(async (cb: (m: unknown) => Promise<unknown>) => cb({})),
};
const service = new ContractBookingService(
{ findByIdWithRelations: jest.fn() } as never,
bookingsRepository as never,
{} as never, // bookingPricingService
{} as never, // consolidationService
{} as never, // containerTypesService
{} as never, // ruleEngineService
{} as never, // milestoneService
{} as never, // invoiceService
{} as never, // bookingNotifier
dataSource as never,
{} as never, // trainSchedulingService
{} as never, // bookingBatchService
{} as never, // bookingTransitionService
// The pairing is parked for approval rather than going straight to
// Operations; the gate itself is covered by its own spec.
{ requestApproval: jest.fn().mockResolvedValue({ id: 'ap-1' }) } as never,
);
return { service, bookingsRepository, dataSource };
}
const partnerBooking = {
id: 'b-2',
reference: 'BK-2',
contractId: 'c-2',
consolidationPartnerId: null,
} as unknown as Booking;
const pairDto = {
partnerBookingId: 'b-2',
booking: { scheduledDate: '2026-09-01' },
partner: { scheduledDate: '2026-09-01' },
};
it('completes both halves and links them', async () => {
const { service, bookingsRepository } = makeService({
bookingsRepository: {
findByIdWithFiles: jest
.fn()
// partner lookup before the transaction
.mockResolvedValueOnce(partnerBooking)
// the two reloads after it
.mockResolvedValueOnce({ id: 'b-1', reference: 'BK-1' } as Booking)
.mockResolvedValueOnce({ id: 'b-2', reference: 'BK-2' } as Booking),
},
});
// Each half runs the ordinary completion machine — one call per booking, so
// each is priced and invoiced on its own.
const complete = jest
.spyOn(service, 'completeUnderContract')
.mockImplementation(
async (_contractId, bookingId) =>
({
booking: { id: bookingId } as Booking,
warnings: [],
}) as never,
);
const result = await service.completeConsolidatedPair(
'c-1',
'b-1',
pairDto as never,
);
expect(complete).toHaveBeenCalledTimes(2);
// The partner is completed against ITS OWN contract, not this one.
expect(complete.mock.calls[0][0]).toBe('c-1');
expect(complete.mock.calls[1][0]).toBe('c-2');
// Neither half may re-enter the automatic matcher — GL links them here.
expect(complete.mock.calls[0][2]).toMatchObject({
skipAutoConsolidation: true,
});
expect(complete.mock.calls[1][2]).toMatchObject({
skipAutoConsolidation: true,
});
expect(bookingsRepository.linkConsolidationPartners).toHaveBeenCalledWith(
'b-1',
'b-2',
);
expect(result.booking.id).toBe('b-1');
expect(result.partner.id).toBe('b-2');
});
it('links nothing when the partner half fails (all-or-nothing)', async () => {
const { service, bookingsRepository } = makeService({
bookingsRepository: {
findByIdWithFiles: jest.fn().mockResolvedValue(partnerBooking),
},
});
jest
.spyOn(service, 'completeUnderContract')
.mockImplementationOnce(
async () => ({ booking: { id: 'b-1' } as Booking, warnings: [] }) as never,
)
.mockImplementationOnce(async () => {
throw new Error('no train space for the partner');
});
await expect(
service.completeConsolidatedPair('c-1', 'b-1', pairDto as never),
).rejects.toThrow('no train space for the partner');
// The link is the last write in the transaction — it must never happen when
// a half failed, so the rollback leaves no dangling pairing.
expect(bookingsRepository.linkConsolidationPartners).not.toHaveBeenCalled();
});
it('refuses a partner that already shares a wagon', async () => {
const { service } = makeService({
bookingsRepository: {
findByIdWithFiles: jest.fn().mockResolvedValue({
...partnerBooking,
consolidationPartnerId: 'b-9',
}),
},
});
await expect(
service.completeConsolidatedPair('c-1', 'b-1', pairDto as never),
).rejects.toThrow(/already shares a wagon/i);
});
it('refuses to consolidate a booking with itself', async () => {
const { service } = makeService({});
await expect(
service.completeConsolidatedPair('c-1', 'b-1', {
...pairDto,
partnerBookingId: 'b-1',
} as never),
).rejects.toThrow(/cannot be consolidated with itself/i);
});
it('offers only bookings whose own 20ft count is odd', async () => {
// Two odd counts always sum to even, so an odd partner is exactly what fills
// the wagon; an even one would leave the pair partial again.
const rows = [
{
id: 'odd',
reference: 'BK-ODD',
bookingContainers: [
{ quantity: 3, containerType: { sizeFt: 20 } },
],
},
{
id: 'even',
reference: 'BK-EVEN',
bookingContainers: [
{ quantity: 4, containerType: { sizeFt: 20 } },
],
},
// A bare instance has no cargo yet — GL enters it on the split form, so it
// stays a candidate.
{ id: 'bare', reference: 'BK-BARE', bookingContainers: [] },
];
const { service } = makeService({
bookingsRepository: {
findByIdWithFiles: jest
.fn()
.mockResolvedValue({ id: 'b-1', contractId: 'c-1' } as Booking),
findManualConsolidationCandidates: jest.fn(async (booking: Booking) =>
// Mirror the repository's in-memory odd filter.
rows.filter((row) => {
void booking;
const lines = row.bookingContainers ?? [];
if (lines.length === 0) return true;
const ft20 = lines
.filter((l) => Number(l.containerType?.sizeFt) === 20)
.reduce((sum, l) => sum + Number(l.quantity || 0), 0);
return ft20 % 2 === 1;
}),
),
},
});
const candidates = await service.listConsolidationCandidates('c-1', 'b-1');
expect(candidates.map((c) => c.reference)).toEqual(['BK-ODD', 'BK-BARE']);
expect(candidates[0].ft20Quantity).toBe(3);
expect(candidates[1].hasCargo).toBe(false);
});
});