mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 10:45:44 +00:00
Add booking window management and locomotive scheduling features
- Implemented migration to release stuck assigned locomotives. - Added schedule window phases to train schedules. - Created booking batch offers table for partial capacity bookings. - Developed BookingSplitService to handle partial booking offers and splits. - Introduced BookingWindowService to manage booking window lifecycle and transitions. - Added BookingBatchOffer entity to represent offers made during booking splits. - Enhanced locomotive options with warnings for scheduling. - Created UpcomingWindowsSection component to display upcoming booking windows.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
@@ -7,7 +8,7 @@ import {
|
||||
Optional,
|
||||
} from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { Cron, SchedulerRegistry } from '@nestjs/schedule';
|
||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
@@ -22,17 +23,14 @@ import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-r
|
||||
import { BookingNotifierService } from './booking-notifier.service';
|
||||
import { TrainSchedulingService } from './train-scheduling.service';
|
||||
import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util';
|
||||
import { Freight } from "@edr/types";
|
||||
import { Freight, TrainScheduleStatus as TrainScheduleStatusEnum } from "@edr/types";
|
||||
import { BillingService } from "../billing/billing.service";
|
||||
|
||||
|
||||
import {
|
||||
BATCH_CRON,
|
||||
BATCH_TIMEZONE,
|
||||
DEFAULT_BULK_WAGON_LENGTH_METERS,
|
||||
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||||
DEFAULT_WAGONS_PER_BOOKING,
|
||||
PAYMENT_WINDOW_MS,
|
||||
} from "./booking-batch.constants";
|
||||
import {
|
||||
bookingTrainLengthMeters,
|
||||
@@ -41,6 +39,7 @@ import {
|
||||
} from './train-capacity.util';
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
||||
import { BookingSplitService } from './booking-split.service';
|
||||
|
||||
/** A train's remaining capacity along the three physical limits the batch enforces. */
|
||||
interface Capacity {
|
||||
@@ -121,6 +120,13 @@ export interface BatchBoardScheduleDetail {
|
||||
scheduleDate: string | null;
|
||||
status: string;
|
||||
bookingWindowStatus: string;
|
||||
direction: string | null;
|
||||
windowPhase: string | null;
|
||||
windowOpensAt: string | null;
|
||||
windowClosesAt: string | null;
|
||||
docReviewEndsAt: string | null;
|
||||
paymentPhaseEndsAt: string | null;
|
||||
bookingCycleNo: number;
|
||||
locomotive: BatchBoardSchedule["locomotive"];
|
||||
capacity: BatchBoardSchedule["capacity"];
|
||||
counts: BatchBoardSchedule["counts"];
|
||||
@@ -138,6 +144,13 @@ export interface BatchBoardSchedule {
|
||||
scheduleDate: string | null;
|
||||
status: string;
|
||||
bookingWindowStatus: string;
|
||||
direction: string | null;
|
||||
windowPhase: string | null;
|
||||
windowOpensAt: string | null;
|
||||
windowClosesAt: string | null;
|
||||
docReviewEndsAt: string | null;
|
||||
paymentPhaseEndsAt: string | null;
|
||||
bookingCycleNo: number;
|
||||
locomotive: {
|
||||
code: string;
|
||||
name: string | null;
|
||||
@@ -189,6 +202,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
private readonly billing: BillingService,
|
||||
|
||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||
@Optional() private readonly splitService?: BookingSplitService,
|
||||
|
||||
) {}
|
||||
|
||||
@@ -284,11 +298,17 @@ export class BookingBatchService implements OnModuleInit {
|
||||
await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
|
||||
}
|
||||
|
||||
/** Distinct (origin, destination, EAT day) groups across all OPEN schedules. */
|
||||
/**
|
||||
* Distinct (origin, destination, EAT day) groups across LEGACY OPEN schedules —
|
||||
* schedules with a `windowPhase` are driven exclusively by the window engine
|
||||
* (BookingWindowService), never by the periodic legacy fill.
|
||||
*/
|
||||
private async openRouteDayGroups(): Promise<RouteDayGroup[]> {
|
||||
const open = await this.trainSchedulesRepository.findAll({
|
||||
where: { bookingWindowStatus: "OPEN" },
|
||||
});
|
||||
const open = (
|
||||
await this.trainSchedulesRepository.findAll({
|
||||
where: { bookingWindowStatus: "OPEN" },
|
||||
})
|
||||
).filter((s) => s.windowPhase == null);
|
||||
const groups = new Map<string, RouteDayGroup>();
|
||||
for (const s of open) {
|
||||
if (!s.scheduledDepartureDate) continue;
|
||||
@@ -340,6 +360,12 @@ export class BookingBatchService implements OnModuleInit {
|
||||
.update(bookingId, { paymentStatus: "PAID" });
|
||||
}
|
||||
|
||||
// Paying inside the window accepts an open partial offer — reduce the booking
|
||||
// to the offered part before it boards (remainder returns to the contract cap).
|
||||
if (this.splitService) {
|
||||
await this.splitService.applySplit(bookingId);
|
||||
}
|
||||
|
||||
const linked =
|
||||
await this.trainScheduleBookingsRepository.existsForBooking(bookingId);
|
||||
if (!linked) {
|
||||
@@ -381,6 +407,99 @@ export class BookingBatchService implements OnModuleInit {
|
||||
await this.ensurePaidBookingAllocated(bookingId);
|
||||
}
|
||||
|
||||
/** Open partial-capacity offer summary for booking detail payloads (null when none). */
|
||||
async getOpenOfferSummary(bookingId: string): Promise<{
|
||||
offeredWagons: number;
|
||||
totalWagons: number;
|
||||
offeredAmount: number;
|
||||
paymentDeadline: Date;
|
||||
} | null> {
|
||||
if (!this.splitService) return null;
|
||||
const offer = await this.splitService.findOpenOffer(bookingId);
|
||||
if (!offer) return null;
|
||||
return {
|
||||
offeredWagons: offer.offeredWagons,
|
||||
totalWagons: offer.totalWagons,
|
||||
offeredAmount: Number(offer.offeredAmount),
|
||||
paymentDeadline: offer.paymentDeadline,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- export FCFS -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Export is first-come-first-serve: no window cycle, no priority, no batch.
|
||||
* Pick the earliest open export train on the booking's corridor/day that still
|
||||
* fits the booking. Throws ConflictException when every train is full — the
|
||||
* staff accept fails and no more export bookings are taken.
|
||||
*/
|
||||
async pickExportSchedule(booking: Booking): Promise<string> {
|
||||
if (!booking.scheduledDate) {
|
||||
throw new BadRequestException('Booking has no scheduled date');
|
||||
}
|
||||
const day = eatDay(new Date(booking.scheduledDate));
|
||||
const corridor = await this.trainSchedulesRepository.findAll({
|
||||
where: [
|
||||
{
|
||||
originStationId: booking.originYardId,
|
||||
destinationStationId: booking.destinationYardId,
|
||||
status: TrainScheduleStatusEnum.Draft,
|
||||
},
|
||||
{
|
||||
originStationId: booking.originYardId,
|
||||
destinationStationId: booking.destinationYardId,
|
||||
status: TrainScheduleStatusEnum.Scheduled,
|
||||
},
|
||||
],
|
||||
});
|
||||
const candidates = corridor
|
||||
.filter(
|
||||
(s) =>
|
||||
s.scheduledDepartureDate != null &&
|
||||
eatDay(s.scheduledDepartureDate) === day &&
|
||||
this.isFillable(s),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.scheduledDepartureDate.getTime() - b.scheduledDepartureDate.getTime(),
|
||||
);
|
||||
if (!candidates.length) {
|
||||
throw new ConflictException(
|
||||
'No export train is accepting bookings for this day',
|
||||
);
|
||||
}
|
||||
|
||||
const rules = await this.loadGlobalRules();
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
const need = this.needFor(booking, wagonLengths);
|
||||
for (const candidate of candidates) {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||
candidate.id,
|
||||
);
|
||||
const locomotive = schedule?.trainSet?.locomotive;
|
||||
if (!schedule || !locomotive) continue;
|
||||
const limits = await this.capacityLimits(locomotive, rules);
|
||||
const budget = await this.remainingCapacity(schedule, limits, wagonLengths);
|
||||
if (this.fits(need, budget)) return schedule.id;
|
||||
}
|
||||
throw new ConflictException('Train is full — no export capacity left for this day');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserve an accepted export booking on its picked train and open the pay
|
||||
* window immediately (payment notification goes out on reserve). Marks the
|
||||
* train FULL when this reservation exhausts the wagon budget.
|
||||
*/
|
||||
async reserveExportBooking(booking: Booking, scheduleId: string): Promise<void> {
|
||||
await this.reserve(booking, scheduleId);
|
||||
this.armSettle(scheduleId);
|
||||
const schedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (schedule && (await this.remainingWagons(schedule)) <= 0) {
|
||||
await this.setWindow(scheduleId, 'FULL');
|
||||
}
|
||||
}
|
||||
|
||||
/** Link PAID bookings that have no train_schedule_bookings row (cron backstop). */
|
||||
async reconcilePaidUnlinked(scheduleId: string): Promise<void> {
|
||||
const unlinked =
|
||||
@@ -393,9 +512,13 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- cron entry point -----------------------------------------------------
|
||||
// ---- legacy fill entry point ----------------------------------------------
|
||||
|
||||
@Cron(BATCH_CRON, { name: "booking-batch-fill", timeZone: BATCH_TIMEZONE })
|
||||
/**
|
||||
* Legacy periodic fill for schedules without a window phase (DOMESTIC and
|
||||
* pre-migration trains). Invoked by BookingWindowService's tick — the old
|
||||
* standalone cron was replaced by the window engine.
|
||||
*/
|
||||
async runBatchFill(): Promise<void> {
|
||||
const groups = await this.openRouteDayGroups();
|
||||
this.logger.log(`Batch fill: ${groups.length} OPEN route-day group(s).`);
|
||||
@@ -595,6 +718,15 @@ export class BookingBatchService implements OnModuleInit {
|
||||
: null,
|
||||
status: s.status,
|
||||
bookingWindowStatus: s.bookingWindowStatus,
|
||||
direction: s.direction ?? null,
|
||||
windowPhase: s.windowPhase ?? null,
|
||||
windowOpensAt: s.windowOpensAt ? s.windowOpensAt.toISOString() : null,
|
||||
windowClosesAt: s.windowClosesAt ? s.windowClosesAt.toISOString() : null,
|
||||
docReviewEndsAt: s.docReviewEndsAt ? s.docReviewEndsAt.toISOString() : null,
|
||||
paymentPhaseEndsAt: s.paymentPhaseEndsAt
|
||||
? s.paymentPhaseEndsAt.toISOString()
|
||||
: null,
|
||||
bookingCycleNo: s.bookingCycleNo ?? 0,
|
||||
locomotive: loco
|
||||
? {
|
||||
code: loco.code,
|
||||
@@ -679,6 +811,15 @@ export class BookingBatchService implements OnModuleInit {
|
||||
: null,
|
||||
status: s.status,
|
||||
bookingWindowStatus: s.bookingWindowStatus,
|
||||
direction: s.direction ?? null,
|
||||
windowPhase: s.windowPhase ?? null,
|
||||
windowOpensAt: s.windowOpensAt ? s.windowOpensAt.toISOString() : null,
|
||||
windowClosesAt: s.windowClosesAt ? s.windowClosesAt.toISOString() : null,
|
||||
docReviewEndsAt: s.docReviewEndsAt ? s.docReviewEndsAt.toISOString() : null,
|
||||
paymentPhaseEndsAt: s.paymentPhaseEndsAt
|
||||
? s.paymentPhaseEndsAt.toISOString()
|
||||
: null,
|
||||
bookingCycleNo: s.bookingCycleNo ?? 0,
|
||||
locomotive: loco
|
||||
? {
|
||||
code: loco.code,
|
||||
@@ -722,11 +863,27 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
// ---- core fill ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Whether the batch engine may reserve/allocate onto this schedule right now.
|
||||
* Legacy (no window phase): the customer-facing OPEN gate doubles as the fill gate.
|
||||
* Import window cycle: the engine fills while the customer window is CLOSED —
|
||||
* during DOC_REVIEW (early staff trigger) and PAYMENT (batch run + top-ups).
|
||||
* Export: FCFS while the booking window is open.
|
||||
*/
|
||||
isFillable(schedule: TrainSchedule): boolean {
|
||||
if (schedule.bookingWindowStatus === "FULL") return false;
|
||||
if (!schedule.windowPhase) return schedule.bookingWindowStatus === "OPEN";
|
||||
if (schedule.direction === "EXPORT") {
|
||||
return schedule.windowPhase === "OPEN" && schedule.bookingWindowStatus === "OPEN";
|
||||
}
|
||||
return schedule.windowPhase === "DOC_REVIEW" || schedule.windowPhase === "PAYMENT";
|
||||
}
|
||||
|
||||
/** Fill one schedule from its priority-ordered pool until full. */
|
||||
async fillSchedule(scheduleId: string): Promise<void> {
|
||||
const schedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule || schedule.bookingWindowStatus !== "OPEN") return;
|
||||
if (!schedule || !this.isFillable(schedule)) return;
|
||||
const locomotive = schedule.trainSet?.locomotive;
|
||||
if (!schedule.trainSetId || !locomotive) {
|
||||
this.logger.warn(
|
||||
@@ -793,22 +950,33 @@ export class BookingBatchService implements OnModuleInit {
|
||||
destinationYardId: string,
|
||||
day: string,
|
||||
): Promise<string[]> {
|
||||
// The day's OPEN bookable schedules on this exact corridor, earliest first.
|
||||
const bookable = await this.trainSchedulingService.getBookableSchedules(
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
);
|
||||
const scheduleIds = bookable
|
||||
// The day's fillable schedules on this exact corridor, earliest first. Fillable
|
||||
// covers legacy OPEN trains and window-cycle trains in DOC_REVIEW/PAYMENT —
|
||||
// the batch must run while the customer window is closed.
|
||||
const corridor = await this.trainSchedulesRepository.findAll({
|
||||
where: [
|
||||
{
|
||||
originStationId: originYardId,
|
||||
destinationStationId: destinationYardId,
|
||||
status: TrainScheduleStatusEnum.Draft,
|
||||
},
|
||||
{
|
||||
originStationId: originYardId,
|
||||
destinationStationId: destinationYardId,
|
||||
status: TrainScheduleStatusEnum.Scheduled,
|
||||
},
|
||||
],
|
||||
});
|
||||
const scheduleIds = corridor
|
||||
.filter(
|
||||
(s) =>
|
||||
s.bookingWindowStatus === "OPEN" &&
|
||||
s.scheduleDate != null &&
|
||||
eatDay(new Date(s.scheduleDate)) === day,
|
||||
s.scheduledDepartureDate != null &&
|
||||
eatDay(s.scheduledDepartureDate) === day &&
|
||||
this.isFillable(s),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.scheduleDate).getTime() -
|
||||
new Date(b.scheduleDate).getTime(),
|
||||
a.scheduledDepartureDate.getTime() - b.scheduledDepartureDate.getTime(),
|
||||
)
|
||||
.map((s) => s.id);
|
||||
|
||||
@@ -870,7 +1038,33 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
// Fits no train this day — stays in the pool, retried next batch.
|
||||
// Fits no train whole. Import GENERAL-contract commercial bookings get a
|
||||
// partial-capacity offer on the train with the most free wagons: pay =
|
||||
// accept the split (remainder returns to the contract cap), no pay =
|
||||
// booking stays whole and expires for this train.
|
||||
const partialTarget = [...trains]
|
||||
.filter((t) => t.budget.wagons >= 1)
|
||||
.sort((a, b) => b.budget.wagons - a.budget.wagons)[0];
|
||||
if (
|
||||
partialTarget &&
|
||||
!booking.isGovernment &&
|
||||
booking.tradeDirection === "IMPORT" &&
|
||||
booking.contractKind === "GENERAL" &&
|
||||
this.splitService
|
||||
) {
|
||||
const offered = await this.tryPartialOffer(
|
||||
booking,
|
||||
partialTarget.id,
|
||||
partialTarget.budget,
|
||||
need,
|
||||
);
|
||||
if (offered) {
|
||||
partialTarget.budget = this.subtract(partialTarget.budget, offered);
|
||||
partialTarget.armed = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Stays in the pool, retried next batch/window cycle.
|
||||
this.notifier.unplaced(booking, day);
|
||||
continue;
|
||||
}
|
||||
@@ -893,6 +1087,62 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return trains.map((t) => t.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Offer the largest fitting part of an over-capacity booking as a partial
|
||||
* (split-on-payment). Returns the capacity the offer consumes, or null when no
|
||||
* meaningful partial fits / an offer is already open.
|
||||
*/
|
||||
private async tryPartialOffer(
|
||||
booking: Booking,
|
||||
scheduleId: string,
|
||||
budget: Capacity,
|
||||
need: Capacity,
|
||||
): Promise<Capacity | null> {
|
||||
if (!this.splitService) return null;
|
||||
if (await this.splitService.findOpenOffer(booking.id)) return null;
|
||||
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
const bulkCapacityTons = await this.loadBulkWagonCapacityTons();
|
||||
const sized = await this.splitService.sizeOffer(
|
||||
booking,
|
||||
budget.wagons,
|
||||
need.wagons,
|
||||
bulkCapacityTons,
|
||||
);
|
||||
if (!sized) return null;
|
||||
|
||||
const offeredNeed: Capacity = {
|
||||
wagons: sized.offeredWagons,
|
||||
weightTons: sized.offeredWeightTons,
|
||||
lengthMeters: bookingTrainLengthMeters(booking.freightType, sized.offeredWagons, {
|
||||
container: wagonLengths.container,
|
||||
bulk: wagonLengths.bulk,
|
||||
}),
|
||||
};
|
||||
if (!this.fits(offeredNeed, budget)) return null;
|
||||
|
||||
const deadline = new Date(Date.now() + (await this.paymentWindowMs()));
|
||||
await this.splitService.createOffer(booking, scheduleId, sized, deadline);
|
||||
// Reserve like a normal batch selection, but the partial invoice + partial
|
||||
// pay-now notification were already produced by createOffer.
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
trainScheduleId: scheduleId,
|
||||
status: "SELECTED_FOR_BATCH",
|
||||
selectedForBatchAt: new Date(),
|
||||
paymentDeadline: deadline,
|
||||
} as never);
|
||||
booking.trainScheduleId = scheduleId;
|
||||
return offeredNeed;
|
||||
}
|
||||
|
||||
private async loadBulkWagonCapacityTons(): Promise<number> {
|
||||
const cw3 = await this.dataSource
|
||||
.getRepository(WagonType)
|
||||
.findOne({ where: { code: "CW3" } });
|
||||
const capacity = cw3 ? wagonTypeDimensionsFromEntity(cw3).capacityTons : 60;
|
||||
return capacity > 0 ? capacity : 60;
|
||||
}
|
||||
|
||||
/** Durable settle: allocate paid / expire overdue reservations, then top up. */
|
||||
async settleDueReservations(scheduleId: string): Promise<void> {
|
||||
const reserved =
|
||||
@@ -1063,7 +1313,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
*/
|
||||
private async reserve(booking: Booking, scheduleId: string): Promise<void> {
|
||||
const now = new Date();
|
||||
const deadline = new Date(now.getTime() + PAYMENT_WINDOW_MS);
|
||||
const deadline = new Date(now.getTime() + (await this.paymentWindowMs()));
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
trainScheduleId: scheduleId,
|
||||
status: "SELECTED_FOR_BATCH",
|
||||
@@ -1136,6 +1386,10 @@ export class BookingBatchService implements OnModuleInit {
|
||||
selectedForBatchAt: null,
|
||||
} as never);
|
||||
booking.trainScheduleId = null;
|
||||
// An unpaid partial offer dies with the reservation — the booking stays whole.
|
||||
if (this.splitService) {
|
||||
await this.splitService.expireOpenOffer(booking.id);
|
||||
}
|
||||
// Pay window closed before settlement → expire the booking's open invoice too
|
||||
// (emits `booking.invoice.expired`). Domain owns the reaction; billing stays
|
||||
// source-agnostic.
|
||||
@@ -1359,7 +1613,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return (schedule.maxWagons ?? 0) - used;
|
||||
}
|
||||
|
||||
private async setWindow(
|
||||
async setWindow(
|
||||
scheduleId: string,
|
||||
status: "OPEN" | "FULL" | "CLOSED",
|
||||
): Promise<void> {
|
||||
@@ -1368,22 +1622,48 @@ export class BookingBatchService implements OnModuleInit {
|
||||
.update(scheduleId, { bookingWindowStatus: status });
|
||||
}
|
||||
|
||||
/** No wagon slots left for allocated + reserved bookings. */
|
||||
async isScheduleFull(scheduleId: string): Promise<boolean> {
|
||||
const schedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) return false;
|
||||
return (await this.remainingWagons(schedule)) <= 0;
|
||||
}
|
||||
|
||||
// ---- timer plumbing -------------------------------------------------------
|
||||
|
||||
/** Configured customer pay window in ms (global rules, with defaults). */
|
||||
private async paymentWindowMs(): Promise<number> {
|
||||
const cfg = await this.trainSchedulingService.getWindowConfig();
|
||||
return cfg.paymentWindowMinutes * 60_000;
|
||||
}
|
||||
|
||||
private timeoutName(scheduleId: string): string {
|
||||
return `settle:${scheduleId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-process accelerator only — the durable settle enforcement is the window
|
||||
* engine's minute tick calling settleDueReservations off `paymentDeadline`.
|
||||
*/
|
||||
private armSettle(scheduleId: string): void {
|
||||
this.removeTimeout(scheduleId);
|
||||
const handle = setTimeout(() => {
|
||||
void this.settleBatch(scheduleId).catch((err) =>
|
||||
this.logger.error(
|
||||
`settleBatch ${scheduleId} failed: ${(err as Error).message}`,
|
||||
void this.paymentWindowMs()
|
||||
.then((delayMs) => {
|
||||
this.removeTimeout(scheduleId);
|
||||
const handle = setTimeout(() => {
|
||||
void this.settleBatch(scheduleId).catch((err) =>
|
||||
this.logger.error(
|
||||
`settleBatch ${scheduleId} failed: ${(err as Error).message}`,
|
||||
),
|
||||
);
|
||||
}, delayMs);
|
||||
this.scheduler.addTimeout(this.timeoutName(scheduleId), handle);
|
||||
})
|
||||
.catch((err) =>
|
||||
this.logger.warn(
|
||||
`armSettle ${scheduleId} skipped: ${(err as Error).message}`,
|
||||
),
|
||||
);
|
||||
}, PAYMENT_WINDOW_MS);
|
||||
this.scheduler.addTimeout(this.timeoutName(scheduleId), handle);
|
||||
}
|
||||
|
||||
private removeTimeout(scheduleId: string): void {
|
||||
|
||||
Reference in New Issue
Block a user