Add contract booking windows feature

This commit is contained in:
Marshal
2026-07-03 15:01:16 +00:00
parent c977deb460
commit 7eae1a920e
18 changed files with 637 additions and 80 deletions

View File

@@ -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).