mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 14:08:11 +00:00
add bookings management and settings
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user