mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 20:38:17 +00:00
199 lines
7.1 KiB
TypeScript
199 lines
7.1 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);
|
|
});
|
|
|
|
// Which bookings qualify is the repository's decision (and its own spec's);
|
|
// what matters here is that the odd 20ft count it resolved survives into the
|
|
// response. A booking awaiting completion has no container lines of its own,
|
|
// so re-deriving the count from bookingContainers would report 0 and the
|
|
// picker would show every candidate as empty.
|
|
it('reports the 20ft count the repository resolved, not the persisted lines', async () => {
|
|
const { service } = makeService({
|
|
bookingsRepository: {
|
|
findByIdWithFiles: jest
|
|
.fn()
|
|
.mockResolvedValue({ id: 'b-1', contractId: 'c-1' } as Booking),
|
|
findManualConsolidationCandidates: jest.fn().mockResolvedValue([
|
|
{
|
|
// Cargo not persisted yet — the count came from its booking request.
|
|
booking: {
|
|
id: 'odd',
|
|
reference: 'BK-2026-001116',
|
|
bookingContainers: [],
|
|
},
|
|
ft20Quantity: 1,
|
|
},
|
|
]),
|
|
},
|
|
});
|
|
|
|
const candidates = await service.listConsolidationCandidates('c-1', 'b-1');
|
|
expect(candidates.map((c) => c.reference)).toEqual(['BK-2026-001116']);
|
|
expect(candidates[0].ft20Quantity).toBe(1);
|
|
expect(candidates[0].hasCargo).toBe(true);
|
|
});
|
|
});
|