enhance gate pass and freight payment handling in train scheduling

- Updated the logic in  to ensure that a booking only earns its gate pass once the freight charges are settled.
- Added logging for bookings that have not settled freight payment when securing gate passes.
- Modified seeders to ensure that bookings have associated company profiles to prevent data inconsistencies.
- Updated freight permissions to include new clearance actions for bookings.
- Enhanced the UI to reflect changes in the clearance process, including new shipment request pages and improved status handling in the clearance action panel.
- Adjusted the contract clearance list to accommodate both customs contracts and shipment bookings.
- Improved the handling of GENERAL contracts in various components to ensure proper booking flow and visibility.
This commit is contained in:
Marshal
2026-07-09 07:19:36 +00:00
parent 1b2b3f68f8
commit cd8fb2b321
20 changed files with 959 additions and 126 deletions

View File

@@ -630,4 +630,104 @@ describe('BookingBatchService — PAID reconcile', () => {
expect(check({ ...importGeneral, contractKind: null } as Booking, false)).toBe(false);
});
});
describe('settleDueReservations — expire then promote the waiting list', () => {
const originYardId = 'yard-origin';
const destinationYardId = 'yard-dest';
const trainId = 'train-a';
// 14m / 70t default wagon → two wagon slots on this locomotive.
const smallLoco = { maxPullWeightTons: 200, maxTrainLengthMeters: 28 };
const booking = (id: string, priority: number, overrides = {}): Booking =>
({
id,
reference: id,
isGovernment: false,
priorityScore: priority,
status: 'FULLY_EXECUTED',
wagonsRequired: 1,
cargoTotalWeightVgm: 10,
freightType: 'CONTAINER',
bookingContainers: [],
originYardId,
destinationYardId,
trainScheduleId: trainId,
...overrides,
}) as unknown as Booking;
beforeEach(() => {
const scheduleRow = {
id: trainId,
maxWagons: 2,
bookingWindowStatus: 'CLOSED',
windowPhase: 'PAYMENT',
direction: 'IMPORT',
trainSetId: `set-${trainId}`,
trainSet: { locomotive: smallLoco },
scheduleBookings: [],
scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'),
originStationId: originYardId,
destinationStationId: destinationYardId,
};
trainSchedulesRepository.findAll.mockResolvedValue([{ ...scheduleRow }]);
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(scheduleRow);
trainSchedulesRepository.findById.mockResolvedValue({
id: trainId,
bookingWindowStatus: 'CLOSED',
windowPhase: 'PAYMENT',
scheduledDepartureDate: scheduleRow.scheduledDepartureDate,
originStationId: originYardId,
destinationStationId: destinationYardId,
});
});
it('promotes a waiting booking into the wagons an expired reservation frees', async () => {
// One reservation whose pay window lapsed, and one booking on the waiting list.
const lapsed = booking('lapsed', 50, {
status: 'SELECTED_FOR_BATCH',
paymentDeadline: new Date(Date.now() - 60_000),
});
const waiting = booking('waiting', 10, { trainScheduleId: null });
bookingsRepository.findReservedForSchedule
.mockResolvedValueOnce([lapsed]) // settleReserved sees the lapsed one
.mockResolvedValue([]); // afterwards nothing is reserved
// The day pool the top-up draws from: only the waiting booking is eligible.
bookingsRepository.findBatchPoolByCorridorDay
.mockResolvedValueOnce([waiting])
.mockResolvedValue([]);
await service.settleDueReservations(trainId);
// The lapsed reservation expired...
expect(notifier.expired).toHaveBeenCalledTimes(1);
expect((notifier.expired.mock.calls[0][0] as Booking).id).toBe('lapsed');
// ...and the waiting booking was promoted in the SAME settle, not next cycle.
expect(notifier.payNow).toHaveBeenCalledTimes(1);
expect((notifier.payNow.mock.calls[0][0] as Booking).id).toBe('waiting');
});
it('serialises concurrent settles so the same reservation is not settled twice', async () => {
const lapsed = booking('lapsed', 50, {
status: 'SELECTED_FOR_BATCH',
paymentDeadline: new Date(Date.now() - 60_000),
});
// Both callers read the reservation; the lock must stop the second from
// acting on rows the first already expired. (The PAYMENT transition and the
// tick's overdue backstop do exactly this, in the same second.)
let reads = 0;
bookingsRepository.findReservedForSchedule.mockImplementation(() => {
reads += 1;
return Promise.resolve(reads === 1 ? [lapsed] : []);
});
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([]);
await Promise.all([
service.settleDueReservations(trainId),
service.settleDueReservations(trainId),
]);
expect(notifier.expired).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -219,6 +219,14 @@ export interface BatchBoardSchedule {
export class BookingBatchService implements OnModuleInit {
private readonly logger = new Logger(BookingBatchService.name);
/**
* Serialises settle/top-up per schedule. The PAYMENT phase transition and the
* tick's overdue backstop both call settleDueReservations for the same schedule
* in the same second; without this they interleave and the top-up runs against a
* schedule whose phase has already been concluded.
*/
private readonly scheduleLocks = new Map<string, Promise<void>>();
constructor(
@InjectDataSource() private readonly dataSource: DataSource,
private readonly bookingsRepository: BookingsRepository,
@@ -1602,21 +1610,92 @@ export class BookingBatchService implements OnModuleInit {
return anySettled;
}
/** Durable settle: allocate paid / expire overdue reservations, then top up. */
/**
* Durable settle: allocate paid / expire overdue reservations, then top up the
* freed capacity from the waiting list.
*
* Serialised per schedule. Two callers race here every time a payment phase
* ends: `advanceImport`'s PAYMENT branch and the tick's `settleOverdueReservations`
* backstop. Both read the same reserved rows in the same second, so without the
* lock the second caller re-settles rows the first is mid-way through expiring,
* and `concludeCycle` observes capacity that is neither pre- nor post-expiry.
*/
async settleDueReservations(scheduleId: string): Promise<void> {
const anySettled = await this.settleReserved(scheduleId, false);
// A settle that allocated/expired anything frees or fills capacity → re-run the
// fill so the next waiting-list bookings get a fresh pay window (top-up).
if (anySettled) {
await this.withScheduleLock(scheduleId, () =>
this.settleAndTopUp(scheduleId, false),
);
}
/**
* Settle, then keep promoting the waiting list until the train can take no more.
* Returns whether anything settled.
*
* One top-up pass is not enough: expiring an N-wagon booking can free room for
* several smaller ones, and reserving those can in turn leave room for the next
* size down. Loop until a pass reserves nothing, so the batch ends with the train
* as full as the pool allows — rather than leaving a booking stranded until the
* next window cycle.
*
* Each round that opens a fresh pay window pushes `paymentPhaseEndsAt` out, so
* `concludeCycle` cannot fire before the promoted customers' deadlines.
*/
private async settleAndTopUp(
scheduleId: string,
expireUnpaidUnknownDeadline: boolean,
): Promise<boolean> {
const anySettled = await this.settleReserved(
scheduleId,
expireUnpaidUnknownDeadline,
);
if (!anySettled) return false;
this.logger.log(
`[BATCH] settle changed state on ${scheduleId} — running top-up fill for the waiting list`,
);
// Bounded: every round either reserves at least one unit (shrinking the pool)
// or breaks. The cap is a backstop against a pathological reserve/expire cycle.
let promoted = 0;
for (let round = 0; round < 10; round += 1) {
const reservedThisRound = await this.topUpFill(scheduleId);
if (reservedThisRound <= 0) break;
promoted += reservedThisRound;
await this.extendPaymentPhaseForTopUp(scheduleId);
}
if (promoted > 0) {
this.logger.log(
`[BATCH] settle changed state on ${scheduleId} — running top-up fill for the waiting list`,
`[BATCH] top-up promoted ${promoted} waiting booking(s) onto ${scheduleId} ` +
`— payment phase extended for them`,
);
const topUpReserved = await this.topUpFill(scheduleId);
// A top-up opened a fresh pay window for waiting bookings — push the
// schedule's PAYMENT phase out so the window tick's concludeCycle doesn't
// fire before those customers' new deadlines and expire them prematurely.
if (topUpReserved > 0) {
await this.extendPaymentPhaseForTopUp(scheduleId);
}
return true;
}
/**
* Run `fn` with exclusive access to `scheduleId`. Concurrent callers await the
* in-flight run rather than interleaving with it. Single-process only — a second
* API replica would need a row lock on the schedule instead.
*/
private async withScheduleLock<T>(
scheduleId: string,
fn: () => Promise<T>,
): Promise<T> {
const inFlight = this.scheduleLocks.get(scheduleId) ?? Promise.resolve();
// Chain onto the previous holder; swallow its rejection so one failure does
// not poison every later caller's lock.
const run = inFlight.catch(() => undefined).then(fn);
const gate = run.then(
() => undefined,
() => undefined,
);
this.scheduleLocks.set(scheduleId, gate);
try {
return await run;
} finally {
// Last one out clears the slot so the map does not grow without bound.
if (this.scheduleLocks.get(scheduleId) === gate) {
this.scheduleLocks.delete(scheduleId);
}
}
}
@@ -1626,11 +1705,9 @@ export class BookingBatchService implements OnModuleInit {
/** Allocate paid reservations, expire the rest, then top up. */
async settleBatch(scheduleId: string): Promise<void> {
this.removeTimeout(scheduleId);
await this.settleReserved(scheduleId, true);
const topUpReserved = await this.topUpFill(scheduleId);
if (topUpReserved > 0) {
await this.extendPaymentPhaseForTopUp(scheduleId);
}
await this.withScheduleLock(scheduleId, () =>
this.settleAndTopUp(scheduleId, true),
);
void this.triggerWagonAllocation(scheduleId);
}

View File

@@ -304,6 +304,24 @@ export class BookingWindowService implements OnModuleInit {
`(allocate paid / expire unpaid) then concluding the cycle`,
);
await this.bookingBatchService.settleDueReservations(schedule.id);
// The settle expires unpaid reservations and promotes the waiting list into
// the wagons they free. Those promoted customers get a fresh pay window, and
// `extendPaymentPhaseForTopUp` pushes `paymentPhaseEndsAt` past `now` to
// cover it. Concluding here on the STALE in-memory timestamp would end the
// cycle the top-up just extended and expire them before they could pay — so
// re-read, and stay in PAYMENT if the deadline moved.
const settled = await this.trainSchedulesRepository.findById(schedule.id);
if (settled?.paymentPhaseEndsAt && now < settled.paymentPhaseEndsAt) {
schedule.paymentPhaseEndsAt = settled.paymentPhaseEndsAt;
this.logger.log(
`[WINDOW] ${schedule.id} PAYMENT extended to ` +
`${settled.paymentPhaseEndsAt.toISOString()} — waiting-list bookings were ` +
`promoted into the freed wagons; not concluding this cycle yet`,
);
return true;
}
await this.concludeCycle(schedule, cfg, now);
return true;
}

View File

@@ -0,0 +1,118 @@
import { TrainSchedulingService } from './train-scheduling.service';
import { Booking } from '../bookings/entities/booking.entity';
import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity';
type Row = Pick<ClearanceMilestone, 'bookingId' | 'milestoneCode' | 'status'> & {
metadata?: Record<string, unknown> | null;
triggeredAt?: Date | null;
};
/**
* The gate pass is secured once per train schedule, but each booking only earns
* its GATEPASS_GRANTED milestone after settling freight payment. An unpaid
* booking must not ride a paid neighbour's grant — the train proceeds, that
* booking stays pending.
*/
function makeService(bookings: Array<Partial<Booking>>, rows: Row[]) {
const milestoneRepo = {
find: jest.fn().mockResolvedValue(rows),
save: jest.fn((row: Row) => Promise.resolve(row)),
};
const bookingRepo = { find: jest.fn().mockResolvedValue(bookings) };
const dataSource = {
getRepository: (entity: unknown) =>
entity === Booking ? bookingRepo : milestoneRepo,
};
const service = Object.create(
TrainSchedulingService.prototype,
) as TrainSchedulingService;
Object.assign(service, {
dataSource,
logger: { warn: jest.fn(), log: jest.fn() },
});
return { service, milestoneRepo };
}
/** Reach the private bridge write under test. */
function grant(service: TrainSchedulingService, at: Date): Promise<void> {
return (
service as unknown as {
completeGatepassMilestoneForSchedule(id: string, at: Date): Promise<void>;
}
).completeGatepassMilestoneForSchedule('sched-1', at);
}
const securedAt = new Date('2026-07-09T08:00:00.000Z');
describe('gate pass is withheld from bookings that have not paid freight', () => {
it('grants the paid booking and leaves the unpaid one pending', async () => {
const rows: Row[] = [
{ bookingId: 'paid', milestoneCode: 'FREIGHT_PAYMENT_SETTLED', status: 'COMPLETED' },
{ bookingId: 'paid', milestoneCode: 'GATEPASS_GRANTED', status: 'PENDING' },
{ bookingId: 'unpaid', milestoneCode: 'FREIGHT_PAYMENT_SETTLED', status: 'PENDING' },
{ bookingId: 'unpaid', milestoneCode: 'GATEPASS_GRANTED', status: 'PENDING' },
];
const { service, milestoneRepo } = makeService(
[
{ id: 'paid', status: 'CONFIRMED', paymentStatus: 'PENDING' },
{ id: 'unpaid', status: 'CONFIRMED', paymentStatus: 'PENDING' },
],
rows,
);
await grant(service, securedAt);
const saved = milestoneRepo.save.mock.calls.map(([r]: [Row]) => r);
expect(saved).toHaveLength(1);
expect(saved[0]!.bookingId).toBe('paid');
expect(saved[0]!.status).toBe('COMPLETED');
expect(saved[0]!.triggeredAt).toBe(securedAt);
const unpaid = rows.find(
(r) => r.bookingId === 'unpaid' && r.milestoneCode === 'GATEPASS_GRANTED',
);
expect(unpaid!.status).toBe('PENDING');
});
it('treats a booking paid outside the milestone path as paid', async () => {
// Some payment paths settle the invoice without writing the milestone; the
// clearance views self-heal it on read, so the gate pass must not lag.
const rows: Row[] = [
{ bookingId: 'b-1', milestoneCode: 'FREIGHT_PAYMENT_SETTLED', status: 'PENDING' },
{ bookingId: 'b-1', milestoneCode: 'GATEPASS_GRANTED', status: 'PENDING' },
];
const { service, milestoneRepo } = makeService(
[{ id: 'b-1', status: 'CONFIRMED', paymentStatus: 'PAID' }],
rows,
);
await grant(service, securedAt);
expect(milestoneRepo.save).toHaveBeenCalledTimes(1);
expect(milestoneRepo.save.mock.calls[0]![0].bookingId).toBe('b-1');
});
it('leaves an already-granted milestone untouched', async () => {
const rows: Row[] = [
{ bookingId: 'b-1', milestoneCode: 'FREIGHT_PAYMENT_SETTLED', status: 'COMPLETED' },
{ bookingId: 'b-1', milestoneCode: 'GATEPASS_GRANTED', status: 'COMPLETED' },
];
const { service, milestoneRepo } = makeService(
[{ id: 'b-1', status: 'PAID', paymentStatus: 'PAID' }],
rows,
);
await grant(service, securedAt);
expect(milestoneRepo.save).not.toHaveBeenCalled();
});
it('does nothing when the schedule carries no customs bookings', async () => {
const { service, milestoneRepo } = makeService([], []);
await grant(service, securedAt);
expect(milestoneRepo.save).not.toHaveBeenCalled();
});
});

View File

@@ -1655,6 +1655,13 @@ export class TrainSchedulingService {
* clearance views still reading that milestone (older deployed builds) see
* the gate pass as done. Drop once every clearance-api deployment reads
* ImportDjiboutiOperation.gatepassGrantedAt directly.
*
* A booking only earns its gate pass once the customer has settled the freight
* charges (FREIGHT_PAYMENT_SETTLED). The gate pass itself is secured per train
* schedule, so an unpaid booking must not ride a paid neighbour's grant: it
* keeps GATEPASS_GRANTED pending — and therefore cannot upload T1 — while the
* train and its paid bookings proceed. Re-securing the gate pass after payment
* settles picks the booking up; so does any later call to this bridge.
*/
private async completeGatepassMilestoneForSchedule(
scheduleId: string,
@@ -1666,20 +1673,49 @@ export class TrainSchedulingService {
if (bookings.length === 0) return;
const milestoneRepo = this.dataSource.getRepository(ClearanceMilestone);
const bookingIds = bookings.map((b) => b.id);
const rows = await milestoneRepo.find({
where: {
bookingId: In(bookings.map((b) => b.id)),
milestoneCode: 'GATEPASS_GRANTED',
bookingId: In(bookingIds),
milestoneCode: In(['GATEPASS_GRANTED', 'FREIGHT_PAYMENT_SETTLED']),
},
});
const paidBookingIds = new Set(
rows
.filter(
(r) => r.milestoneCode === 'FREIGHT_PAYMENT_SETTLED' && r.status === 'COMPLETED',
)
.map((r) => r.bookingId),
);
// A booking whose payment settled through a path that never wrote the
// milestone still counts as paid — the clearance views self-heal the row on
// read, and the gate pass must not lag behind that.
for (const booking of bookings) {
if (booking.paymentStatus === 'PAID' || booking.status === 'PAID') {
paidBookingIds.add(booking.id);
}
}
const skipped: string[] = [];
for (const row of rows) {
if (row.milestoneCode !== 'GATEPASS_GRANTED') continue;
if (row.status === 'COMPLETED') continue;
if (!row.bookingId || !paidBookingIds.has(row.bookingId)) {
skipped.push(row.bookingId ?? '(unknown)');
continue;
}
row.status = 'COMPLETED';
row.triggeredAt = securedAt;
row.metadata = { ...(row.metadata ?? {}), gatepassAt: securedAt.toISOString() };
await milestoneRepo.save(row);
}
if (skipped.length > 0) {
this.logger.warn(
`Gate pass secured for schedule ${scheduleId}, but ${skipped.length} booking(s) have not settled freight payment and stay pending: ${skipped.join(', ')}`,
);
}
}
async markImportReadyForLoading(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {