mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 08:20:58 +00:00
Add contract booking windows feature
This commit is contained in:
@@ -1052,16 +1052,11 @@ export class BookingTransitionService {
|
||||
// only reserve once both partners are FULLY_EXECUTED (handled inside).
|
||||
const fresh = await this.bookingsService.findById(booking.id);
|
||||
await this.bookingBatchService.acceptExportBooking(fresh);
|
||||
} else if (booking.tradeDirection === "IMPORT") {
|
||||
// Import bookings wait for their booking-day window cycle — the batch runs
|
||||
// after staff document review, never at accept time.
|
||||
} else if (booking.scheduledDate) {
|
||||
this.bookingBatchService.enqueueRouteDayProcessing(
|
||||
booking.originYardId,
|
||||
booking.destinationYardId,
|
||||
eatDay(new Date(booking.scheduledDate)),
|
||||
);
|
||||
}
|
||||
// IMPORT and DOMESTIC bookings wait for their booking-day window cycle — the
|
||||
// batch runs after the window closes + staff document review, never at accept
|
||||
// time. (Legacy pre-migration schedules with no window phase are still served
|
||||
// by the periodic legacy fill.)
|
||||
return this.bookingsService.findById(booking.id);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { TrainScheduleStatus as TrainScheduleStatusEnum } from '@edr/types';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { BookingBatchService } from './booking-batch.service';
|
||||
import { TrainSchedulingService } from './train-scheduling.service';
|
||||
import { BATCH_TIMEZONE } from './booking-batch.constants';
|
||||
@@ -19,12 +20,13 @@ import { type BookingWindowConfig } from './booking-window.config';
|
||||
* schedule row, so every transition is derived purely from the clock — a restart
|
||||
* resumes mid-phase with no loss (onModuleInit runs one tick immediately).
|
||||
*
|
||||
* Import phases: PRE_WINDOW → OPEN (customers book) → DOC_REVIEW (staff accept
|
||||
* documents) → PAYMENT (batch reserves in priority order, customers pay) →
|
||||
* reopen same day | CLOSED_FOR_DAY | DONE (full → auto-finalized).
|
||||
* Import & domestic phases: PRE_WINDOW → OPEN (customers book) → DOC_REVIEW
|
||||
* (staff accept documents) → PAYMENT (batch reserves in priority order, customers
|
||||
* pay) → reopen same day | CLOSED_FOR_DAY | DONE (full → auto-finalized).
|
||||
* Export phases: PRE_WINDOW → OPEN → DONE (no batch, no priority).
|
||||
* Legacy/DOMESTIC schedules have windowPhase NULL and are served by the legacy
|
||||
* fill (runBatchFill), which this tick invokes every 5th minute.
|
||||
* Only PRE-MIGRATION rows have windowPhase NULL; those are served by the legacy
|
||||
* fill (runBatchFill), which this tick invokes every 5th minute. New schedules of
|
||||
* every direction get a window phase.
|
||||
*/
|
||||
@Injectable()
|
||||
export class BookingWindowService implements OnModuleInit {
|
||||
@@ -37,6 +39,7 @@ export class BookingWindowService implements OnModuleInit {
|
||||
private readonly trainSchedulesRepository: TrainSchedulesRepository,
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
private readonly trainSchedulingService: TrainSchedulingService,
|
||||
private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
@@ -156,6 +159,7 @@ export class BookingWindowService implements OnModuleInit {
|
||||
await this.bookingBatchService.setWindow(schedule.id, 'OPEN');
|
||||
schedule.bookingWindowStatus = 'OPEN';
|
||||
}
|
||||
await this.notifyWindowOpened(schedule);
|
||||
this.logger.log(`Export booking window opened for schedule ${schedule.id}`);
|
||||
return true;
|
||||
}
|
||||
@@ -193,6 +197,8 @@ export class BookingWindowService implements OnModuleInit {
|
||||
await this.bookingBatchService.setWindow(schedule.id, 'OPEN');
|
||||
schedule.bookingWindowStatus = 'OPEN';
|
||||
}
|
||||
// Only announce the first opening of the day; reopen cycles don't re-notify.
|
||||
if (schedule.bookingCycleNo === 1) await this.notifyWindowOpened(schedule);
|
||||
this.logger.log(
|
||||
`Import booking window opened for schedule ${schedule.id} (cycle ${schedule.bookingCycleNo})`,
|
||||
);
|
||||
@@ -325,6 +331,67 @@ export class BookingWindowService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SMS + email every active-contract customer on this schedule's route when its
|
||||
* booking window opens, so they can book from the portal home before it closes.
|
||||
* Fire-and-forget; a failed notification never blocks the window transition.
|
||||
*/
|
||||
private async notifyWindowOpened(schedule: TrainSchedule): Promise<void> {
|
||||
try {
|
||||
const rows: Array<{ phone: string | null; email: string | null }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT DISTINCT
|
||||
COALESCE(co.contact_person_phone, co.phone) AS phone,
|
||||
COALESCE(co.email, co.general_manager_email) AS email
|
||||
FROM freight.contract_routes cr
|
||||
JOIN freight.contracts c
|
||||
ON c.id = cr.contract_id
|
||||
AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED')
|
||||
AND c.deleted_at IS NULL
|
||||
JOIN freight.companies co ON co.id = c.company_id
|
||||
WHERE cr.origin_yard_id = $1
|
||||
AND cr.destination_yard_id = $2
|
||||
AND cr.deleted_at IS NULL`,
|
||||
[schedule.originStationId, schedule.destinationStationId],
|
||||
);
|
||||
if (!rows.length) return;
|
||||
|
||||
const closes = schedule.windowClosesAt
|
||||
? schedule.windowClosesAt.toLocaleString('en-GB', { timeZone: BATCH_TIMEZONE })
|
||||
: 'later today';
|
||||
const depart = schedule.scheduledDepartureDate.toLocaleDateString('en-GB', {
|
||||
timeZone: BATCH_TIMEZONE,
|
||||
});
|
||||
const msg =
|
||||
`Booking is now open for the train departing ${depart}. ` +
|
||||
`Book your shipment from the portal home page before ${closes} EAT.`;
|
||||
|
||||
const seenPhone = new Set<string>();
|
||||
const seenEmail = new Set<string>();
|
||||
for (const r of rows) {
|
||||
if (r.phone && !seenPhone.has(r.phone)) {
|
||||
seenPhone.add(r.phone);
|
||||
await this.notifications
|
||||
.directSend('sms', r.phone, msg)
|
||||
.catch((e) => this.logger.warn(`Window-open SMS failed: ${(e as Error).message}`));
|
||||
}
|
||||
if (r.email && !seenEmail.has(r.email)) {
|
||||
seenEmail.add(r.email);
|
||||
await this.notifications
|
||||
.directSend('email', r.email, msg)
|
||||
.catch((e) => this.logger.warn(`Window-open email failed: ${(e as Error).message}`));
|
||||
}
|
||||
}
|
||||
this.logger.log(
|
||||
`Notified ${seenPhone.size} phone / ${seenEmail.size} email contacts of open window for schedule ${schedule.id}`,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`notifyWindowOpened failed for ${schedule.id}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async setPhase(
|
||||
schedule: TrainSchedule,
|
||||
patch: Partial<
|
||||
|
||||
@@ -70,6 +70,17 @@ export class TrainSchedulingController {
|
||||
return this.trainSchedulingService.getBookingWindowsForCompany(companyId);
|
||||
}
|
||||
|
||||
@Get("contracts/:contractId/booking-windows")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Upcoming/open booking windows on a contract's routes — gates the booking form for customer + Ethiopian GL",
|
||||
})
|
||||
getContractBookingWindows(
|
||||
@Param("contractId", ParseUUIDPipe) contractId: string,
|
||||
) {
|
||||
return this.trainSchedulingService.getBookingWindowsForContract(contractId);
|
||||
}
|
||||
|
||||
@Get("global-rules")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: "Get global train scheduling rules (singleton)" })
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
@@ -168,8 +169,27 @@ const DEFAULT_TRAIN_LIMITS: Required<TrainLimitConfig> = {
|
||||
max20ftPairWeightDiffTons: 10,
|
||||
};
|
||||
|
||||
/** Raw row shape for the booking-window queries (company- and contract-scoped). */
|
||||
interface BookingWindowRow {
|
||||
schedule_id: string;
|
||||
contract_id: string | null;
|
||||
direction: string | null;
|
||||
window_phase: string | null;
|
||||
window_opens_at: Date | null;
|
||||
window_closes_at: Date | null;
|
||||
booking_window_status: string;
|
||||
booking_cycle_no: number;
|
||||
scheduled_departure_date: Date;
|
||||
origin_label: string | null;
|
||||
origin_code: string | null;
|
||||
destination_label: string | null;
|
||||
destination_code: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TrainSchedulingService {
|
||||
private readonly logger = new Logger(TrainSchedulingService.name);
|
||||
|
||||
constructor(
|
||||
@InjectDataSource()
|
||||
private readonly dataSource: DataSource,
|
||||
@@ -248,7 +268,68 @@ export class TrainSchedulingService {
|
||||
if (dto.docReviewMinutes != null) row.docReviewMinutes = dto.docReviewMinutes;
|
||||
if (dto.paymentWindowMinutes != null) row.paymentWindowMinutes = dto.paymentWindowMinutes;
|
||||
if (dto.reopenDelayMinutes != null) row.reopenDelayMinutes = dto.reopenDelayMinutes;
|
||||
return this.dataSource.getRepository(TrainSchedulingGlobalRules).save(row);
|
||||
|
||||
// Fields that change the STAMPED open/close times of a schedule. docReview/
|
||||
// payment/reopen are read live by the cron each tick, so they need no
|
||||
// re-stamp; only the four below feed computeImport/ExportWindowTimes.
|
||||
const windowTimingChanged =
|
||||
dto.importWindowLeadDays != null ||
|
||||
dto.windowOpenHour != null ||
|
||||
dto.windowDurationHours != null ||
|
||||
dto.exportBookingLeadHours != null;
|
||||
|
||||
const saved = await this.dataSource
|
||||
.getRepository(TrainSchedulingGlobalRules)
|
||||
.save(row);
|
||||
|
||||
// The cron reads config fresh every tick, so derived timings (doc review,
|
||||
// payment, reopen) take effect on the next tick with no restart. But each
|
||||
// schedule's initial open/close times were FROZEN at creation — re-stamp the
|
||||
// ones whose window has not opened yet so a config edit applies to them too.
|
||||
if (windowTimingChanged) {
|
||||
await this.restampPendingWindows();
|
||||
}
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-derive windowOpensAt/windowClosesAt for schedules whose booking window has
|
||||
* not opened yet (windowPhase === 'PRE_WINDOW', still Draft/Scheduled, departure
|
||||
* in the future) using the CURRENT global-rules config. Schedules already OPEN or
|
||||
* past their window are left untouched — customers may have booked against the
|
||||
* times they were shown, so those stay frozen. Returns the count re-stamped.
|
||||
*/
|
||||
async restampPendingWindows(): Promise<number> {
|
||||
const cfg = await this.getWindowConfig();
|
||||
const now = new Date();
|
||||
const schedules = await this.trainSchedulesRepository.findAll({
|
||||
where: [
|
||||
{ status: TrainScheduleStatusEnum.Draft, windowPhase: 'PRE_WINDOW' },
|
||||
{ status: TrainScheduleStatusEnum.Scheduled, windowPhase: 'PRE_WINDOW' },
|
||||
],
|
||||
});
|
||||
|
||||
const repo = this.dataSource.getRepository(TrainSchedule);
|
||||
let restamped = 0;
|
||||
for (const s of schedules) {
|
||||
if (!s.scheduledDepartureDate || s.scheduledDepartureDate <= now) continue;
|
||||
const times =
|
||||
s.direction === 'EXPORT'
|
||||
? computeExportWindowTimes(s.scheduledDepartureDate, cfg)
|
||||
: computeImportWindowTimes(s.scheduledDepartureDate, cfg, now);
|
||||
await repo.update(s.id, {
|
||||
windowOpensAt: times.windowOpensAt,
|
||||
windowClosesAt: times.windowClosesAt,
|
||||
});
|
||||
restamped += 1;
|
||||
}
|
||||
if (restamped > 0) {
|
||||
this.logger.log(
|
||||
`Re-stamped booking windows for ${restamped} pending schedule(s) after a global-rules change`,
|
||||
);
|
||||
}
|
||||
return restamped;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -383,24 +464,24 @@ export class TrainSchedulingService {
|
||||
// Effective capacity is capped by the weakest locomotive in the set.
|
||||
const limitLoco = minLocomotiveLimits(lockedLocomotives) ?? undefined;
|
||||
const departure = new Date(dto.scheduleDate);
|
||||
// IMPORT/EXPORT trains start with a CLOSED customer window; the window engine
|
||||
// opens it on schedule (import: booking day at 08:00 EAT; export: 24h lead).
|
||||
// DOMESTIC keeps the legacy always-OPEN behavior (windowPhase stays NULL).
|
||||
// Every schedule starts with a CLOSED customer window; the window engine opens
|
||||
// it on schedule. DOMESTIC runs the same one-booking-day cycle as IMPORT
|
||||
// (opens at 08:00 EAT `importWindowLeadDays` before departure); EXPORT opens
|
||||
// 24h before departure (FCFS). No schedule is ever always-open now.
|
||||
const windowCfg = await this.getWindowConfig();
|
||||
const windowFields =
|
||||
direction === 'IMPORT'
|
||||
direction === 'EXPORT'
|
||||
? {
|
||||
bookingWindowStatus: 'CLOSED',
|
||||
windowPhase: 'PRE_WINDOW',
|
||||
...computeImportWindowTimes(departure, windowCfg, new Date()),
|
||||
...computeExportWindowTimes(departure, windowCfg),
|
||||
}
|
||||
: direction === 'EXPORT'
|
||||
? {
|
||||
bookingWindowStatus: 'CLOSED',
|
||||
windowPhase: 'PRE_WINDOW',
|
||||
...computeExportWindowTimes(departure, windowCfg),
|
||||
}
|
||||
: {};
|
||||
: {
|
||||
// IMPORT and DOMESTIC share the import booking-day window cycle.
|
||||
bookingWindowStatus: 'CLOSED',
|
||||
windowPhase: 'PRE_WINDOW',
|
||||
...computeImportWindowTimes(departure, windowCfg, new Date()),
|
||||
};
|
||||
const schedule = manager.getRepository(TrainSchedule).create({
|
||||
trainSetId: trainSet.id,
|
||||
routeId: route.id,
|
||||
@@ -2921,21 +3002,9 @@ export class TrainSchedulingService {
|
||||
* always open and need no announcement.
|
||||
*/
|
||||
async getBookingWindowsForCompany(companyId: string) {
|
||||
const rows: Array<{
|
||||
schedule_id: string;
|
||||
direction: string | null;
|
||||
window_phase: string | null;
|
||||
window_opens_at: Date | null;
|
||||
window_closes_at: Date | null;
|
||||
booking_window_status: string;
|
||||
booking_cycle_no: number;
|
||||
scheduled_departure_date: Date;
|
||||
origin_label: string | null;
|
||||
origin_code: string | null;
|
||||
destination_label: string | null;
|
||||
destination_code: string | null;
|
||||
}> = await this.dataSource.query(
|
||||
const rows: Array<BookingWindowRow> = await this.dataSource.query(
|
||||
`SELECT DISTINCT ts.id AS schedule_id,
|
||||
cr.contract_id AS contract_id,
|
||||
ts.direction,
|
||||
ts.window_phase,
|
||||
ts.window_opens_at,
|
||||
@@ -2965,8 +3034,50 @@ export class TrainSchedulingService {
|
||||
ORDER BY ts.window_opens_at ASC NULLS LAST`,
|
||||
[companyId],
|
||||
);
|
||||
return rows.map((r) => ({
|
||||
return rows.map((r) => this.mapBookingWindowRow(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* Upcoming/open booking windows on a single contract's routes. Used to gate the
|
||||
* booking form for the customer AND Ethiopian GL (who books on the customer's
|
||||
* behalf): no window row with isOpenNow=true → booking entry is hidden.
|
||||
*/
|
||||
async getBookingWindowsForContract(contractId: string) {
|
||||
const rows: Array<BookingWindowRow> = await this.dataSource.query(
|
||||
`SELECT DISTINCT ts.id AS schedule_id,
|
||||
cr.contract_id AS contract_id,
|
||||
ts.direction,
|
||||
ts.window_phase,
|
||||
ts.window_opens_at,
|
||||
ts.window_closes_at,
|
||||
ts.booking_window_status,
|
||||
ts.booking_cycle_no,
|
||||
ts.scheduled_departure_date,
|
||||
oy.label AS origin_label, oy.code AS origin_code,
|
||||
dy.label AS destination_label, dy.code AS destination_code
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.contract_routes cr
|
||||
ON cr.origin_yard_id = ts.origin_station_id
|
||||
AND cr.destination_yard_id = ts.destination_station_id
|
||||
AND cr.contract_id = $1
|
||||
AND cr.deleted_at IS NULL
|
||||
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
||||
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
|
||||
WHERE ts.deleted_at IS NULL
|
||||
AND ts.status IN ('DRAFT', 'SCHEDULED')
|
||||
AND ts.window_phase IS NOT NULL
|
||||
AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY')
|
||||
AND ts.scheduled_departure_date >= now()
|
||||
ORDER BY ts.window_opens_at ASC NULLS LAST`,
|
||||
[contractId],
|
||||
);
|
||||
return rows.map((r) => this.mapBookingWindowRow(r));
|
||||
}
|
||||
|
||||
private mapBookingWindowRow(r: BookingWindowRow) {
|
||||
return {
|
||||
scheduleId: r.schedule_id,
|
||||
contractId: r.contract_id,
|
||||
direction: r.direction,
|
||||
windowPhase: r.window_phase,
|
||||
isOpenNow: r.window_phase === 'OPEN' && r.booking_window_status === 'OPEN',
|
||||
@@ -2977,7 +3088,7 @@ export class TrainSchedulingService {
|
||||
departureDate: r.scheduled_departure_date,
|
||||
origin: r.origin_label ?? r.origin_code ?? null,
|
||||
destination: r.destination_label ?? r.destination_code ?? null,
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
||||
/** OPEN schedules a new booking may target (with rough remaining capacity).
|
||||
|
||||
Reference in New Issue
Block a user