fix issues

This commit is contained in:
marshal
2026-09-06 13:50:07 +00:00
parent 75b75e3d4e
commit 19145306c9
26 changed files with 1716 additions and 14 deletions

View File

@@ -298,6 +298,42 @@ export class BookingLifecycleNotifierService {
this.inApp(b, 'Operation request needs changes', msg);
}
/**
* Operations moved the shipment day (and possibly the train) themselves
* instead of asking the customer to. The booking stays under review, so the
* customer only needs to know the new day — nothing to resubmit.
*/
operationRescheduled(b: Booking, previousDay: string | null, note?: string): void {
const newDay = b.scheduledDate
? b.scheduledDate.toLocaleDateString('en-GB', { timeZone: 'Africa/Addis_Ababa' })
: 'a new day';
const msg =
`Operations moved the shipment day of booking ${b.reference} ` +
`${previousDay ? `from ${previousDay} ` : ''}to ${newDay}.` +
(note ? ` Note from Operations: ${note}` : '') +
' The request stays under review — no action is needed on your side.';
if (b.customsClearingEnabled) {
this.logger.log(`OPERATION RESCHEDULED (to GL) — ${this.ref(b)}`);
void this.inbox.notify({
recipients:
b.createdByRole === 'GL_ET' && b.createdByUserId
? { userIds: [b.createdByUserId] }
: CLEARANCE_DESK,
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.BOOKING_STATUS,
title: `Booking ${b.reference} shipment day changed`,
body: msg,
link: b.contractId
? `/dashboard/contracts/clearance/${b.contractId}`
: `/dashboard/bookings/${b.id}/clearance`,
data: { bookingId: b.id, reference: b.reference, note: note ?? null },
});
return;
}
void this.notifyContact(b, msg, 'OPERATION RESCHEDULED');
this.inApp(b, 'Shipment day changed by Operations', msg);
}
/** Operation accepted → invoice ready; await payment / booking window. */
operationAccepted(b: Booking): void {
// No invoice and no pay window for a shipping line — the charge sits on

View File

@@ -217,3 +217,170 @@ describe('BookingTransitionService — requestOperation export space gate', () =
);
});
});
/**
* Staff reschedule of an operation request: instead of returning the booking
* to the customer, Operations sets the new shipment day (and the export train)
* themselves. Same day-pool / export gates as the customer request; the booking
* lands (back) at OPERATION_REQUEST_PENDING and the customer is told.
*/
describe('BookingTransitionService — staff reschedule of an operation request', () => {
function makeService(over: {
status?: string;
tradeDirection?: 'EXPORT' | 'IMPORT';
hasDeparture?: boolean;
} = {}) {
const booking = {
id: 'b-1',
reference: 'BKG-1',
status: over.status ?? 'OPERATION_REQUEST_PENDING',
tradeDirection: over.tradeDirection ?? 'IMPORT',
originYardId: 'o-1',
destinationYardId: 'd-1',
totalAmount: 1000,
contractId: null,
scheduledDate: new Date('2026-07-01T00:00:00.000Z'),
requestedTrainScheduleId: null,
serviceType: { code: 'RAIL_CONTAINER' },
};
const bookingsRepository = {
update: jest.fn().mockResolvedValue({ id: 'b-1' }),
createReviewNote: jest.fn().mockResolvedValue(undefined),
};
const bookingsService = {
findById: jest.fn().mockResolvedValue(booking),
checkDayCompatibilityForBooking: jest.fn().mockResolvedValue({
hasDeparture: over.hasDeparture ?? true,
hasCompatible: true,
}),
};
const bookingBatchService = {
pickExportSchedule: jest.fn().mockResolvedValue('sched-1'),
};
const notifier = { operationRescheduled: jest.fn() };
const clearanceEvents = { record: jest.fn() };
const service = new BookingTransitionService(
bookingsRepository as never,
{} as never, // ruleEngineService
{} as never, // pricingService
{} as never, // contractService
{} as never, // filesService
{} as never, // fileUploadSettingsService
bookingBatchService as never,
bookingsService as never,
{ isPhasedCustomsBooking: () => false } as never,
{} as never, // workflowService
{} as never, // invoiceService
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
notifier as never,
clearanceEvents as never,
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository, bookingBatchService, notifier, clearanceEvents };
}
it('refuses a booking that has not requested operation', async () => {
const { service, bookingsRepository } = makeService({ status: 'CLEARANCE_READY' });
await expect(
service.rescheduleOperationRequest('b-1', '2026-07-20T00:00:00.000Z', null, 'staff-1'),
).rejects.toBeInstanceOf(ConflictException);
expect(bookingsRepository.update).not.toHaveBeenCalled();
});
it('refuses a day with no departure on the route and changes nothing', async () => {
const { service, bookingsRepository } = makeService({ hasDeparture: false });
await expect(
service.rescheduleOperationRequest('b-1', '2026-07-20T00:00:00.000Z', null, 'staff-1'),
).rejects.toBeInstanceOf(BadRequestException);
expect(bookingsRepository.update).not.toHaveBeenCalled();
});
it('moves an import request to the new day, keeps it pending, logs it and tells the customer', async () => {
const { service, bookingsRepository, notifier, clearanceEvents } = makeService();
await service.rescheduleOperationRequest(
'b-1',
'2026-07-20T00:00:00.000Z',
'sched-9', // ignored for import — the batch engine assigns the train
'staff-1',
);
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
status: 'OPERATION_REQUEST_PENDING',
scheduledDate: new Date('2026-07-20T00:00:00.000Z'),
requestedTrainScheduleId: null,
});
expect(bookingsRepository.createReviewNote).not.toHaveBeenCalled();
expect(clearanceEvents.record).toHaveBeenCalledWith(
expect.objectContaining({
bookingId: 'b-1',
action: 'OPERATION_RESCHEDULED',
actorType: 'STAFF',
actorId: 'staff-1',
metadata: expect.objectContaining({
previousScheduledDate: '2026-07-01',
scheduledDate: '2026-07-20',
}),
}),
);
expect(notifier.operationRescheduled).toHaveBeenCalledWith(
expect.objectContaining({ id: 'b-1' }),
'2026-07-01',
undefined,
);
});
it('resolves a change request staff had raised: back to pending, note kept as a staff note', async () => {
const { service, bookingsRepository, notifier } = makeService({
status: 'OPERATION_CHANGES_REQUESTED',
});
await service.rescheduleOperationRequest(
'b-1',
'2026-07-20T00:00:00.000Z',
null,
'staff-1',
{ note: ' Moved to the Monday train ' },
);
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-1',
expect.objectContaining({ status: 'OPERATION_REQUEST_PENDING' }),
);
expect(bookingsRepository.createReviewNote).toHaveBeenCalledWith(
'b-1',
'Moved to the Monday train',
'STAFF_NOTE',
'staff-1',
);
expect(notifier.operationRescheduled).toHaveBeenCalledWith(
expect.anything(),
'2026-07-01',
'Moved to the Monday train',
);
});
it('export rail: requires the train and persists the pick after the space gate', async () => {
const { service, bookingsRepository, bookingBatchService } = makeService({
tradeDirection: 'EXPORT',
});
await expect(
service.rescheduleOperationRequest('b-1', '2026-07-20T00:00:00.000Z', null, 'staff-1'),
).rejects.toThrow(/select a train/i);
expect(bookingsRepository.update).not.toHaveBeenCalled();
await service.rescheduleOperationRequest(
'b-1',
'2026-07-20T00:00:00.000Z',
'sched-9',
'staff-1',
);
expect(bookingBatchService.pickExportSchedule).toHaveBeenCalledWith(
expect.objectContaining({ requestedTrainScheduleId: 'sched-9' }),
);
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-1',
expect.objectContaining({
status: 'OPERATION_REQUEST_PENDING',
requestedTrainScheduleId: 'sched-9',
}),
);
});
});

