feat: implement shipping line booking completion functionality

- Added ShippingLineBookingCompletionController and associated service to handle the completion of shipping line bookings.
- Introduced a new module for booking completion to maintain module separation and avoid cyclic dependencies.
- Updated the train scheduling global rules to set default desk hours to 24 hours.
- Modified existing services and entities to accommodate the new booking completion logic.
- Enhanced the front-end components to support the new booking completion flow, including updates to the booking detail and bookings pages.
- Implemented validation and error handling for booking completion, ensuring that only approved bookings can be completed.
- Added migration to set default desk hours in the database.
This commit is contained in:
Marshal
2026-08-13 15:10:27 +00:00
parent 5837ac7b6e
commit 0e00a98ef3
18 changed files with 681 additions and 389 deletions

View File

@@ -57,9 +57,10 @@ export class TrainSchedulingGlobalRules extends BaseEntity {
/**
* Local (Africa/Addis_Ababa) hour the booking desk shuts each day. A not-yet-full
* train whose next cycle would reopen at/after this hour pauses until the next
* morning's windowOpenHour. Set equal to windowOpenHour for a 24-hour desk.
* morning's windowOpenHour. Set equal to windowOpenHour for a 24-hour desk
* (the default).
*/
@Column({ name: 'window_close_hour', type: 'int', default: 17 })
@Column({ name: 'window_close_hour', type: 'int', default: 8 })
windowCloseHour!: number;
// Stored in hours; 4 decimals so sub-minute UI durations (4 min = 0.0667h)

View File

@@ -471,7 +471,11 @@ export class TrainSchedulingService {
private async emitWindowState(scheduleId: string): Promise<void> {
try {
const fresh = await this.trainSchedulesRepository.findById(scheduleId);
if (fresh) this.bookingWindowGateway.emitPhase(fresh);
// Dedicated shipping-line departures are never announced to the portal —
// the broadcast reaches every customer client.
if (fresh && !fresh.shippingLineCompanyId) {
this.bookingWindowGateway.emitPhase(fresh);
}
} catch (err) {
this.logger.warn(
`Booking-window push failed for ${scheduleId}: ${(err as Error).message}`,
@@ -512,7 +516,11 @@ export class TrainSchedulingService {
// and a newborn anchoring to it would inherit that dead window verbatim.
.andWhere('s.status != :cancelledStatus', {
cancelledStatus: TrainScheduleStatusEnum.Cancelled,
});
})
// A dedicated shipping-line departure is never a sibling either: it runs
// no window cycle, so it must neither anchor a customer group nor be
// dragged through one's open/doc-review/payment instants.
.andWhere('s.shippingLineCompanyId IS NULL');
if (excludeScheduleId) {
qb.andWhere('s.id != :excludeScheduleId', { excludeScheduleId });
}
@@ -1612,8 +1620,11 @@ export class TrainSchedulingService {
// doc-review/payment phase — so there is no cross-expiry to fix, and two
// export trains departing the same day at different times must keep their
// own departure-anchored windows.
// Dedicated shipping-line departures never group either: they run no
// window cycle at all, so sharing a customer group's timeline (or
// anchoring one) would drag them into phases they must not have.
const groupAnchor =
direction === 'EXPORT'
direction === 'EXPORT' || dto.shippingLineCompanyId
? null
: await this.findGroupWindowAnchor(
manager,
@@ -1678,45 +1689,71 @@ export class TrainSchedulingService {
// only re-derives NOT-YET-OPEN schedules (see restampPendingWindows); an
// already-open schedule keeps this snapshot, and the batch board draws its
// windows from it rather than the live config.
const ruleSnapshot = windowRuleSnapshot(windowCfg);
const computedTimes =
direction === 'EXPORT'
? { ...ruleSnapshot, ...computeExportWindowTimes(departure, windowCfg) }
: {
// IMPORT and DOMESTIC share the import booking-day window cycle.
...ruleSnapshot,
...computeImportWindowTimes(departure, windowCfg, new Date()),
};
// Inside-lead departure (e.g. a huge configured lead): the raw open lands
// in the past — clamp it to `now` so the window tick opens it immediately.
if (computedTimes.windowOpensAt.getTime() < Date.now()) {
computedTimes.windowOpensAt = new Date();
let windowFields: Partial<TrainSchedule>;
if (dto.shippingLineCompanyId) {
// Dedicated shipping-line departure: NO window cycle at all. The line
// books whenever it wants from creation until the close offset before
// departure. windowPhase stays NULL, so the window engine, restamp and
// the customer window lists all skip this schedule; the close-offset
// gate is enforced by the shipping-line completion path, which reads
// windowClosesAt stamped here.
const offsetMinutes = windowCfg.importCloseOffsetMinutes ?? 0;
const closesAt = new Date(departure.getTime() - offsetMinutes * 60_000);
if (closesAt.getTime() <= Date.now()) {
throw new BadRequestException(
'With the booking-close offset applied, this departure would already be ' +
'closed for shipping-line booking — pick a later departure.',
);
}
windowFields = {
bookingWindowStatus: 'OPEN',
windowPhase: null,
windowOpensAt: new Date(),
windowClosesAt: closesAt,
ruleImportCloseOffsetMinutes: offsetMinutes || null,
windowRuleCustom: dto.windowRule != null,
};
} else {
const ruleSnapshot = windowRuleSnapshot(windowCfg);
const computedTimes =
direction === 'EXPORT'
? { ...ruleSnapshot, ...computeExportWindowTimes(departure, windowCfg) }
: {
// IMPORT and DOMESTIC share the import booking-day window cycle.
...ruleSnapshot,
...computeImportWindowTimes(departure, windowCfg, new Date()),
};
// Inside-lead departure (e.g. a huge configured lead): the raw open lands
// in the past — clamp it to `now` so the window tick opens it immediately.
if (computedTimes.windowOpensAt.getTime() < Date.now()) {
computedTimes.windowOpensAt = new Date();
}
if (
computedTimes.windowOpensAt.getTime() >= computedTimes.windowClosesAt.getTime()
) {
throw new BadRequestException(
'These booking-window settings leave no window before departure — with the ' +
'desk hours and close offset applied, the window would only open once the ' +
'train has left.',
);
}
windowFields = {
bookingWindowStatus: 'CLOSED',
windowPhase: 'PRE_WINDOW',
...(groupAnchor
? this.groupWindowFieldsFrom(groupAnchor, departure)
: computedTimes),
// `windowRuleSnapshot` never stamps the pay window (NULL = follow the
// live global value for the direction), so an explicit staff override is
// persisted here — the same field the post-creation override writes.
...(dto.windowRule?.paymentWindowMinutes !== undefined
? { rulePaymentWindowMinutes: dto.windowRule.paymentWindowMinutes }
: {}),
// Hand-configured windows opt OUT of the global re-stamp, or the next
// global-rules edit would overwrite exactly what staff chose here.
windowRuleCustom: dto.windowRule != null,
};
}
if (
computedTimes.windowOpensAt.getTime() >= computedTimes.windowClosesAt.getTime()
) {
throw new BadRequestException(
'These booking-window settings leave no window before departure — with the ' +
'desk hours and close offset applied, the window would only open once the ' +
'train has left.',
);
}
const windowFields = {
bookingWindowStatus: 'CLOSED',
windowPhase: 'PRE_WINDOW',
...(groupAnchor
? this.groupWindowFieldsFrom(groupAnchor, departure)
: computedTimes),
// `windowRuleSnapshot` never stamps the pay window (NULL = follow the
// live global value for the direction), so an explicit staff override is
// persisted here — the same field the post-creation override writes.
...(dto.windowRule?.paymentWindowMinutes !== undefined
? { rulePaymentWindowMinutes: dto.windowRule.paymentWindowMinutes }
: {}),
// Hand-configured windows opt OUT of the global re-stamp, or the next
// global-rules edit would overwrite exactly what staff chose here.
windowRuleCustom: dto.windowRule != null,
};
// A built train's own consist is the schedule's capacity: full when all
// its wagons are allocated. Trains built without wagons yet fall back to
// the configured limit.