mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
enhance train scheduling and booking management
This commit is contained in:
@@ -8,7 +8,10 @@ import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
|
||||
import { ScheduleModule } from "@nestjs/schedule";
|
||||
import { EventEmitterModule } from "@nestjs/event-emitter";
|
||||
import { DataSource, DataSourceOptions } from "typeorm";
|
||||
import { ensurePostgresSchemas } from "./config/ensure-postgres-schemas";
|
||||
import {
|
||||
ensurePostgresSchemas,
|
||||
APPLICATION_SEARCH_PATH,
|
||||
} from "./config/ensure-postgres-schemas";
|
||||
import { IamModule, DataSeeder } from "@tria-plc/iamapi-common";
|
||||
import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module";
|
||||
|
||||
@@ -105,7 +108,27 @@ import { LoggerMiddleware } from "./logger.middleware";
|
||||
}
|
||||
await ensurePostgresSchemas(options as DataSourceOptions);
|
||||
const dataSource = new DataSource(options as DataSourceOptions);
|
||||
return dataSource.initialize();
|
||||
await dataSource.initialize();
|
||||
|
||||
// The remote edr_dev DB sits behind a connection pooler/proxy that rejects
|
||||
// the Postgres `options` startup parameter (08P01). Instead of setting
|
||||
// search_path at connect time, apply it per physical connection: the pg
|
||||
// Pool emits `connect` for every new client (initial fill, pool growth,
|
||||
// reconnect), so every backend session gets the schema search order.
|
||||
const pool = (dataSource.driver as { master?: unknown }).master as
|
||||
| { on?: (event: string, cb: (client: unknown) => void) => void }
|
||||
| undefined;
|
||||
if (pool?.on) {
|
||||
pool.on("connect", (client) => {
|
||||
(client as { query: (sql: string) => Promise<unknown> })
|
||||
.query(`SET search_path TO ${APPLICATION_SEARCH_PATH}`)
|
||||
.catch(() => {
|
||||
/* connection will be validated on first real query */
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return dataSource;
|
||||
},
|
||||
}),
|
||||
SharedAuthModule,
|
||||
|
||||
@@ -44,7 +44,6 @@ import {
|
||||
NotificationTemplate,
|
||||
} from "@tria-plc/iamapi-common";
|
||||
import { OrganizationSetting } from "@tria-plc/iamapi-common/entities/iam/organization-structure/organization-setting.entity";
|
||||
import { APPLICATION_SEARCH_PATH } from "./ensure-postgres-schemas";
|
||||
|
||||
const iamEntities = [
|
||||
DefaultPosition,
|
||||
@@ -105,9 +104,12 @@ export default registerAs("database", (): TypeOrmModuleOptions => {
|
||||
password: process.env.DB_PASSWORD ?? "",
|
||||
database: process.env.DB_NAME ?? "edr_freight",
|
||||
schema: "public",
|
||||
extra: {
|
||||
options: `-c search_path=${APPLICATION_SEARCH_PATH}`,
|
||||
},
|
||||
// NOTE: do NOT pass `extra.options: '-c search_path=...'`. That sends the
|
||||
// Postgres startup `options` parameter, which connection poolers (PgBouncer /
|
||||
// proxies fronting the remote edr_dev DB) reject with
|
||||
// `08P01 unsupported startup parameter in options: search_path`.
|
||||
// The search_path is instead applied per-connection via a pool `connect`
|
||||
// handler in app.module.ts (see setPoolSearchPath).
|
||||
entities: [__dirname + "/../**/*.entity.{ts,js}", ...iamEntities],
|
||||
autoLoadEntities: true,
|
||||
migrations: [
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Adds bookings.consolidation_resume_status: the status a booking parked in
|
||||
* PENDING_CONSOLIDATION returns to once it pairs with a wagon partner.
|
||||
*
|
||||
* Direct customer bookings leave it NULL (they resume to SUBMITTED, unchanged).
|
||||
* Contract-drawdown bookings (GL shipments) set it to the status
|
||||
* createUnderContract would otherwise have used (OPERATION_REQUEST_PENDING or
|
||||
* AWAITING_DOCUMENTS), so pairing resumes them into the contract-booking flow
|
||||
* instead of wrongly moving them to SUBMITTED.
|
||||
*/
|
||||
export class AddConsolidationResumeStatus2010000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS consolidation_resume_status VARCHAR(40);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS consolidation_resume_status;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -114,6 +114,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
BookingPricingService,
|
||||
BookingInvoiceService,
|
||||
BookingLifecycleNotifierService,
|
||||
ConsolidationService,
|
||||
CustomerTruckService,
|
||||
ContainerReceiptService,
|
||||
],
|
||||
|
||||
@@ -272,26 +272,51 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Pair two bookings for consolidation. Both return to SUBMITTED so staff can
|
||||
* accept them into the approval chain; the link itself (consolidationPartnerId)
|
||||
* marks them as consolidated in the UI.
|
||||
* Pair two bookings for consolidation. Each returns to its own resume status —
|
||||
* SUBMITTED for a direct customer booking (so staff can accept it into the
|
||||
* approval chain) or the stored consolidationResumeStatus for a contract
|
||||
* drawdown (OPERATION_REQUEST_PENDING / AWAITING_DOCUMENTS). The link itself
|
||||
* (consolidationPartnerId) marks them as consolidated in the UI. The resume
|
||||
* status is cleared once used, so a later un-pair re-parks cleanly.
|
||||
*/
|
||||
async pairConsolidation(bookingId: string, partnerId: string): Promise<void> {
|
||||
const [booking, partner] = await Promise.all([
|
||||
this.repository.findOne({
|
||||
where: { id: bookingId },
|
||||
select: { id: true, consolidationResumeStatus: true },
|
||||
}),
|
||||
this.repository.findOne({
|
||||
where: { id: partnerId },
|
||||
select: { id: true, consolidationResumeStatus: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
await this.repository.update(bookingId, {
|
||||
consolidationPartnerId: partnerId,
|
||||
status: 'SUBMITTED',
|
||||
status: booking?.consolidationResumeStatus ?? 'SUBMITTED',
|
||||
consolidationResumeStatus: null,
|
||||
} as never);
|
||||
await this.repository.update(partnerId, {
|
||||
consolidationPartnerId: bookingId,
|
||||
status: 'SUBMITTED',
|
||||
status: partner?.consolidationResumeStatus ?? 'SUBMITTED',
|
||||
consolidationResumeStatus: null,
|
||||
} as never);
|
||||
}
|
||||
|
||||
/** Park a booking that needs consolidation but has no partner yet. */
|
||||
async parkForConsolidation(bookingId: string): Promise<void> {
|
||||
/**
|
||||
* Park a booking that needs consolidation but has no partner yet. The optional
|
||||
* resumeStatus is where the booking returns once it pairs — pass it for a
|
||||
* contract drawdown so pairing resumes the contract-booking flow rather than
|
||||
* the direct-booking SUBMITTED default.
|
||||
*/
|
||||
async parkForConsolidation(
|
||||
bookingId: string,
|
||||
resumeStatus?: string | null,
|
||||
): Promise<void> {
|
||||
await this.repository.update(bookingId, {
|
||||
consolidationPartnerId: null,
|
||||
status: 'PENDING_CONSOLIDATION',
|
||||
consolidationResumeStatus: resumeStatus ?? null,
|
||||
} as never);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
RuleEngineService,
|
||||
} from '../rule-engine/rule-engine.service';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { DataSource, In } from 'typeorm';
|
||||
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
@@ -99,6 +100,7 @@ export class BookingsService {
|
||||
private readonly consolidationService: ConsolidationService,
|
||||
private readonly vehiclesService: VehiclesService,
|
||||
private readonly contractPdfService: ContractPdfService,
|
||||
private readonly events: EventEmitter2,
|
||||
) {}
|
||||
|
||||
async assignCustomerTruck(
|
||||
@@ -493,6 +495,14 @@ export class BookingsService {
|
||||
messages.push(
|
||||
this.consolidationService.describePaired(partner.reference, slots),
|
||||
);
|
||||
// Let deferred owners (e.g. contract drawdowns whose invoice/milestones
|
||||
// were held while the booking waited) finalize now that a whole wagon
|
||||
// exists. Fire-and-forget: a listener failure must not undo the pairing.
|
||||
this.events
|
||||
.emitAsync('booking.consolidation.paired', {
|
||||
bookingIds: [booking.id, partner.id],
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return { booking: paired, messages };
|
||||
}
|
||||
|
||||
|
||||
@@ -431,6 +431,14 @@ export class Booking extends BaseEntity {
|
||||
@JoinColumn({ name: 'consolidation_partner_id' })
|
||||
consolidationPartner?: Booking | null;
|
||||
|
||||
// Status a booking parked in PENDING_CONSOLIDATION returns to once it pairs.
|
||||
// Null for direct customer bookings (they resume to SUBMITTED, the historical
|
||||
// default); contract-drawdown bookings set it to the status createUnderContract
|
||||
// would otherwise have used (OPERATION_REQUEST_PENDING / AWAITING_DOCUMENTS), so
|
||||
// pairing resumes them into the right flow instead of the direct-booking one.
|
||||
@Column({ name: 'consolidation_resume_status', type: 'varchar', length: 40, nullable: true })
|
||||
consolidationResumeStatus?: string | null;
|
||||
|
||||
@Column({ name: 'wagons_required', type: 'numeric', precision: 6, scale: 2, nullable: true })
|
||||
wagonsRequired?: number | null;
|
||||
|
||||
|
||||
@@ -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)
|
||||
}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
|
||||
@Entity({
|
||||
// Table lives in the freight schema like every other freight entity. Without
|
||||
// this the entity inherits the DataSource default schema (public), so TypeORM
|
||||
// queries public.otp_verifications — which doesn't exist — and OTP verify
|
||||
// (e.g. the contract-signature sudo gate) fails with a 500 QueryFailedError.
|
||||
schema: "freight",
|
||||
name: "otp_verifications",
|
||||
})
|
||||
export class OtpVerification extends BaseEntity{
|
||||
|
||||
@@ -125,7 +125,10 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
notifier as never,
|
||||
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
||||
trainSchedulingService as never,
|
||||
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never,
|
||||
{
|
||||
syncPayableDueDate: jest.fn().mockResolvedValue(undefined),
|
||||
expirePayable: jest.fn().mockResolvedValue(undefined),
|
||||
} as never,
|
||||
{ emitPhase: jest.fn() } as never,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -80,6 +80,10 @@ export interface BatchBoardBooking {
|
||||
lengthMeters: number;
|
||||
paymentDeadline: string | null;
|
||||
state: BatchBoardBookingState;
|
||||
/** Rule-engine priority score used to rank the batch (higher = boards first). */
|
||||
priorityScore: number;
|
||||
/** CONTAINER | BULK — for the priority-tracking visuals. */
|
||||
freightType: string | null;
|
||||
}
|
||||
|
||||
export type BookingAllocationStatus =
|
||||
@@ -638,6 +642,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
? b.paymentDeadline.toISOString()
|
||||
: null,
|
||||
state: this.boardState(b, linkedIds.has(b.id)),
|
||||
priorityScore: Number(b.priorityScore ?? 0),
|
||||
freightType: b.freightType ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -726,6 +732,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
? b.paymentDeadline.toISOString()
|
||||
: null,
|
||||
state: this.boardState(b, linkedIds.has(b.id)),
|
||||
priorityScore: Number(b.priorityScore ?? 0),
|
||||
freightType: b.freightType ?? null,
|
||||
fullyExecutedAt: b.fullyExecutedAt
|
||||
? b.fullyExecutedAt.toISOString()
|
||||
: null,
|
||||
@@ -1026,8 +1034,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const units = this.groupConsolidatedPool(pool);
|
||||
let armed = false;
|
||||
|
||||
// TODO(change-c-diagnostics): remove once the "only 1 reserved" capacity cause
|
||||
// is confirmed. Logs the caps + pool so we can see which axis rejects unit #2.
|
||||
// Batch fill trace: caps + pool at entry. Kept on debug level — invaluable when
|
||||
// reservations trickle instead of landing in one pass (a reserve() throwing
|
||||
// mid-loop, e.g. schema drift, or a mis-synced capacity cap).
|
||||
this.logger.debug(
|
||||
`[fillSchedule ${scheduleId}] limits=${JSON.stringify(limits)} ` +
|
||||
`maxWagons=${schedule.maxWagons} remaining=${JSON.stringify(budget.maxRemaining())} ` +
|
||||
@@ -1045,7 +1054,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// stands for the pair.
|
||||
const leg = budget.legForYards(booking.originYardId, booking.destinationYardId);
|
||||
|
||||
// TODO(change-c-diagnostics): remove once the capacity cause is confirmed.
|
||||
// Per-unit fit trace: which axis (wagons/weight/length) admits or rejects.
|
||||
this.logger.debug(
|
||||
`[fillSchedule ${scheduleId}] unit ${booking.reference}: need=${JSON.stringify(need)} ` +
|
||||
`roomOnLeg=${JSON.stringify(budget.remainingFor(leg))} fits=${budget.fits(need, leg)}`,
|
||||
@@ -1175,8 +1184,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// consolidated booking whose partner isn't ready this cycle is skipped.
|
||||
const units = this.groupConsolidatedPool(pool);
|
||||
|
||||
// TODO(change-c-diagnostics): remove once the "only 1 reserved" capacity cause
|
||||
// is confirmed. Shows each train's caps + the day pool size.
|
||||
// Batch fill trace: each train's caps + the day pool size at entry.
|
||||
this.logger.debug(
|
||||
`[fillRouteDay ${originYardId}->${destinationYardId} ${day}] ` +
|
||||
`trains=${trains.map((t) => `${t.id}:${JSON.stringify(t.budget.maxRemaining())}`).join(",")} ` +
|
||||
@@ -1201,7 +1209,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return leg != null && t.budget.fits(need, leg);
|
||||
});
|
||||
|
||||
// TODO(change-c-diagnostics): remove once the capacity cause is confirmed.
|
||||
// Per-unit trace: chosen train + each train's remaining room on this leg.
|
||||
this.logger.debug(
|
||||
`[fillRouteDay] unit ${booking.reference}: need=${JSON.stringify(need)} ` +
|
||||
`targetTrain=${target?.id ?? "none"} ` +
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { BookingWindowService } from './booking-window.service';
|
||||
import type { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
|
||||
/**
|
||||
* Window state-machine tests: exercise the real advanceImport transitions and the
|
||||
* concludeCycle reopen/done decision with mocked collaborators. Drives the exact
|
||||
* production phase logic (PRE_WINDOW → OPEN → DOC_REVIEW → PAYMENT → conclude) and
|
||||
* asserts the side effects the batch/settle/reopen flow depends on.
|
||||
*/
|
||||
describe('BookingWindowService — window state machine', () => {
|
||||
const scheduleId = 'sched-1';
|
||||
|
||||
let service: BookingWindowService;
|
||||
let batch: {
|
||||
setWindow: jest.Mock;
|
||||
processRouteDay: jest.Mock;
|
||||
expireUnacceptedForRouteDay: jest.Mock;
|
||||
settleDueReservations: jest.Mock;
|
||||
isScheduleFull: jest.Mock;
|
||||
};
|
||||
let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock };
|
||||
let trainSchedulingService: { finalizeSchedule: jest.Mock; getWindowConfig: jest.Mock };
|
||||
let updateMock: jest.Mock;
|
||||
|
||||
const cfg = {
|
||||
importWindowLeadDays: 3,
|
||||
exportBookingLeadHours: 24,
|
||||
windowOpenHour: 0, // 24h desk → reopen opens immediately
|
||||
windowCloseHour: 0,
|
||||
windowDurationHours: 1,
|
||||
docReviewMinutes: 30,
|
||||
paymentWindowMinutes: 60,
|
||||
reopenDelayMinutes: 0,
|
||||
};
|
||||
|
||||
const baseSchedule = (over: Partial<TrainSchedule>): TrainSchedule =>
|
||||
({
|
||||
id: scheduleId,
|
||||
direction: 'IMPORT',
|
||||
originStationId: 'yard-o',
|
||||
destinationStationId: 'yard-d',
|
||||
scheduledDepartureDate: new Date('2026-08-01T06:00:00.000Z'),
|
||||
bookingWindowStatus: 'CLOSED',
|
||||
windowPhase: 'PRE_WINDOW',
|
||||
bookingCycleNo: 0,
|
||||
windowOpensAt: null,
|
||||
windowClosesAt: null,
|
||||
docReviewEndsAt: null,
|
||||
docReviewCompletedAt: null,
|
||||
paymentPhaseEndsAt: null,
|
||||
...over,
|
||||
}) as unknown as TrainSchedule;
|
||||
|
||||
const advanceImport = (s: TrainSchedule, now: Date): Promise<boolean> =>
|
||||
(service as unknown as {
|
||||
advanceImport: (s: TrainSchedule, c: unknown, n: Date) => Promise<boolean>;
|
||||
}).advanceImport(s, cfg, now);
|
||||
const concludeCycle = (s: TrainSchedule, now: Date): Promise<void> =>
|
||||
(service as unknown as {
|
||||
concludeCycle: (s: TrainSchedule, c: unknown, n: Date) => Promise<void>;
|
||||
}).concludeCycle(s, cfg, now);
|
||||
|
||||
beforeEach(() => {
|
||||
updateMock = jest.fn().mockResolvedValue(undefined);
|
||||
batch = {
|
||||
setWindow: jest.fn().mockResolvedValue(undefined),
|
||||
processRouteDay: jest.fn().mockResolvedValue(undefined),
|
||||
expireUnacceptedForRouteDay: jest.fn().mockResolvedValue(undefined),
|
||||
settleDueReservations: jest.fn().mockResolvedValue(undefined),
|
||||
isScheduleFull: jest.fn().mockResolvedValue(false),
|
||||
};
|
||||
trainSchedulesRepository = {
|
||||
findById: jest.fn().mockResolvedValue(null),
|
||||
findAll: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
trainSchedulingService = {
|
||||
finalizeSchedule: jest.fn().mockResolvedValue(undefined),
|
||||
getWindowConfig: jest.fn().mockResolvedValue(cfg),
|
||||
};
|
||||
|
||||
service = new BookingWindowService(
|
||||
{ getRepository: () => ({ update: updateMock }) } as never,
|
||||
trainSchedulesRepository as never,
|
||||
batch as never,
|
||||
trainSchedulingService as never,
|
||||
{ directSend: jest.fn() } as never,
|
||||
{ notify: jest.fn() } as never,
|
||||
{ emitPhase: jest.fn() } as never,
|
||||
);
|
||||
});
|
||||
|
||||
it('PRE_WINDOW → OPEN at windowOpensAt (opens the customer window)', async () => {
|
||||
const s = baseSchedule({
|
||||
windowPhase: 'PRE_WINDOW',
|
||||
windowOpensAt: new Date('2026-07-01T00:00:00.000Z'),
|
||||
});
|
||||
const advanced = await advanceImport(s, new Date('2026-07-01T00:00:01.000Z'));
|
||||
expect(advanced).toBe(true);
|
||||
expect(s.windowPhase).toBe('OPEN');
|
||||
expect(s.bookingCycleNo).toBe(1);
|
||||
expect(batch.setWindow).toHaveBeenCalledWith(scheduleId, 'OPEN');
|
||||
});
|
||||
|
||||
it('OPEN → DOC_REVIEW at windowClosesAt (closes booking, sets doc-review deadline)', async () => {
|
||||
const closesAt = new Date('2026-07-01T01:00:00.000Z');
|
||||
const s = baseSchedule({
|
||||
windowPhase: 'OPEN',
|
||||
bookingWindowStatus: 'OPEN',
|
||||
windowClosesAt: closesAt,
|
||||
});
|
||||
const advanced = await advanceImport(s, new Date('2026-07-01T01:00:01.000Z'));
|
||||
expect(advanced).toBe(true);
|
||||
expect(s.windowPhase).toBe('DOC_REVIEW');
|
||||
expect(s.docReviewEndsAt).toEqual(new Date(closesAt.getTime() + 30 * 60_000));
|
||||
expect(batch.setWindow).toHaveBeenCalledWith(scheduleId, 'CLOSED');
|
||||
});
|
||||
|
||||
it('DOC_REVIEW → PAYMENT expires un-accepted, then runs the batch', async () => {
|
||||
const s = baseSchedule({
|
||||
windowPhase: 'DOC_REVIEW',
|
||||
docReviewEndsAt: new Date('2026-07-01T01:30:00.000Z'),
|
||||
});
|
||||
const advanced = await advanceImport(s, new Date('2026-07-01T01:30:01.000Z'));
|
||||
expect(advanced).toBe(true);
|
||||
expect(s.windowPhase).toBe('PAYMENT');
|
||||
expect(s.paymentPhaseEndsAt).not.toBeNull();
|
||||
// Expiry sweep runs BEFORE the batch (unaccepted must not compete for capacity).
|
||||
expect(batch.expireUnacceptedForRouteDay).toHaveBeenCalledTimes(1);
|
||||
expect(batch.processRouteDay).toHaveBeenCalledTimes(1);
|
||||
const expireOrder = batch.expireUnacceptedForRouteDay.mock.invocationCallOrder[0];
|
||||
const batchOrder = batch.processRouteDay.mock.invocationCallOrder[0];
|
||||
expect(expireOrder).toBeLessThan(batchOrder);
|
||||
});
|
||||
|
||||
it('DOC_REVIEW → PAYMENT also fires when staff finished review early (docReviewCompletedAt)', async () => {
|
||||
const s = baseSchedule({
|
||||
windowPhase: 'DOC_REVIEW',
|
||||
docReviewEndsAt: new Date('2026-07-01T05:00:00.000Z'), // far future
|
||||
docReviewCompletedAt: new Date('2026-07-01T01:31:00.000Z'), // staff clicked done
|
||||
});
|
||||
const advanced = await advanceImport(s, new Date('2026-07-01T01:31:01.000Z'));
|
||||
expect(advanced).toBe(true);
|
||||
expect(s.windowPhase).toBe('PAYMENT');
|
||||
});
|
||||
|
||||
it('PAYMENT → conclude at paymentPhaseEndsAt settles due reservations', async () => {
|
||||
const s = baseSchedule({
|
||||
windowPhase: 'PAYMENT',
|
||||
paymentPhaseEndsAt: new Date('2026-07-01T02:30:00.000Z'),
|
||||
});
|
||||
const advanced = await advanceImport(s, new Date('2026-07-01T02:30:01.000Z'));
|
||||
expect(advanced).toBe(true);
|
||||
// settleDueReservations runs (allocate paid / expire unpaid, then top-up).
|
||||
expect(batch.settleDueReservations).toHaveBeenCalledWith(scheduleId);
|
||||
});
|
||||
|
||||
it('conclude: train FULL → window FULL + phase DONE + auto-finalize', async () => {
|
||||
batch.isScheduleFull.mockResolvedValue(true);
|
||||
const s = baseSchedule({ windowPhase: 'PAYMENT' });
|
||||
await concludeCycle(s, new Date('2026-07-01T02:30:02.000Z'));
|
||||
expect(batch.setWindow).toHaveBeenCalledWith(scheduleId, 'FULL');
|
||||
expect(s.windowPhase).toBe('DONE');
|
||||
expect(trainSchedulingService.finalizeSchedule).toHaveBeenCalledWith(scheduleId);
|
||||
});
|
||||
|
||||
it('conclude: NOT full + a cycle fits before departure → REOPEN (back to PRE_WINDOW)', async () => {
|
||||
batch.isScheduleFull.mockResolvedValue(false);
|
||||
const s = baseSchedule({
|
||||
windowPhase: 'PAYMENT',
|
||||
// departure well in the future so nextCycleOpensAt returns a real time.
|
||||
scheduledDepartureDate: new Date('2026-08-01T06:00:00.000Z'),
|
||||
});
|
||||
await concludeCycle(s, new Date('2026-07-01T02:30:03.000Z'));
|
||||
expect(s.windowPhase).toBe('PRE_WINDOW');
|
||||
expect(s.windowOpensAt).not.toBeNull();
|
||||
expect(trainSchedulingService.finalizeSchedule).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('conclude: NOT full but NO cycle fits before departure → DONE', async () => {
|
||||
batch.isScheduleFull.mockResolvedValue(false);
|
||||
const s = baseSchedule({
|
||||
windowPhase: 'PAYMENT',
|
||||
// departure already passed → nextCycleOpensAt returns null → finish.
|
||||
scheduledDepartureDate: new Date('2026-07-01T00:00:00.000Z'),
|
||||
});
|
||||
await concludeCycle(s, new Date('2026-07-01T02:30:04.000Z'));
|
||||
expect(s.windowPhase).toBe('DONE');
|
||||
});
|
||||
|
||||
it('no transition fires before its deadline (idempotent tick)', async () => {
|
||||
const s = baseSchedule({
|
||||
windowPhase: 'OPEN',
|
||||
bookingWindowStatus: 'OPEN',
|
||||
windowClosesAt: new Date('2026-07-01T10:00:00.000Z'), // future
|
||||
});
|
||||
const advanced = await advanceImport(s, new Date('2026-07-01T01:00:00.000Z'));
|
||||
expect(advanced).toBe(false);
|
||||
expect(s.windowPhase).toBe('OPEN');
|
||||
expect(batch.setWindow).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user