mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
- 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
217 lines
7.6 KiB
TypeScript
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);
|
|
});
|
|
});
|