add bookings management and settings

This commit is contained in:
Marshal
2026-07-05 10:22:58 +00:00
parent dfdf7a025d
commit 3447394e32
13 changed files with 1617 additions and 9 deletions

View File

@@ -0,0 +1,62 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsNumber, IsOptional, Max, Min } from 'class-validator';
/**
* Per-schedule booking-window rule override (staff action on the ops board).
* Every field is optional — only the ones sent are changed; the rest keep the
* schedule's existing snapshot. Mirrors the window fields of the global rules DTO.
*/
export class UpdateScheduleWindowRuleDto {
@ApiPropertyOptional({ example: 8, description: 'Local EAT hour the booking desk opens each day' })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(23)
windowOpenHour?: number;
@ApiPropertyOptional({
example: 17,
description:
'Local EAT hour the booking desk shuts each day; a not-yet-full window resumes next morning at windowOpenHour. Equal to windowOpenHour = 24-hour desk',
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(23)
windowCloseHour?: number;
@ApiPropertyOptional({ example: 3, description: 'How long each booking cycle stays open, in hours' })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0.0166)
@Max(12)
windowDurationHours?: number;
@ApiPropertyOptional({ example: 30, description: 'Max staff document-review minutes after the window closes' })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
docReviewMinutes?: number;
@ApiPropertyOptional({ example: 60, description: 'Customer payment window minutes' })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
paymentWindowMinutes?: number;
@ApiPropertyOptional({
example: 3,
description: 'Days before departure the booking window starts (re-derives the window start)',
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
importWindowLeadDays?: number;
}

View File

@@ -42,6 +42,7 @@ import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto";
import { AvailableDaysQueryDto } from "./dto/available-days-query.dto";
import { AvailableDaysForCargoQueryDto } from "./dto/available-days-for-cargo-query.dto";
import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto";
import { UpdateScheduleWindowRuleDto } from "./dto/update-schedule-window-rule.dto";
import { TrainSchedulingService } from "./train-scheduling.service";
import { BookingBatchService } from "./booking-batch.service";
import { BookingWindowService } from "./booking-window.service";
@@ -519,6 +520,20 @@ export class TrainSchedulingController {
return this.trainSchedulingService.getContainerTrainScheduleById(id);
}
@Patch("schedules/:id/window-rule")
@TrainSchedulingManage()
@ApiOperation({
summary:
"Override the booking-window rule for one schedule (open/close hour, duration, doc-review, payment, lead days) — only before the window opens",
})
async updateScheduleWindowRule(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateScheduleWindowRuleDto,
) {
await this.trainSchedulingService.updateScheduleWindowRule(id, dto);
return this.trainSchedulingService.getContainerTrainScheduleById(id);
}
@Post("schedules/:id/doc-review-complete")
@TrainSchedulingManage()
@ApiOperation({

View File

@@ -64,6 +64,7 @@ import {
UploadImportDjiboutiDocumentDto,
} from './dto/import-djibouti-operation.dto';
import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto';
import { UpdateScheduleWindowRuleDto } from './dto/update-schedule-window-rule.dto';
import { type BookingWindowConfig } from './booking-window.config';
import {
buildCappedWagonPlan,
@@ -331,6 +332,87 @@ export class TrainSchedulingService {
return saved;
}
/**
* Override the booking-window rule for ONE schedule (staff action on the ops
* board). Only the fields provided are changed; the rest keep the schedule's
* existing snapshot (falling back to the live global config for legacy rows).
* The window must not have opened yet — an OPEN/past schedule stays frozen so
* customers keep the times they were shown. windowOpensAt/ClosesAt are
* re-derived from the merged rule, and the snapshot is updated so the board
* draws the new cycles.
*/
async updateScheduleWindowRule(
id: string,
dto: UpdateScheduleWindowRuleDto,
): Promise<TrainSchedule> {
const schedule = await this.trainSchedulesRepository.findById(id);
if (!schedule) {
throw new NotFoundException(`Train schedule ${id} not found`);
}
if (schedule.windowPhase !== 'PRE_WINDOW') {
throw new BadRequestException(
'Booking window settings can only be changed before the window opens ' +
`(this schedule is "${schedule.windowPhase ?? 'not window-managed'}").`,
);
}
const now = new Date();
if (!schedule.scheduledDepartureDate || schedule.scheduledDepartureDate <= now) {
throw new BadRequestException(
'This schedule has already departed or has no departure date.',
);
}
// Merge the override onto the schedule's current effective rule (its snapshot,
// or the live config where a legacy row has no snapshot).
const liveCfg = await this.getWindowConfig();
const merged: BookingWindowConfig = {
importWindowLeadDays:
dto.importWindowLeadDays ??
schedule.ruleImportWindowLeadDays ??
liveCfg.importWindowLeadDays,
exportBookingLeadHours:
schedule.ruleExportBookingLeadHours ?? liveCfg.exportBookingLeadHours,
windowOpenHour:
dto.windowOpenHour ?? schedule.ruleWindowOpenHour ?? liveCfg.windowOpenHour,
windowCloseHour:
dto.windowCloseHour ?? schedule.ruleWindowCloseHour ?? liveCfg.windowCloseHour,
windowDurationHours:
dto.windowDurationHours ??
(schedule.ruleWindowDurationHours != null
? Number(schedule.ruleWindowDurationHours)
: liveCfg.windowDurationHours),
// The reopen gap is doc review + payment; keep the config values unless the
// override changes them, so the derived snapshot delay stays consistent.
docReviewMinutes: dto.docReviewMinutes ?? liveCfg.docReviewMinutes,
paymentWindowMinutes: dto.paymentWindowMinutes ?? liveCfg.paymentWindowMinutes,
reopenDelayMinutes: liveCfg.reopenDelayMinutes,
};
if (merged.windowCloseHour < merged.windowOpenHour) {
throw new BadRequestException(
`Window close hour (${merged.windowCloseHour}) must be on or after the open hour ` +
`(${merged.windowOpenHour}); set them equal for a 24-hour desk.`,
);
}
const times =
schedule.direction === 'EXPORT'
? computeExportWindowTimes(schedule.scheduledDepartureDate, merged)
: computeImportWindowTimes(schedule.scheduledDepartureDate, merged, now);
await this.dataSource.getRepository(TrainSchedule).update(id, {
windowOpensAt: times.windowOpensAt,
windowClosesAt: times.windowClosesAt,
...windowRuleSnapshot(merged),
});
this.logger.log(
`Booking-window rule overridden for schedule ${id} — reopens ${times.windowOpensAt.toISOString()}`,
);
const fresh = await this.trainSchedulesRepository.findById(id);
return fresh ?? schedule;
}
/**
* Re-derive windowOpensAt/windowClosesAt for schedules whose booking window has
* not opened yet (windowPhase === 'PRE_WINDOW', still Draft/Scheduled, departure
@@ -3677,6 +3759,8 @@ export class TrainSchedulingService {
.flatMap((w) => w.allocations ?? [])
.map((a) => a.id);
const windowCfg = await this.getWindowConfig();
const [containerItems, bulkLoads] = await Promise.all([
allocationIds.length
? this.wagonAllocationContainerItemsRepository.findAll({
@@ -3723,6 +3807,22 @@ export class TrainSchedulingService {
paymentPhaseEndsAt: schedule.paymentPhaseEndsAt
? schedule.paymentPhaseEndsAt.toISOString()
: null,
// Per-schedule booking-window rule snapshot — powers the "Booking window
// settings" editor on the ops board (prefill + save one schedule's
// override). docReview/payment are not snapshotted per schedule (only their
// sum, as reopenDelayMinutes), so the editor prefills them from live config.
windowRule: {
windowOpenHour: schedule.ruleWindowOpenHour ?? null,
windowCloseHour: schedule.ruleWindowCloseHour ?? null,
windowDurationHours:
schedule.ruleWindowDurationHours != null
? Number(schedule.ruleWindowDurationHours)
: null,
reopenDelayMinutes: schedule.ruleReopenDelayMinutes ?? null,
importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null,
docReviewMinutes: windowCfg.docReviewMinutes,
paymentWindowMinutes: windowCfg.paymentWindowMinutes,
},
route: schedule.route
? { id: schedule.route.id, name: formatRouteLabel(schedule.route) }
: null,