View File

@@ -1311,6 +1311,45 @@ export class BookingTransitionService {
);
}
const { date, requestedId } = await this.resolveOperationDay(
booking,
scheduledDate,
requestedTrainScheduleId,
opts?.bypassDayPool,
);
await this.bookingsRepository.update(bookingId, {
status: "OPERATION_REQUEST_PENDING",
scheduledDate: date,
requestedTrainScheduleId: requestedId,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'OPERATION_REQUESTED',
label: `Requested operation for shipment day ${scheduledDate}`,
actorType: 'CUSTOMER',
actorId: opts?.userId ?? null,
metadata: { scheduledDate },
});
const fresh = await this.bookingsService.findById(bookingId);
this.notifier.operationRequestedToStaff(fresh);
return fresh;
}
/**
* Validate a shipment day (and, for export rail, the picked train) for a
* booking the way the customer's operation request does, and resolve what
* gets persisted: the binding `scheduledDate` and the `requestedTrainScheduleId`
* (export rail / shipping-line only — import and domestic trains are assigned
* by the batch engine, so their pick is dropped). Shared by the customer
* request and the staff reschedule so both enforce the same gates.
*/
private async resolveOperationDay(
booking: Booking,
scheduledDate: string,
requestedTrainScheduleId?: string | null,
bypassDayPool?: boolean,
): Promise<{ date: Date; requestedId: string | null }> {
const date = new Date(scheduledDate);
if (Number.isNaN(date.getTime())) {
throw new BadRequestException("A valid schedule date is required");
@@ -1322,7 +1361,7 @@ export class BookingTransitionService {
// gate; quantity never blocks — oversized bookings get a partial split
// offer). The batch engine assigns the specific train within that
// (route, day) pool later.
if (!opts?.bypassDayPool) {
if (!bypassDayPool) {
const { hasDeparture, hasCompatible } =
await this.bookingsService.checkDayCompatibilityForBooking(
booking,
@@ -1359,7 +1398,7 @@ export class BookingTransitionService {
// persisted here the same way an export pick is. Customer import/domestic
// bookings still never carry one (the batch engine assigns their train).
const requestedId =
isExportTrain || opts?.bypassDayPool
isExportTrain || bypassDayPool
? (requestedTrainScheduleId ?? null)
: null;
// Export rail rides the exact train the customer picked — never an
@@ -1403,21 +1442,76 @@ export class BookingTransitionService {
}
}
return { date, requestedId };
}
/**
* Operations changes the shipment day and/or train of a booking the customer
* has already requested operation on — instead of bouncing it back to the
* customer with a change request, staff set the new day (and, for export
* rail, the train) themselves. The same day-pool / export-space gates as the
* customer's own request apply, so staff cannot park a booking on a day with
* no departure or a train with no room.
*
* Allowed at OPERATION_REQUEST_PENDING (staff review) and at
* OPERATION_CHANGES_REQUESTED (staff resolve their own change request); either
* way the booking lands back at OPERATION_REQUEST_PENDING for the normal
* accept. The customer is told the new day, with the staff note when given.
*/
async rescheduleOperationRequest(
bookingId: string,
scheduledDate: string,
requestedTrainScheduleId: string | null | undefined,
actorId: string,
options: { note?: string } = {},
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [
"OPERATION_REQUEST_PENDING",
"OPERATION_CHANGES_REQUESTED",
]);
const { date, requestedId } = await this.resolveOperationDay(
booking,
scheduledDate,
requestedTrainScheduleId,
);
const previousDay = booking.scheduledDate ? eatDay(booking.scheduledDate) : null;
const previousTrainId = booking.requestedTrainScheduleId ?? null;
const note = options.note?.trim() || undefined;
await this.bookingsRepository.update(bookingId, {
status: "OPERATION_REQUEST_PENDING",
scheduledDate: date,
requestedTrainScheduleId: requestedId,
} as never);
if (note) {
await this.bookingsRepository.createReviewNote(
bookingId,
note,
"STAFF_NOTE",
actorId,
);
}
await this.clearanceEvents.record({
bookingId,
action: 'OPERATION_REQUESTED',
label: `Requested operation for shipment day ${scheduledDate}`,
actorType: 'CUSTOMER',
actorId: opts?.userId ?? null,
metadata: { scheduledDate },
action: "OPERATION_RESCHEDULED",
label:
`Operations moved the shipment day ` +
`${previousDay ? `from ${previousDay} ` : ""}to ${eatDay(date)}` +
(requestedId && requestedId !== previousTrainId ? " and changed the train" : ""),
actorType: "STAFF",
actorId,
metadata: {
previousScheduledDate: previousDay,
scheduledDate: eatDay(date),
previousTrainScheduleId: previousTrainId,
trainScheduleId: requestedId,
note: note ?? null,
},
});
const fresh = await this.bookingsService.findById(bookingId);
this.notifier.operationRequestedToStaff(fresh);
this.notifier.operationRescheduled(fresh, previousDay, note);
return fresh;
}

View File

@@ -82,6 +82,7 @@ import {
ReviewDocumentDto,
RequestOperationDto,
OperationReviewDto,
RescheduleOperationDto,
StaffRejectDto,
} from "./dto/request-changes.dto";
import { ContractViewDto } from "./dto/contract-view.dto";
@@ -1344,6 +1345,29 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/operation/reschedule")
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({
summary:
"Operations changes a pending operation request's shipment day and/or " +
"train on the customer's behalf (OPERATION_REQUEST_PENDING | " +
"OPERATION_CHANGES_REQUESTED → OPERATION_REQUEST_PENDING)",
})
async rescheduleOperationRequest(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: RescheduleOperationDto,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.rescheduleOperationRequest(
id,
dto.scheduledDate,
dto.trainScheduleId ?? null,
resolveAuthUserId(user),
{ note: dto.note },
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/clearance/review")
@BookingStaff(FREIGHT_PERMS.bookings.reviewDocuments)
@ApiOperation({

View File

@@ -107,6 +107,38 @@ export class RequestOperationDto {
trainScheduleId?: string;
}
/**
* Operations changes a pending operation request's shipment day and/or train
* on the customer's behalf (instead of returning it for changes).
*/
export class RescheduleOperationDto {
@ApiProperty({
description:
'The new shipment day (train departure day). ISO date — must have an ' +
'open departure on the booking route that can carry the cargo.',
example: '2026-07-15',
})
@IsDateString()
scheduledDate!: string;
@ApiPropertyOptional({
description:
'EXPORT rail only: the train (schedule id) to ride, from ' +
'GET /bookings/:id/export-trains for the new day. Required for export ' +
'rail; ignored for import/domestic/road bookings.',
})
@IsOptional()
@IsUUID()
trainScheduleId?: string;
@ApiPropertyOptional({
description: 'Optional note to the customer explaining the change.',
})
@IsOptional()
@IsString()
note?: string;
}
export class OperationReviewDto {
@ApiProperty({
description:

View File

@@ -289,6 +289,99 @@ export function bookingCloseCutoff(
return new Date(departure.getTime() - offsetMinutes * 60_000);
}
/** The schedule fields the close-offset reopen guard reads. */
export interface CloseOffsetReopenSchedule {
status: string;
direction?: string | null;
windowPhase?: string | null;
bookingWindowStatus?: string | null;
scheduledDepartureDate?: Date | null;
}
export interface CloseOffsetReopenCheck {
/** True when shortening the close offset is the one thing that reopens booking. */
eligible: boolean;
/** Why the schedule is not eligible; null when it is. */
reason: string | null;
/** Minutes before departure this schedule currently stops taking bookings. */
offsetMinutes: number | null;
/** The cutoff that shut booking (departure offset); null without an offset. */
cutoffAt: Date | null;
}
/**
* Is this schedule's booking shut ONLY because of its close offset? That is the
* one case staff may fix from the board by shortening the offset (3 days → 1
* day, 2 hours, …) so the desk reopens before departure. Every other way a
* window ends stays closed: the train departed, it is full, it never had an
* offset (booking ran until departure), or the window is still mid-cycle.
*
* The last guard — "a cycle would fit before departure with no offset at all" —
* is what makes the offset the ONLY problem: when the desk's next opening lands
* after the train leaves, no offset change can help.
*/
export function closeOffsetReopenCheck(
schedule: CloseOffsetReopenSchedule,
cfg: {
importCloseOffsetMinutes?: number | null;
exportCloseOffsetMinutes?: number | null;
windowOpenHour: number;
windowCloseHour: number;
},
now: Date,
): CloseOffsetReopenCheck {
const departure = schedule.scheduledDepartureDate ?? null;
const offsetRaw =
schedule.direction === 'EXPORT'
? cfg.exportCloseOffsetMinutes
: cfg.importCloseOffsetMinutes;
const offsetMinutes = offsetRaw != null && offsetRaw > 0 ? offsetRaw : null;
const cutoffAt =
departure && offsetMinutes != null
? bookingCloseCutoff(departure, schedule.direction, cfg)
: null;
const no = (reason: string): CloseOffsetReopenCheck => ({
eligible: false,
reason,
offsetMinutes,
cutoffAt,
});
if (schedule.status !== 'DRAFT' && schedule.status !== 'SCHEDULED') {
return no(`A ${schedule.status.toLowerCase()} train cannot reopen booking.`);
}
if (!departure || departure.getTime() <= now.getTime()) {
return no('This train has already departed (or has no departure date).');
}
if (offsetMinutes == null) {
return no(
'This train has no close offset — booking ran until departure, so there is nothing to shorten.',
);
}
if (schedule.windowPhase !== 'DONE') {
return no(
schedule.windowPhase == null
? 'This train does not run a managed booking window.'
: `Booking is not closed yet — the window is in its ${schedule.windowPhase} phase.`,
);
}
if (schedule.bookingWindowStatus === 'FULL') {
return no(
'Booking closed because the train is full, not because of the close offset.',
);
}
const hours: OfficeHours = {
windowOpenHour: cfg.windowOpenHour,
windowCloseHour: cfg.windowCloseHour,
};
if (nextCycleOpensAt(now, hours, departure) == null) {
return no(
'The desk would not reopen before departure even with no close offset — the offset is not what is blocking booking.',
);
}
return { eligible: true, reason: null, offsetMinutes, cutoffAt };
}
export interface InitialWindowTimes {
windowOpensAt: Date;
windowClosesAt: Date;

View File

@@ -66,6 +66,7 @@ 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 { ReduceScheduleCloseOffsetDto } from "../dto/reduce-schedule-close-offset.dto";
import { UpdateScheduleDateDto } from "../dto/update-schedule-date.dto";
import { MergeScheduleTrainDto } from "../dto/merge-schedule-train.dto";
import { UpdateScheduleTrainNumberDto } from "../dto/update-schedule-train-number.dto";
@@ -985,6 +986,20 @@ export class TrainSchedulingController {
return this.trainSchedulingService.getContainerTrainScheduleById(id);
}
@Patch("schedules/:id/close-offset")
@TrainSchedulingUpdate()
@ApiOperation({
summary:
"Shorten the booking-close offset of a schedule whose booking shut only because of that offset, so its window reopens before departure",
})
async reduceScheduleCloseOffset(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: ReduceScheduleCloseOffsetDto,
) {
await this.trainSchedulingService.reduceScheduleCloseOffset(id, dto);
return this.trainSchedulingService.getContainerTrainScheduleById(id);
}
@Patch("schedules/:id/schedule-date")
@TrainSchedulingUpdate()
@ApiOperation({

View File

@@ -0,0 +1,20 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, Min } from 'class-validator';
/**
* Shorten the booking-close offset of ONE schedule whose booking shut only
* because of that offset (staff action on the ops board). The value replaces the
* schedule's frozen offset; 0 means "close at departure".
*/
export class ReduceScheduleCloseOffsetDto {
@ApiProperty({
example: 120,
description:
'New minutes-before-departure at which booking closes. Must be shorter than the current offset; 0 = close at departure.',
})
@Type(() => Number)
@IsInt()
@Min(0)
closeOffsetMinutes!: number;
}

View File

@@ -0,0 +1,266 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { closeOffsetReopenCheck } from './batch-window.util';
import { TrainSchedulingService } from './services/train-scheduling.service';
/**
* "Reopen booking by shortening the close offset": a train whose booking shut
* ONLY because of its close offset (3 days → cut to 1 day / 2 hours) gets its
* window re-armed. Every other closed state is refused. Pure guard first, then
* the service against stub repositories.
*/
describe('close-offset reopen', () => {
// Wednesday 2026-09-09 10:00 EAT (07:00Z). A 3-day offset closes Sunday 10:00 EAT.
const DEPARTURE = new Date('2026-09-09T07:00:00.000Z');
// Monday 2026-09-07 09:00 EAT — inside the desk day, past the 3-day cutoff.
const NOW = new Date('2026-09-07T06:00:00.000Z');
const THREE_DAYS = 3 * 1_440;
const cfg = {
importWindowLeadDays: 3,
exportBookingLeadHours: 24,
windowOpenHour: 8,
windowCloseHour: 17,
windowDurationHours: 3,
docReviewMinutes: 30,
paymentWindowMinutes: 60,
exportPaymentWindowMinutes: 60,
importCloseOffsetMinutes: THREE_DAYS,
exportCloseOffsetMinutes: THREE_DAYS,
};
const closedByOffset = (over: Record<string, unknown> = {}) => ({
id: 'S1',
reference: 'S-2026-00001',
status: 'SCHEDULED',
direction: 'IMPORT',
windowPhase: 'DONE',
bookingWindowStatus: 'CLOSED',
scheduledDepartureDate: DEPARTURE,
originStationId: 'Y-ADD',
destinationStationId: 'Y-DJ',
ruleWindowOpenHour: 8,
ruleWindowCloseHour: 17,
ruleWindowDurationHours: 3,
ruleImportWindowLeadDays: 3,
ruleExportBookingLeadHours: 24,
ruleImportCloseOffsetMinutes: THREE_DAYS,
ruleExportCloseOffsetMinutes: THREE_DAYS,
...over,
});
describe('closeOffsetReopenCheck', () => {
it('is eligible when DONE, not full, departure ahead, and an offset shut it', () => {
const check = closeOffsetReopenCheck(closedByOffset(), cfg, NOW);
expect(check.eligible).toBe(true);
expect(check.offsetMinutes).toBe(THREE_DAYS);
expect(check.cutoffAt?.toISOString()).toBe('2026-09-06T07:00:00.000Z');
});
it.each([
['dispatched train', { status: 'DISPATCHED' }, /dispatched/i],
['already departed', { scheduledDepartureDate: new Date('2026-09-01T07:00:00.000Z') }, /departed/i],
['full train', { bookingWindowStatus: 'FULL' }, /full/i],
['window still open', { windowPhase: 'OPEN' }, /not closed yet/i],
['legacy row with no window', { windowPhase: null }, /managed booking window/i],
])('refuses a %s', (_label, over, reason) => {
const check = closeOffsetReopenCheck(closedByOffset(over), cfg, NOW);
expect(check.eligible).toBe(false);
expect(check.reason).toMatch(reason);
});
it('refuses when the schedule never had an offset (booking ran to departure)', () => {
const check = closeOffsetReopenCheck(
closedByOffset(),
{ ...cfg, importCloseOffsetMinutes: null },
NOW,
);
expect(check.eligible).toBe(false);
expect(check.reason).toMatch(/no close offset/i);
expect(check.cutoffAt).toBeNull();
});
it('refuses when the desk could not reopen before departure even with no offset', () => {
// Tuesday 18:00 EAT, desk 817: next opening is Wednesday 08:00, but the
// train departs Wednesday 07:00 EAT — the offset is not the blocker.
const lateNow = new Date('2026-09-08T15:00:00.000Z');
const earlyDeparture = new Date('2026-09-09T04:00:00.000Z');
const check = closeOffsetReopenCheck(
closedByOffset({ scheduledDepartureDate: earlyDeparture }),
cfg,
lateNow,
);
expect(check.eligible).toBe(false);
expect(check.reason).toMatch(/would not reopen before departure/i);
});
it('reads the export offset for an EXPORT schedule', () => {
const check = closeOffsetReopenCheck(
closedByOffset({ direction: 'EXPORT' }),
{ ...cfg, importCloseOffsetMinutes: null, exportCloseOffsetMinutes: 120 },
NOW,
);
expect(check.eligible).toBe(true);
expect(check.offsetMinutes).toBe(120);
});
});
describe('TrainSchedulingService.reduceScheduleCloseOffset', () => {
type Fixture = {
schedule: Record<string, unknown> | null;
siblings?: Record<string, unknown>[];
now?: Date;
};
const makeService = (fx: Fixture) => {
const updates: Array<{ id: string; patch: Record<string, unknown> }> = [];
const repo = {
update: jest.fn().mockImplementation(async (id: string, patch: Record<string, unknown>) => {
updates.push({ id, patch });
}),
};
const siblingsQb = {
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue(fx.siblings ?? []),
};
const dataSource = {
getRepository: jest.fn().mockReturnValue(repo),
manager: {
getRepository: jest
.fn()
.mockReturnValue({ createQueryBuilder: () => siblingsQb }),
},
};
const service = Object.create(
TrainSchedulingService.prototype,
) as TrainSchedulingService;
const emitted: string[] = [];
Object.assign(service, {
dataSource,
trainSchedulesRepository: {
findById: jest.fn().mockResolvedValue(fx.schedule),
},
getWindowConfig: jest.fn().mockResolvedValue(cfg),
emitWindowState: jest.fn().mockImplementation(async (id: string) => {
emitted.push(id);
}),
logger: { log: jest.fn(), warn: jest.fn() },
});
jest.useFakeTimers().setSystemTime(fx.now ?? NOW);
return { service, updates, emitted };
};
afterEach(() => jest.useRealTimers());
it('404s on an unknown schedule', async () => {
const { service } = makeService({ schedule: null });
await expect(
service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 60 }),
).rejects.toBeInstanceOf(NotFoundException);
});
it('refuses a train whose window is not shut by its offset', async () => {
const { service, updates } = makeService({
schedule: closedByOffset({ bookingWindowStatus: 'FULL' }),
});
await expect(
service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 60 }),
).rejects.toThrow(/full/i);
expect(updates).toHaveLength(0);
});
it('refuses an offset that is not shorter than the current one', async () => {
const { service, updates } = makeService({ schedule: closedByOffset() });
await expect(
service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: THREE_DAYS }),
).rejects.toThrow(/shorter than the current 3 days/i);
expect(updates).toHaveLength(0);
});
it('refuses an offset whose new cutoff is still before the next desk opening', async () => {
// 2 days before departure = Monday 10:00 EAT; now is Monday 09:00 so a
// cycle fits… but 2 days 1 hour (Mon 09:00) does not.
const { service } = makeService({ schedule: closedByOffset() });
await expect(
service.reduceScheduleCloseOffset('S1', {
closeOffsetMinutes: 2 * 1_440 + 60,
}),
).rejects.toBeInstanceOf(BadRequestException);
});
it('shortens the offset, re-arms the window at now (desk open) and caps it at the new cutoff', async () => {
const { service, updates, emitted } = makeService({ schedule: closedByOffset() });
// 1 day before departure → new cutoff Tuesday 10:00 EAT.
await service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 1_440 });
expect(updates).toHaveLength(1);
const [{ id, patch }] = updates;
expect(id).toBe('S1');
expect(patch).toMatchObject({
ruleImportCloseOffsetMinutes: 1_440,
windowRuleCustom: true,
windowPhase: 'PRE_WINDOW',
docReviewCompletedAt: null,
docReviewEndsAt: null,
paymentPhaseEndsAt: null,
});
// Desk is open at 09:00 → reopens now; 3h cycle → 12:00 EAT (09:00Z).
expect((patch.windowOpensAt as Date).toISOString()).toBe(NOW.toISOString());
expect((patch.windowClosesAt as Date).toISOString()).toBe('2026-09-07T09:00:00.000Z');
expect(emitted).toEqual(['S1']);
});
it('stores 0 as null (booking runs to departure) and caps the cycle at departure', async () => {
// Tuesday 16:00 EAT: 3h cycle would run past the 17:00 desk close.
const tueAfternoon = new Date('2026-09-08T13:00:00.000Z');
const { service, updates } = makeService({
schedule: closedByOffset(),
now: tueAfternoon,
});
await service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 0 });
const [{ patch }] = updates;
expect(patch.ruleImportCloseOffsetMinutes).toBeNull();
expect((patch.windowOpensAt as Date).toISOString()).toBe(tueAfternoon.toISOString());
// Desk close (17:00 EAT = 14:00Z) ends the cycle before departure.
expect((patch.windowClosesAt as Date).toISOString()).toBe('2026-09-08T14:00:00.000Z');
});
it('reopens route+day siblings shut by the same offset and leaves the rest alone', async () => {
const { service, updates } = makeService({
schedule: closedByOffset(),
siblings: [
closedByOffset({ id: 'S2' }),
// Already full — booking did not close because of the offset.
closedByOffset({ id: 'S3', bookingWindowStatus: 'FULL' }),
// Still mid-cycle — must keep the state its customers see.
closedByOffset({ id: 'S4', windowPhase: 'PAYMENT' }),
],
});
await service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 1_440 });
expect(updates.map((u) => u.id)).toEqual(['S1', 'S2']);
expect(updates[1].patch).toMatchObject({
ruleImportCloseOffsetMinutes: 1_440,
windowPhase: 'PRE_WINDOW',
});
});
it('an EXPORT reopen is a single FCFS window to the new cutoff and touches no sibling', async () => {
const { service, updates } = makeService({
schedule: closedByOffset({ direction: 'EXPORT' }),
siblings: [closedByOffset({ id: 'S2', direction: 'EXPORT' })],
});
await service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 120 });
expect(updates).toHaveLength(1);
const [{ patch }] = updates;
expect(patch).toMatchObject({
ruleExportCloseOffsetMinutes: 120,
windowPhase: 'PRE_WINDOW',
});
expect((patch.windowOpensAt as Date).toISOString()).toBe(NOW.toISOString());
// Departure 07:00Z 2h.
expect((patch.windowClosesAt as Date).toISOString()).toBe('2026-09-09T05:00:00.000Z');
});
});
});

