mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 09:00:57 +00:00
enhance train scheduling and booking management
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
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),
|
||||
...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,
|
||||
{} as never, // workflowService
|
||||
invoiceService as never,
|
||||
{} as never, // dataSource
|
||||
{} as never, // trainSchedulingService
|
||||
);
|
||||
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 pre + post milestones.
|
||||
expect(milestoneService.seedPreBookingMilestonesOnBooking).toHaveBeenCalled();
|
||||
expect(milestoneService.seedPostBookingMilestones).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
forwardRef,
|
||||
} from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { insertWithGeneratedReference } from '@edr/api-common';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
@@ -15,6 +16,7 @@ import { BookingContainer } from '../bookings/entities/booking-container.entity'
|
||||
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
||||
import { ConsolidationService } from '../bookings/consolidation.service';
|
||||
import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
|
||||
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
|
||||
import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
|
||||
@@ -61,6 +63,7 @@ export class ContractBookingService {
|
||||
private readonly contractsRepository: ContractsRepository,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly bookingPricingService: BookingPricingService,
|
||||
private readonly consolidationService: ConsolidationService,
|
||||
private readonly containerTypesService: ContainerTypesService,
|
||||
private readonly ruleEngineService: RuleEngineService,
|
||||
private readonly milestoneService: ClearanceMilestoneService,
|
||||
@@ -232,6 +235,105 @@ export class ContractBookingService {
|
||||
warnings.push(...computed.warnings);
|
||||
}
|
||||
|
||||
// Wagon consolidation gate. A container drawdown whose lines leave a partial
|
||||
// wagon (e.g. 21× 20FT → one leftover) must share that wagon with a partner
|
||||
// before it can ship. Direct bookings do this at submit; drawdowns have no
|
||||
// submit step, so we run it here — BEFORE invoicing/milestones. When it parks
|
||||
// for a partner the booking is NOT invoiced or scheduled: those steps run
|
||||
// later in finalizeContractBooking, triggered by the pairing event. When it
|
||||
// pairs (or needs no consolidation) we finalize inline.
|
||||
const withContainers = await this.bookingsRepository.findByIdWithFiles(
|
||||
booking.id,
|
||||
);
|
||||
const intendedStatus = generalCustoms
|
||||
? 'AWAITING_DOCUMENTS'
|
||||
: 'OPERATION_REQUEST_PENDING';
|
||||
if (
|
||||
withContainers &&
|
||||
freightType === 'CONTAINER' &&
|
||||
(await this.consolidationService.needsConsolidationFromBooking(
|
||||
withContainers,
|
||||
))
|
||||
) {
|
||||
const parked = await this.consolidateDrawdown(
|
||||
withContainers,
|
||||
intendedStatus,
|
||||
);
|
||||
warnings.push(parked.message);
|
||||
if (!parked.paired) {
|
||||
// Waiting for a partner — stop here. The booking sits in
|
||||
// PENDING_CONSOLIDATION, unbilled and unscheduled, until it pairs.
|
||||
const pendingResult = await this.bookingsRepository.findByIdWithFiles(
|
||||
booking.id,
|
||||
);
|
||||
return { booking: pendingResult ?? booking, warnings };
|
||||
}
|
||||
}
|
||||
|
||||
await this.finalizeContractBooking(
|
||||
booking.id,
|
||||
contract,
|
||||
generalCustoms,
|
||||
);
|
||||
|
||||
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||
return { booking: result ?? booking, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for a complementary partner for a parked-eligible drawdown, pair it or
|
||||
* park it in PENDING_CONSOLIDATION with the resume status it should return to.
|
||||
* Pairing (via BookingsRepository.pairConsolidation) resumes both partners and
|
||||
* emits booking.consolidation.paired, which finalizes any deferred contract
|
||||
* booking. Returns whether a partner was found plus a customer-facing message.
|
||||
*/
|
||||
private async consolidateDrawdown(
|
||||
booking: Booking,
|
||||
resumeStatus: string,
|
||||
): Promise<{ paired: boolean; message: string }> {
|
||||
const slots = await this.consolidationService.slotsFromBooking(booking);
|
||||
if (!slots.length) {
|
||||
return { paired: false, message: '' };
|
||||
}
|
||||
|
||||
const partner = await this.bookingsRepository.findConsolidationPartner(
|
||||
booking,
|
||||
slots,
|
||||
);
|
||||
|
||||
if (partner) {
|
||||
await this.bookingsRepository.pairConsolidation(booking.id, partner.id);
|
||||
return {
|
||||
paired: true,
|
||||
message: this.consolidationService.describePaired(
|
||||
partner.reference,
|
||||
slots,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
await this.bookingsRepository.parkForConsolidation(booking.id, resumeStatus);
|
||||
return {
|
||||
paired: false,
|
||||
message: this.consolidationService.describePending(booking, slots),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize a contract booking once it is cleared to proceed (needed no
|
||||
* consolidation, or has just paired): seed clearance milestones / link the
|
||||
* contract cycle, then generate the invoice. Idempotent — safe to call again
|
||||
* for a booking that pairs after having waited. Skips a booking that is still
|
||||
* PENDING_CONSOLIDATION (guards the pairing event against a stray partner).
|
||||
*/
|
||||
private async finalizeContractBooking(
|
||||
bookingId: string,
|
||||
contract: Contract,
|
||||
generalCustoms: boolean,
|
||||
): Promise<void> {
|
||||
const booking = await this.bookingsRepository.findByIdWithFiles(bookingId);
|
||||
if (!booking || booking.status === 'PENDING_CONSOLIDATION') return;
|
||||
|
||||
// ONE_TIME customs (legacy contract-cycle path): link the contract clearance
|
||||
// cycle to this booking, seed post-booking milestones, and lock the contract
|
||||
// to ACTIVE_SHIPMENT_IN_PROGRESS. NOT for GENERAL — it has no contract cycle
|
||||
@@ -239,10 +341,10 @@ export class ContractBookingService {
|
||||
if (contract.customsClearingEnabled && !generalCustoms) {
|
||||
const cycle = await this.contractsRepository.currentCycle(contract.id);
|
||||
if (cycle) {
|
||||
await this.contractsRepository.linkBooking(cycle.id, booking.id);
|
||||
await this.contractsRepository.linkBooking(cycle.id, bookingId);
|
||||
}
|
||||
await this.milestoneService.seedPostBookingMilestones(
|
||||
booking.id,
|
||||
bookingId,
|
||||
contract.tradeDirection,
|
||||
);
|
||||
await this.contractsRepository.update(contract.id, {
|
||||
@@ -252,24 +354,22 @@ export class ContractBookingService {
|
||||
} else if (generalCustoms) {
|
||||
// Per-booking clearance: seed full milestone timeline on the booking.
|
||||
await this.milestoneService.seedPreBookingMilestonesOnBooking(
|
||||
booking.id,
|
||||
bookingId,
|
||||
contract.tradeDirection,
|
||||
);
|
||||
await this.milestoneService.seedPostBookingMilestones(
|
||||
booking.id,
|
||||
bookingId,
|
||||
contract.tradeDirection,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||
|
||||
// Contract bookings are born past the billable gate (the contract is already
|
||||
// executed), so the invoice is generated here — they never pass through the
|
||||
// legacy marketingApprove → FULLY_EXECUTED path that invoices direct bookings.
|
||||
// Idempotent and non-blocking: a billing hiccup must not undo the booking.
|
||||
// Skips silently when unbillable (no company / no priced amount).
|
||||
await this.invoiceService
|
||||
.ensureInvoiceForBooking(result ?? booking)
|
||||
.ensureInvoiceForBooking(booking)
|
||||
.catch((err) =>
|
||||
this.logger.error(
|
||||
`Failed to generate invoice for contract booking ${booking.reference}: ${
|
||||
@@ -277,8 +377,40 @@ export class ContractBookingService {
|
||||
}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return { booking: result ?? booking, warnings };
|
||||
/**
|
||||
* A parked drawdown just paired — finalize whichever partner is a contract
|
||||
* booking that was waiting (invoice + milestones deferred at creation). The
|
||||
* pairing already resumed the booking's status from consolidationResumeStatus;
|
||||
* this runs the create-time tail that was skipped. Non-contract partners have
|
||||
* their own finalize path (staff accept) and are ignored here.
|
||||
*/
|
||||
@OnEvent('booking.consolidation.paired')
|
||||
async onConsolidationPaired(payload: {
|
||||
bookingIds: string[];
|
||||
}): Promise<void> {
|
||||
for (const id of payload.bookingIds ?? []) {
|
||||
const booking = await this.bookingsRepository.findByIdWithFiles(id);
|
||||
if (!booking?.contractId || booking.status === 'PENDING_CONSOLIDATION') {
|
||||
continue;
|
||||
}
|
||||
const contract = await this.contractsRepository.findByIdWithRelations(
|
||||
booking.contractId,
|
||||
);
|
||||
if (!contract) continue;
|
||||
const generalCustoms =
|
||||
contract.contractKind === 'GENERAL' &&
|
||||
Boolean(contract.customsClearingEnabled);
|
||||
await this.finalizeContractBooking(id, contract, generalCustoms).catch(
|
||||
(err) =>
|
||||
this.logger.error(
|
||||
`Failed to finalize paired contract booking ${booking.reference}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user