feat: enhance booking and scheduling features with shipping line support and improved search functionality

This commit is contained in:
Marshal
2026-08-16 08:18:40 +00:00
parent 9f53114778
commit 5ce5cbe29d
8 changed files with 225 additions and 75 deletions

View File

@@ -1821,5 +1821,31 @@ describe('TrainSchedulingService', () => {
expect(written.windowPhase).toBeUndefined();
expect(written.windowOpensAt).toBeUndefined();
});
it('moves an OPEN export close to the new departure but keeps the open', async () => {
const opensAt = new Date('2027-06-19T03:00:00.000Z');
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(
doneExportSchedule({
windowPhase: 'OPEN',
bookingWindowStatus: 'OPEN',
windowOpensAt: opensAt,
windowClosesAt: new Date('2027-06-20T03:00:00.000Z'),
}),
);
// Departure pushed 3 days later → close = new departure 120min; the
// open customers already booked against stays untouched.
await service.maintenanceReschedule('sch-done', {
newDepartureDate: '2027-06-23T05:00:00.000Z',
} as never);
const written = scheduleUpdate.mock.calls[0][1];
expect(written.scheduledDepartureDate).toEqual(
new Date('2027-06-23T05:00:00.000Z'),
);
expect(written.windowClosesAt).toEqual(new Date('2027-06-23T03:00:00.000Z'));
expect(written.windowOpensAt).toBeUndefined();
expect(written.windowPhase).toBeUndefined();
});
});
});

View File