View File

@@ -112,6 +112,7 @@ import {
UploadImportDjiboutiDocumentDto,
} from '../dto/import-djibouti-operation.dto';
import { UpdateScheduleWindowRuleDto } from '../dto/update-schedule-window-rule.dto';
import { ReduceScheduleCloseOffsetDto } from '../dto/reduce-schedule-close-offset.dto';
import { UpdateScheduleDateDto } from '../dto/update-schedule-date.dto';
import { MergeScheduleTrainDto } from '../dto/merge-schedule-train.dto';
import { UpdateScheduleTrainNumberDto } from '../dto/update-schedule-train-number.dto';
@@ -197,12 +198,15 @@ import { orderConsistWagons } from '../consist-order.util';
import {
bookingCloseCutoff,
clampCloseToOfficeHours,
closeOffsetReopenCheck,
computeExportWindowTimes,
computeImportWindowTimes,
earliestSchedulableDeparture,
eatDay,
eatDayToUtc,
nextCycleOpensAt,
shiftEatDay,
type OfficeHours,
} from '../batch-window.util';
import { TrainCheckpointEvent } from '../entities/train-checkpoint-event.entity';
import { BookingJourneyService } from '../booking-journey.service';
@@ -239,6 +243,19 @@ const HANDLING_FIELDS = [
type HandlingField = (typeof HANDLING_FIELDS)[number][0];
/** "3 days" / "2 hours" / "45 minutes" for an error message. */
function describeMinutes(minutes: number): string {
if (minutes % 1_440 === 0) {
const d = minutes / 1_440;
return `${d} day${d === 1 ? '' : 's'}`;
}
if (minutes % 60 === 0) {
const h = minutes / 60;
return `${h} hour${h === 1 ? '' : 's'}`;
}
return `${minutes} minute${minutes === 1 ? '' : 's'}`;
}
/** Drops the keys a partial override left undefined, so `...` merges keep the base value. */
function pickDefined<T extends object>(source: T): Partial<T> {
return Object.fromEntries(
@@ -266,6 +283,16 @@ function windowRuleSnapshot(cfg: BookingWindowConfig) {
};
}
/** Wire shape of a close-offset reopen check (dates as ISO strings). */
function toCloseOffsetReopenInfo(check: ReturnType<typeof closeOffsetReopenCheck>) {
return {
eligible: check.eligible,
reason: check.reason,
offsetMinutes: check.offsetMinutes,
cutoffAt: check.cutoffAt ? check.cutoffAt.toISOString() : null,
};
}
/**
* The booking-window config a specific schedule runs under: its frozen rule
* snapshot (open/close hour, duration, lead, reopen gap) overlaid on the live
@@ -1024,6 +1051,152 @@ export class TrainSchedulingService {
return fresh ?? schedule;
}
/**
* Shorten the booking-close offset of ONE schedule whose booking shut ONLY
* because of that offset, and re-arm its window so the desk reopens. A 3-day
* offset that closed booking with the train still days away can be cut to a
* day or a couple of hours; the window then opens at the next desk opening
* (now, if the desk is open) and runs its normal cycles until the new cutoff.
*
* Refused for every other kind of closed window (departed, full, no offset,
* still mid-cycle) — see `closeOffsetReopenCheck`. The new offset must be
* shorter than the current one and must leave room for a cycle before the
* new cutoff. The offset is frozen onto the schedule (the global value is
* untouched) and the row is marked custom so a later global-rules save does
* not re-stamp it.
*
* IMPORT/DOMESTIC: the same shorter offset is applied to every route+day
* sibling that is likewise shut only by its offset, so the group keeps its
* single shared timeline (each capped at its own new cutoff). EXPORT windows
* are per-train, so an export change touches only this schedule.
*/
async reduceScheduleCloseOffset(
id: string,
dto: ReduceScheduleCloseOffsetDto,
): Promise<TrainSchedule> {
const schedule = await this.trainSchedulesRepository.findById(id);
if (!schedule) {
throw new NotFoundException(`Train schedule ${id} not found`);
}
const now = new Date();
const liveCfg = await this.getWindowConfig();
const cfg = effectiveWindowConfig(schedule, liveCfg);
const check = closeOffsetReopenCheck(schedule, cfg, now);
if (!check.eligible || check.offsetMinutes == null) {
throw new BadRequestException(
check.reason ?? 'This schedule cannot reopen by shortening its close offset.',
);
}
const newOffset = dto.closeOffsetMinutes;
if (newOffset >= check.offsetMinutes) {
throw new BadRequestException(
`The new close offset must be shorter than the current ${describeMinutes(
check.offsetMinutes,
)} before departure.`,
);
}
const isExport = schedule.direction === 'EXPORT';
// 0 is stored as null so "no offset" keeps its single canonical value.
const offsetPatch = isExport
? { ruleExportCloseOffsetMinutes: newOffset || null }
: { ruleImportCloseOffsetMinutes: newOffset || null };
const merged: BookingWindowConfig = {
...cfg,
...(isExport
? { exportCloseOffsetMinutes: newOffset || null }
: { importCloseOffsetMinutes: newOffset || null }),
};
const hours: OfficeHours = {
windowOpenHour: cfg.windowOpenHour,
windowCloseHour: cfg.windowCloseHour,
};
const cutoff = bookingCloseCutoff(
schedule.scheduledDepartureDate,
schedule.direction,
merged,
);
// The desk reopens at the next office-hours opening (now, when it is open),
// exactly as a reopen cycle would — and only if that lands before the cutoff.
const opensAt = nextCycleOpensAt(now, hours, cutoff);
if (opensAt == null) {
throw new BadRequestException(
'Even with this offset the desk would not reopen before booking closes again ' +
`(new cutoff ${cutoff.toISOString()}) — shorten the offset further.`,
);
}
let closesAt: Date;
if (isExport) {
// Export runs one FCFS window: from the reopen until the cutoff.
closesAt = cutoff;
} else {
closesAt = new Date(opensAt.getTime() + cfg.windowDurationHours * 3_600_000);
closesAt = clampCloseToOfficeHours(opensAt, closesAt, hours);
if (closesAt.getTime() > cutoff.getTime()) closesAt = cutoff;
}
const cap = (d: Date, bound: Date): Date =>
d.getTime() > bound.getTime() ? bound : d;
const targets: Array<{ id: string; cutoff: Date }> = [{ id, cutoff }];
if (!isExport) {
const siblings = await this.findGroupSiblings(
this.dataSource.manager,
schedule.originStationId,
schedule.destinationStationId,
schedule.scheduledDepartureDate,
id,
);
for (const sib of siblings) {
const sibCfg = effectiveWindowConfig(sib, liveCfg);
const sibCheck = closeOffsetReopenCheck(sib, sibCfg, now);
// Only a sibling that is ALSO shut purely by an offset longer than the
// new one joins in; anything else keeps the state its customers saw.
if (
!sibCheck.eligible ||
sibCheck.offsetMinutes == null ||
sibCheck.offsetMinutes <= newOffset ||
!sib.scheduledDepartureDate
) {
continue;
}
const sibCutoff = bookingCloseCutoff(sib.scheduledDepartureDate, sib.direction, {
...sibCfg,
importCloseOffsetMinutes: newOffset || null,
});
if (opensAt.getTime() >= sibCutoff.getTime()) continue;
targets.push({ id: sib.id, cutoff: sibCutoff });
}
}
const repo = this.dataSource.getRepository(TrainSchedule);
for (const t of targets) {
await repo.update(t.id, {
...offsetPatch,
// Staff-set — exempt from the global re-stamp.
windowRuleCustom: true,
// Back to PRE_WINDOW: the window tick opens it at windowOpensAt and runs
// the normal cycle from there (bookingWindowStatus flips OPEN then).
windowPhase: 'PRE_WINDOW',
windowOpensAt: cap(opensAt, t.cutoff),
windowClosesAt: cap(closesAt, t.cutoff),
docReviewCompletedAt: null,
docReviewEndsAt: null,
paymentPhaseEndsAt: null,
});
}
this.logger.log(
`Close offset of schedule ${schedule.reference ?? id} shortened ` +
`${check.offsetMinutes}${newOffset} min before departure` +
` (+${targets.length - 1} route+day sibling(s)) — booking reopens ` +
`${opensAt.toISOString()}, closes ${closesAt.toISOString()}`,
);
for (const t of targets) void this.emitWindowState(t.id);
const fresh = await this.trainSchedulesRepository.findById(id);
return fresh ?? schedule;
}
/**
* Correct a departure's operational run identifiers — the train number and
* voyage number yards and customs quote.
@@ -6313,8 +6486,11 @@ export class TrainSchedulingService {
skip,
take,
});
// Live window config: each row's frozen rule overlays it to decide whether
// the "shorten close offset" action applies (see closeOffsetReopenCheck).
const liveCfg = await this.getWindowConfig();
return {
items: schedules.map((s) => this.mapScheduleListItem(s)),
items: schedules.map((s) => this.mapScheduleListItem(s, liveCfg)),
meta: buildPaginationMeta(total, page, pageSize),
};
}
@@ -8857,7 +9033,11 @@ export class TrainSchedulingService {
throw new ConflictException('Could not allocate a unique schedule reference');
}
private mapScheduleListItem(schedule: import('../../train-schedules/entities/train-schedule.entity').TrainSchedule) {
private mapScheduleListItem(
schedule: import('../../train-schedules/entities/train-schedule.entity').TrainSchedule,
/** Live window config; when given, the row carries its close-offset reopen state. */
liveCfg?: BookingWindowConfig,
) {
// Wagon figures must match the detail page's wagon plan (WagonPlanGrid) —
// see computeScheduleWagonUsage for why the stored counter cannot be used.
const { wagonsUsed, wagonsTotal, wagonsReserved, wagonsRemaining } =
@@ -8921,6 +9101,18 @@ export class TrainSchedulingService {
freightType: this.resolveScheduleFreightType(schedule),
status: schedule.status,
bookingWindowStatus: schedule.bookingWindowStatus ?? 'OPEN',
windowPhase: schedule.windowPhase ?? null,
// Whether booking shut ONLY because of the close offset — the board offers
// "shorten close offset" on exactly these rows.
closeOffsetReopen: liveCfg
? toCloseOffsetReopenInfo(
closeOffsetReopenCheck(
schedule,
effectiveWindowConfig(schedule, liveCfg),
new Date(),
),
)
: null,
cancellationReason: schedule.cancellationReason ?? null,
cancelledAt: schedule.cancelledAt ?? null,
maxWagons: schedule.maxWagons ?? 0,
@@ -10965,6 +11157,14 @@ export class TrainSchedulingService {
// settings" editor on the ops board (prefill + save one schedule's
// override). docReview/payment are not snapshotted per schedule (only their
// sum, as the frozen reopen gap), so the editor prefills them from live config.
// Shut only by its close offset? Drives the "shorten close offset" action.
closeOffsetReopen: toCloseOffsetReopenInfo(
closeOffsetReopenCheck(
schedule,
effectiveWindowConfig(schedule, windowCfg),
new Date(),
),
),
windowRule: {
windowOpenHour: schedule.ruleWindowOpenHour ?? null,
windowCloseHour: schedule.ruleWindowCloseHour ?? null,
@@ -10974,6 +11174,12 @@ export class TrainSchedulingService {
: null,
importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null,
exportBookingLeadHours: schedule.ruleExportBookingLeadHours ?? null,
// The offsets this train actually runs under (its frozen snapshot, or
// the live global for a legacy row) — null = booking runs to departure.
importCloseOffsetMinutes:
effectiveWindowConfig(schedule, windowCfg).importCloseOffsetMinutes ?? null,
exportCloseOffsetMinutes:
effectiveWindowConfig(schedule, windowCfg).exportCloseOffsetMinutes ?? null,
docReviewMinutes: windowCfg.docReviewMinutes,
// Editor prefill: this schedule's own override when staff set one,
// else the live global for the schedule's direction (import/export