mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 19:00:55 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 = {}) {
|
||||
|
||||
Reference in New Issue
Block a user