mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 09:35: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:
@@ -13,7 +13,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource, EntityManager, In } from 'typeorm';
|
||||
import { DataSource, EntityManager, In, Not } from 'typeorm';
|
||||
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
UploadImportDjiboutiDocumentDto,
|
||||
} from './dto/import-djibouti-operation.dto';
|
||||
import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto';
|
||||
import { type BookingWindowConfig } from './booking-window.config';
|
||||
import {
|
||||
buildCappedWagonPlan,
|
||||
computeFleetAvailability,
|
||||
@@ -98,7 +99,11 @@ import {
|
||||
DEFAULT_BULK_WAGON_LENGTH_METERS,
|
||||
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||||
} from './booking-batch.constants';
|
||||
import { eatDay } from './batch-window.util';
|
||||
import {
|
||||
computeExportWindowTimes,
|
||||
computeImportWindowTimes,
|
||||
eatDay,
|
||||
} from './batch-window.util';
|
||||
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
||||
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
|
||||
import { RecordCheckpointDto } from './dto/record-checkpoint.dto';
|
||||
@@ -236,9 +241,37 @@ export class TrainSchedulingService {
|
||||
if (dto.max20ftPairWeightDiffTons != null) {
|
||||
row.max20ftPairWeightDiffTons = dto.max20ftPairWeightDiffTons;
|
||||
}
|
||||
if (dto.importWindowLeadDays != null) row.importWindowLeadDays = dto.importWindowLeadDays;
|
||||
if (dto.exportBookingLeadHours != null) row.exportBookingLeadHours = dto.exportBookingLeadHours;
|
||||
if (dto.windowOpenHour != null) row.windowOpenHour = dto.windowOpenHour;
|
||||
if (dto.windowDurationHours != null) row.windowDurationHours = dto.windowDurationHours;
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Booking-window timings with hardcoded fallbacks for a missing/legacy config row.
|
||||
* Numeric columns come back from pg as strings — normalize every field.
|
||||
*/
|
||||
async getWindowConfig(): Promise<BookingWindowConfig> {
|
||||
const row = await this.loadGlobalRulesRow();
|
||||
const num = (v: unknown, fallback: number) => {
|
||||
const n = v == null ? NaN : Number(v);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
};
|
||||
return {
|
||||
importWindowLeadDays: num(row?.importWindowLeadDays, 3),
|
||||
exportBookingLeadHours: num(row?.exportBookingLeadHours, 24),
|
||||
windowOpenHour: num(row?.windowOpenHour, 8),
|
||||
windowDurationHours: num(row?.windowDurationHours, 3),
|
||||
docReviewMinutes: num(row?.docReviewMinutes, 30),
|
||||
paymentWindowMinutes: num(row?.paymentWindowMinutes, 60),
|
||||
reopenDelayMinutes: num(row?.reopenDelayMinutes, 90),
|
||||
};
|
||||
}
|
||||
|
||||
async previewTrainSchedule(dto: PreviewTrainScheduleDto) {
|
||||
const limits = await this.resolveTrainLimitConfig(dto);
|
||||
return this.buildPreviewResponse(
|
||||
@@ -310,8 +343,12 @@ export class TrainSchedulingService {
|
||||
throw new BadRequestException('A train must be pulled by at least two locomotives');
|
||||
}
|
||||
|
||||
const scheduleWarnings: string[] = [];
|
||||
const createdScheduleId = await this.dataSource.transaction(async (manager) => {
|
||||
// Lock and validate every locomotive: all must be AVAILABLE and at the origin yard.
|
||||
// Lock every locomotive. Advance scheduling is allowed: a locomotive may sit on
|
||||
// multiple future schedules and does not need to be at the origin yard yet — staff
|
||||
// plan around its arrival. Only decommissioned locomotives are hard-blocked;
|
||||
// everything else surfaces as a warning.
|
||||
const lockedLocomotives: Locomotive[] = [];
|
||||
for (const locomotiveId of locomotiveIds) {
|
||||
const locked = await manager.getRepository(Locomotive).findOne({
|
||||
@@ -321,12 +358,17 @@ export class TrainSchedulingService {
|
||||
if (!locked) {
|
||||
throw new NotFoundException(`Locomotive ${locomotiveId} not found`);
|
||||
}
|
||||
if (locked.status === 'OUT_OF_SERVICE') {
|
||||
throw new ConflictException(`Locomotive ${locked.code} is out of service`);
|
||||
}
|
||||
if (locked.status !== 'AVAILABLE') {
|
||||
throw new ConflictException(`Locomotive ${locked.code} is not available`);
|
||||
scheduleWarnings.push(
|
||||
`Locomotive ${locked.code} is currently ${locked.status}; it must be released before this train dispatches`,
|
||||
);
|
||||
}
|
||||
if (locked.currentYardId !== route.originYardId) {
|
||||
throw new ConflictException(
|
||||
`Locomotive ${locked.code} is at yard ${locked.currentYardId} but schedule originates from ${route.originYardId}`,
|
||||
scheduleWarnings.push(
|
||||
`Locomotive ${locked.code} is not at the origin yard yet; it must arrive before this train dispatches`,
|
||||
);
|
||||
}
|
||||
lockedLocomotives.push(locked);
|
||||
@@ -340,27 +382,46 @@ export class TrainSchedulingService {
|
||||
const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotives);
|
||||
// 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).
|
||||
const windowCfg = await this.getWindowConfig();
|
||||
const windowFields =
|
||||
direction === 'IMPORT'
|
||||
? {
|
||||
bookingWindowStatus: 'CLOSED',
|
||||
windowPhase: 'PRE_WINDOW',
|
||||
...computeImportWindowTimes(departure, windowCfg, new Date()),
|
||||
}
|
||||
: direction === 'EXPORT'
|
||||
? {
|
||||
bookingWindowStatus: 'CLOSED',
|
||||
windowPhase: 'PRE_WINDOW',
|
||||
...computeExportWindowTimes(departure, windowCfg),
|
||||
}
|
||||
: {};
|
||||
const schedule = manager.getRepository(TrainSchedule).create({
|
||||
trainSetId: trainSet.id,
|
||||
routeId: route.id,
|
||||
originStationId: route.originYardId,
|
||||
destinationStationId: route.destinationYardId,
|
||||
scheduledDepartureDate: new Date(dto.scheduleDate),
|
||||
scheduledDepartureDate: departure,
|
||||
status: TrainScheduleStatusEnum.Draft,
|
||||
direction,
|
||||
maxWagons: (
|
||||
await this.resolveTrainLimitConfig(dto, limitLoco)
|
||||
).maxWagonsPerTrain,
|
||||
...windowFields,
|
||||
});
|
||||
const saved = await manager.getRepository(TrainSchedule).save(schedule);
|
||||
await manager.getRepository(Locomotive).update(
|
||||
{ id: In(lockedLocomotives.map((l) => l.id)) },
|
||||
{ status: 'ASSIGNED' },
|
||||
);
|
||||
// Locomotives stay in their current status until dispatch — advance scheduling
|
||||
// must not block the locomotive from serving earlier trains.
|
||||
return saved.id;
|
||||
});
|
||||
|
||||
return this.getTrainScheduleById(createdScheduleId);
|
||||
const created = await this.getTrainScheduleById(createdScheduleId);
|
||||
return { ...created, warnings: scheduleWarnings };
|
||||
}
|
||||
|
||||
async assignBookingsToSchedule(
|
||||
@@ -778,10 +839,19 @@ export class TrainSchedulingService {
|
||||
throw new BadRequestException('Only SCHEDULED trains can be dispatched');
|
||||
}
|
||||
await this.assertImportDjiboutiMayDepart(schedule);
|
||||
// A locomotive may sit on many future schedules, but it can only pull one train
|
||||
// at a time — block dispatch while any set locomotive is out on a dispatched train.
|
||||
const setLocomotiveIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
|
||||
await this.assertLocomotivesNotDispatchedElsewhere(setLocomotiveIds, scheduleId);
|
||||
|
||||
const now = new Date();
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const trainNumber = await this.assignTrainNumber(manager, schedule);
|
||||
if (setLocomotiveIds.length) {
|
||||
await manager
|
||||
.getRepository(Locomotive)
|
||||
.update({ id: In(setLocomotiveIds) }, { status: 'ASSIGNED' });
|
||||
}
|
||||
|
||||
await this.trainSchedulesRepository.updateStatus(
|
||||
scheduleId,
|
||||
@@ -1461,6 +1531,12 @@ export class TrainSchedulingService {
|
||||
|
||||
/** Open or close a schedule's booking window (staff override). */
|
||||
async setBookingWindow(scheduleId: string, status: 'OPEN' | 'CLOSED'): Promise<void> {
|
||||
if (status === 'OPEN') {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (schedule?.bookingWindowStatus === 'FULL') {
|
||||
throw new ConflictException('Train is full — the booking window cannot be reopened');
|
||||
}
|
||||
}
|
||||
await this.dataSource
|
||||
.getRepository(TrainSchedule)
|
||||
.update(scheduleId, { bookingWindowStatus: status });
|
||||
@@ -1685,16 +1761,14 @@ export class TrainSchedulingService {
|
||||
[scheduleId, 'IN_TRANSIT', SchedulingStatus.Dispatched],
|
||||
);
|
||||
|
||||
if (schedule.trainSet?.locomotiveId) {
|
||||
const loco = await manager
|
||||
.getRepository(Locomotive)
|
||||
.findOne({ where: { id: schedule.trainSet.locomotiveId } });
|
||||
if (loco) {
|
||||
await manager.getRepository(Locomotive).update(loco.id, {
|
||||
status: 'AVAILABLE',
|
||||
currentYardId: schedule.destinationStationId,
|
||||
});
|
||||
}
|
||||
// Release every locomotive of the set (not just the legacy primary) and move it
|
||||
// to the destination yard where it physically arrived.
|
||||
const arrivedLocoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
|
||||
if (arrivedLocoIds.length) {
|
||||
await manager.getRepository(Locomotive).update(
|
||||
{ id: In(arrivedLocoIds) },
|
||||
{ status: 'AVAILABLE', currentYardId: schedule.destinationStationId },
|
||||
);
|
||||
}
|
||||
|
||||
for (const slot of schedule.trainSet?.wagons ?? []) {
|
||||
@@ -1769,11 +1843,21 @@ export class TrainSchedulingService {
|
||||
if (schedule.trainSetId) {
|
||||
await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'CANCELLED' });
|
||||
}
|
||||
// Locomotives are only ASSIGNED while out on a dispatched train. Release ours,
|
||||
// but never stomp a locomotive that is currently pulling another dispatched train.
|
||||
const cancelledLocoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
|
||||
if (cancelledLocoIds.length) {
|
||||
await manager
|
||||
.getRepository(Locomotive)
|
||||
.update({ id: In(cancelledLocoIds) }, { status: 'AVAILABLE' });
|
||||
const busyElsewhere = await this.findLocomotiveIdsDispatchedElsewhere(
|
||||
cancelledLocoIds,
|
||||
id,
|
||||
manager,
|
||||
);
|
||||
const releasable = cancelledLocoIds.filter((locoId) => !busyElsewhere.has(locoId));
|
||||
if (releasable.length) {
|
||||
await manager
|
||||
.getRepository(Locomotive)
|
||||
.update({ id: In(releasable), status: 'ASSIGNED' }, { status: 'AVAILABLE' });
|
||||
}
|
||||
}
|
||||
for (const wagon of schedule.trainSet?.wagons ?? []) {
|
||||
if (wagon.physicalWagonId) {
|
||||
@@ -2016,15 +2100,17 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
if (assignedLocomotives.length) {
|
||||
// Every locomotive of the set must sit at the origin yard, and the weakest
|
||||
// one must still be able to pull the train (min limits across the set).
|
||||
// Advance scheduling: a locomotive that hasn't reached the origin yard yet is a
|
||||
// warning (it must arrive before dispatch), but a set too weak to pull the train
|
||||
// is a hard violation.
|
||||
const offYard = assignedLocomotives.find((l) => l.currentYardId !== originYardId);
|
||||
const setLimits = minLocomotiveLimits(assignedLocomotives);
|
||||
if (offYard) {
|
||||
violations.push(
|
||||
`Locomotive ${offYard.code} is not at the schedule origin yard`,
|
||||
warnings.push(
|
||||
`Locomotive ${offYard.code} is not at the schedule origin yard yet; it must arrive before dispatch`,
|
||||
);
|
||||
} else if (
|
||||
}
|
||||
if (
|
||||
setLimits &&
|
||||
(setLimits.maxPullWeightTons < totalWeightTons ||
|
||||
setLimits.maxTrainLengthMeters < totalLengthMeters)
|
||||
@@ -2034,21 +2120,22 @@ export class TrainSchedulingService {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const availableLocomotives = (
|
||||
await this.locomotivesRepository.findAll({
|
||||
where: { status: 'AVAILABLE' },
|
||||
})
|
||||
).filter((l) => l.currentYardId === originYardId);
|
||||
if (!availableLocomotives.length) {
|
||||
violations.push('No available locomotive at the schedule origin yard');
|
||||
} else if (
|
||||
!availableLocomotives.some(
|
||||
const inServiceLocomotives = await this.locomotivesRepository.findAll({
|
||||
where: { status: Not('OUT_OF_SERVICE' as Locomotive['status']) },
|
||||
});
|
||||
if (!inServiceLocomotives.some((l) => l.currentYardId === originYardId)) {
|
||||
warnings.push(
|
||||
'No locomotive is at the schedule origin yard yet; one must arrive before dispatch',
|
||||
);
|
||||
}
|
||||
if (
|
||||
!inServiceLocomotives.some(
|
||||
(l) =>
|
||||
Number(l.maxPullWeightTons) >= totalWeightTons &&
|
||||
Number(l.maxTrainLengthMeters) >= totalLengthMeters,
|
||||
)
|
||||
) {
|
||||
violations.push('No available locomotive can support the total train weight and length');
|
||||
violations.push('No locomotive can support the total train weight and length');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2596,6 +2683,58 @@ export class TrainSchedulingService {
|
||||
return trainSet.locomotive ? [trainSet.locomotive] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Locomotive ids (among the given ones) that are attached to a DISPATCHED train
|
||||
* other than `excludeScheduleId`. Covers both the multi-loco link rows and the
|
||||
* legacy single-locomotive column on the train set.
|
||||
*/
|
||||
private async findLocomotiveIdsDispatchedElsewhere(
|
||||
locomotiveIds: string[],
|
||||
excludeScheduleId: string,
|
||||
manager?: EntityManager,
|
||||
): Promise<Set<string>> {
|
||||
if (!locomotiveIds.length) return new Set();
|
||||
const runner = manager ?? this.dataSource;
|
||||
const rows: { locomotive_id: string }[] = await runner.query(
|
||||
`SELECT DISTINCT loco.locomotive_id
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
||||
JOIN (
|
||||
SELECT tsl.train_set_id, tsl.locomotive_id
|
||||
FROM freight.train_set_locomotives tsl
|
||||
WHERE tsl.deleted_at IS NULL
|
||||
UNION
|
||||
SELECT t.id AS train_set_id, t.locomotive_id
|
||||
FROM freight.train_sets t
|
||||
WHERE t.locomotive_id IS NOT NULL
|
||||
) loco ON loco.train_set_id = tset.id
|
||||
WHERE ts.status = 'DISPATCHED'
|
||||
AND ts.deleted_at IS NULL
|
||||
AND ts.id <> $1
|
||||
AND loco.locomotive_id = ANY($2)`,
|
||||
[excludeScheduleId, locomotiveIds],
|
||||
);
|
||||
return new Set(rows.map((r) => r.locomotive_id));
|
||||
}
|
||||
|
||||
private async assertLocomotivesNotDispatchedElsewhere(
|
||||
locomotiveIds: string[],
|
||||
excludeScheduleId: string,
|
||||
): Promise<void> {
|
||||
const busy = await this.findLocomotiveIdsDispatchedElsewhere(
|
||||
locomotiveIds,
|
||||
excludeScheduleId,
|
||||
);
|
||||
if (!busy.size) return;
|
||||
const locos = await this.dataSource
|
||||
.getRepository(Locomotive)
|
||||
.find({ where: { id: In([...busy]) } });
|
||||
const codes = locos.map((l) => l.code).join(', ');
|
||||
throw new ConflictException(
|
||||
`Locomotive(s) ${codes} are currently out on another dispatched train`,
|
||||
);
|
||||
}
|
||||
|
||||
async selectOrValidateLocomotive(
|
||||
locomotiveId: string,
|
||||
totalWeightTons: number,
|
||||
@@ -2732,15 +2871,113 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
/** AVAILABLE locomotives at the route's origin yard. */
|
||||
async getAvailableLocomotivesForRoute(routeId: string): Promise<Locomotive[]> {
|
||||
/**
|
||||
* All in-service locomotives, annotated for the schedule-creation picker.
|
||||
* Advance scheduling means nothing is filtered out — staff see status, whether the
|
||||
* locomotive is at the origin yard yet, and how many future schedules it already has.
|
||||
*/
|
||||
async getAvailableLocomotivesForRoute(routeId: string) {
|
||||
const route = await this.getSchedulableRoute(routeId);
|
||||
|
||||
const locomotives = await this.locomotivesRepository.findAll({
|
||||
where: { status: 'AVAILABLE', currentYardId: route.originYardId },
|
||||
where: { status: Not('OUT_OF_SERVICE' as Locomotive['status']) },
|
||||
order: { code: 'ASC' },
|
||||
});
|
||||
|
||||
return locomotives;
|
||||
const counts: { locomotive_id: string; future_count: string }[] = locomotives.length
|
||||
? await this.dataSource.query(
|
||||
`SELECT loco.locomotive_id, COUNT(DISTINCT ts.id) AS future_count
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
||||
JOIN (
|
||||
SELECT tsl.train_set_id, tsl.locomotive_id
|
||||
FROM freight.train_set_locomotives tsl
|
||||
WHERE tsl.deleted_at IS NULL
|
||||
UNION
|
||||
SELECT t.id AS train_set_id, t.locomotive_id
|
||||
FROM freight.train_sets t
|
||||
WHERE t.locomotive_id IS NOT NULL
|
||||
) loco ON loco.train_set_id = tset.id
|
||||
WHERE ts.status IN ('DRAFT', 'SCHEDULED')
|
||||
AND ts.deleted_at IS NULL
|
||||
AND loco.locomotive_id = ANY($1)
|
||||
GROUP BY loco.locomotive_id`,
|
||||
[locomotives.map((l) => l.id)],
|
||||
)
|
||||
: [];
|
||||
const futureCounts = new Map(counts.map((c) => [c.locomotive_id, Number(c.future_count)]));
|
||||
|
||||
return locomotives.map((loco) => ({
|
||||
...loco,
|
||||
atOriginYard: loco.currentYardId === route.originYardId,
|
||||
futureScheduleCount: futureCounts.get(loco.id) ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Upcoming/open booking windows for a customer's active-contract lanes —
|
||||
* powers the portal home "booking windows" section. Only window-engine
|
||||
* schedules (IMPORT cycle / EXPORT lead) are listed; DOMESTIC trains are
|
||||
* 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(
|
||||
`SELECT DISTINCT ts.id AS schedule_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.deleted_at IS NULL
|
||||
JOIN freight.contracts c
|
||||
ON c.id = cr.contract_id
|
||||
AND c.company_id = $1
|
||||
AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED')
|
||||
AND c.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`,
|
||||
[companyId],
|
||||
);
|
||||
return rows.map((r) => ({
|
||||
scheduleId: r.schedule_id,
|
||||
direction: r.direction,
|
||||
windowPhase: r.window_phase,
|
||||
isOpenNow: r.window_phase === 'OPEN' && r.booking_window_status === 'OPEN',
|
||||
windowOpensAt: r.window_opens_at,
|
||||
windowClosesAt: r.window_closes_at,
|
||||
bookingWindowStatus: r.booking_window_status,
|
||||
bookingCycleNo: r.booking_cycle_no,
|
||||
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