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

@@ -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);
}