@@ -164,6 +164,8 @@ import {
} from '../booking-batch.constants';
import { orderConsistWagons } from '../consist-order.util';
import {
bookingCloseCutoff,
clampCloseToOfficeHours,
computeExportWindowTimes,
computeImportWindowTimes,
earliestSchedulableDeparture,
@@ -473,7 +475,7 @@ export class TrainSchedulingService {
* used for lifecycle changes outside the window tick (create, cancel,
* finalize, restamp). A push failure must never break the mutation.
*/
private async emitWindowState(scheduleId: string): Promise<void> {
async emitWindowState(scheduleId: string): Promise<void> {
try {
const fresh = await this.trainSchedulesRepository.findById(scheduleId);
// Dedicated shipping-line departures are never announced to the portal —
@@ -1159,66 +1161,73 @@ export class TrainSchedulingService {
}
/**
* Maintenance reschedule: the admin moves a train (with everything aboard) to
* a new departure. Unlike {@link updateScheduleDate} this runs at ANY window
* phase and inside the booking lead window — a maintenance move is an
* operational fact, not a planning choice. What moves and what stays:
* Booking-window fields that must follow a train's departure moving to
* `departure` (any window phase). Shared by every reschedule path so the
* "closes in" countdown always tracks the real departure.
*
* - MOVES: scheduledDepartureDate; scheduledArrivalDate (same delta); every
* aboard/targeted booking's scheduledDate (the day-pool queries key on it,
* so a booking left on the old day would fall out of its own train's pool).
* - STAYS: train set, wagon assignments, schedule↔booking links, route,
* maxWagons, and the window RULE snapshot. Stamped window times are only
* re-derived for PRE_WINDOW schedules (their window hasn't run yet); a
* schedule mid- or post-window keeps its timeline untouched.
* PRE_WINDOW: the stamped open/close were derived from the old departure
* and the window hasn't opened yet, so re-derive them from the schedule's
* own rule snapshot against the new date (joining the target day's route
* group timeline when one exists, exactly like updateScheduleDate).
*
* Customers of every moved booking are notified (maintenanceMoved).
* DONE: the window already finished (e.g. the close offset hit and then the
* train was moved to a later departure). The window must follow the new
* departure, so it REOPENS: re-derive open/close the same way, reset the
* phase to PRE_WINDOW and clamp a past open into the present so the tick
* opens it immediately. A FULL train stays closed — there is nothing left
* to sell — and so does one whose re-derived window would already be over.
*
* OPEN: customers are already booking against the open they were shown, so
* the open stays put — but the close was capped at the OLD departure's
* cutoff, so it must follow the new one (import: open + duration under
* office hours, capped at the cutoff; export: the cutoff itself). Moving
* the train later extends the "closes in" countdown, moving it earlier
* shortens it (a close now in the past is picked up by the next tick).
*
* DOC_REVIEW/PAYMENT keep their running timeline.
*/
async maintenanceReschedule(
id: string,
dto: MaintenanceRescheduleDto,
): Promise<TrainSchedule> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id);
if (!schedule) {
throw new NotFoundException(`Train schedule ${id} not found`);
}
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
throw new BadRequestException(
`Cannot reschedule a ${schedule.status.toLowerCase()} train`,
);
}
const departure = new Date(dto.newDepartureDate);
if (Number.isNaN(departure.getTime())) {
throw new BadRequestException('Invalid departure date.');
}
if (departure.getTime() <= Date.now()) {
throw new BadRequestException('New departure must be in the future.');
}
const deltaMs =
departure.getTime() - new Date(schedule.scheduledDepartureDate).getTime();
const scheduledArrivalDate = schedule.scheduledArrivalDate
? new Date(new Date(schedule.scheduledArrivalDate).getTime() + deltaMs)
: undefined;
// PRE_WINDOW: the stamped open/close were derived from the old departure
// and the window hasn't opened yet, so re-derive them from the schedule's
// own rule snapshot against the new date (joining the target day's route
// group timeline when one exists, exactly like updateScheduleDate).
//
// DONE: the window already finished (e.g. the close offset hit and then the
// train was moved to a later departure). The window must follow the new
// departure, so it REOPENS: re-derive open/close the same way, reset the
// phase to PRE_WINDOW and clamp a past open into the present so the tick
// opens it immediately. A FULL train stays closed — there is nothing left
// to sell — and so does one whose re-derived window would already be over.
//
// Mid-window phases (OPEN/DOC_REVIEW/PAYMENT) keep their running timeline.
async windowFieldsForNewDeparture(
schedule: TrainSchedule,
departure: Date,
): Promise<
Partial<
Pick<
TrainSchedule,
| 'windowOpensAt'
| 'windowClosesAt'
| 'windowPhase'
| 'bookingWindowStatus'
| 'docReviewCompletedAt'
| 'docReviewEndsAt'
| 'paymentPhaseEndsAt'
>
>
> {
const reopenFromDone =
schedule.windowPhase === 'DONE' && schedule.bookingWindowStatus !== 'FULL';
const shiftOpenClose =
schedule.windowPhase === 'OPEN' && schedule.windowOpensAt != null;
const windowFields =
schedule.windowPhase === 'PRE_WINDOW' || reopenFromDone
shiftOpenClose
? await (async () => {
const merged = effectiveWindowConfig(
schedule,
await this.getWindowConfig(),
);
const opensAt = schedule.windowOpensAt!;
const cutoff = bookingCloseCutoff(departure, schedule.direction, merged);
let closesAt = cutoff;
if (schedule.direction !== 'EXPORT') {
closesAt = clampCloseToOfficeHours(
opensAt,
new Date(opensAt.getTime() + merged.windowDurationHours * 3_600_000),
merged,
);
if (closesAt.getTime() > cutoff.getTime()) closesAt = cutoff;
}
return { windowClosesAt: closesAt };
})()
: schedule.windowPhase === 'PRE_WINDOW' || reopenFromDone
? await (async () => {
const merged = effectiveWindowConfig(
schedule,
@@ -1266,6 +1275,55 @@ export class TrainSchedulingService {
};
})()
: {};
return windowFields;
}
/**
* Maintenance reschedule: the admin moves a train (with everything aboard) to
* a new departure. Unlike {@link updateScheduleDate} this runs at ANY window
* phase and inside the booking lead window — a maintenance move is an
* operational fact, not a planning choice. What moves and what stays:
*
* - MOVES: scheduledDepartureDate; scheduledArrivalDate (same delta); every
* aboard/targeted booking's scheduledDate (the day-pool queries key on it,
* so a booking left on the old day would fall out of its own train's pool).
* - STAYS: train set, wagon assignments, schedule↔booking links, route,
* maxWagons, and the window RULE snapshot. Stamped window times are
* re-derived for PRE_WINDOW schedules (their window hasn't run yet); an
* OPEN schedule keeps its open but its close follows the new departure;
* DOC_REVIEW/PAYMENT keep their timeline untouched.
*
* Customers of every moved booking are notified (maintenanceMoved).
*/
async maintenanceReschedule(
id: string,
dto: MaintenanceRescheduleDto,
): Promise<TrainSchedule> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id);
if (!schedule) {
throw new NotFoundException(`Train schedule ${id} not found`);
}
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
throw new BadRequestException(
`Cannot reschedule a ${schedule.status.toLowerCase()} train`,
);
}
const departure = new Date(dto.newDepartureDate);
if (Number.isNaN(departure.getTime())) {
throw new BadRequestException('Invalid departure date.');
}
if (departure.getTime() <= Date.now()) {
throw new BadRequestException('New departure must be in the future.');
}
const deltaMs =
departure.getTime() - new Date(schedule.scheduledDepartureDate).getTime();
const scheduledArrivalDate = schedule.scheduledArrivalDate
? new Date(new Date(schedule.scheduledArrivalDate).getTime() + deltaMs)
: undefined;
const windowFields = await this.windowFieldsForNewDeparture(schedule, departure);
await this.dataSource.getRepository(TrainSchedule).update(id, {
scheduledDepartureDate: departure,