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