mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 11:55:42 +00:00
fix issues
This commit is contained in:
@@ -298,6 +298,42 @@ export class BookingLifecycleNotifierService {
|
|||||||
this.inApp(b, 'Operation request needs changes', msg);
|
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. */
|
/** Operation accepted → invoice ready; await payment / booking window. */
|
||||||
operationAccepted(b: Booking): void {
|
operationAccepted(b: Booking): void {
|
||||||
// No invoice and no pay window for a shipping line — the charge sits on
|
// No invoice and no pay window for a shipping line — the charge sits on
|
||||||
|
|||||||
@@ -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',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
const date = new Date(scheduledDate);
|
||||||
if (Number.isNaN(date.getTime())) {
|
if (Number.isNaN(date.getTime())) {
|
||||||
throw new BadRequestException("A valid schedule date is required");
|
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
|
// gate; quantity never blocks — oversized bookings get a partial split
|
||||||
// offer). The batch engine assigns the specific train within that
|
// offer). The batch engine assigns the specific train within that
|
||||||
// (route, day) pool later.
|
// (route, day) pool later.
|
||||||
if (!opts?.bypassDayPool) {
|
if (!bypassDayPool) {
|
||||||
const { hasDeparture, hasCompatible } =
|
const { hasDeparture, hasCompatible } =
|
||||||
await this.bookingsService.checkDayCompatibilityForBooking(
|
await this.bookingsService.checkDayCompatibilityForBooking(
|
||||||
booking,
|
booking,
|
||||||
@@ -1359,7 +1398,7 @@ export class BookingTransitionService {
|
|||||||
// persisted here the same way an export pick is. Customer import/domestic
|
// persisted here the same way an export pick is. Customer import/domestic
|
||||||
// bookings still never carry one (the batch engine assigns their train).
|
// bookings still never carry one (the batch engine assigns their train).
|
||||||
const requestedId =
|
const requestedId =
|
||||||
isExportTrain || opts?.bypassDayPool
|
isExportTrain || bypassDayPool
|
||||||
? (requestedTrainScheduleId ?? null)
|
? (requestedTrainScheduleId ?? null)
|
||||||
: null;
|
: null;
|
||||||
// Export rail rides the exact train the customer picked — never an
|
// 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, {
|
await this.bookingsRepository.update(bookingId, {
|
||||||
status: "OPERATION_REQUEST_PENDING",
|
status: "OPERATION_REQUEST_PENDING",
|
||||||
scheduledDate: date,
|
scheduledDate: date,
|
||||||
requestedTrainScheduleId: requestedId,
|
requestedTrainScheduleId: requestedId,
|
||||||
} as never);
|
} as never);
|
||||||
|
if (note) {
|
||||||
|
await this.bookingsRepository.createReviewNote(
|
||||||
|
bookingId,
|
||||||
|
note,
|
||||||
|
"STAFF_NOTE",
|
||||||
|
actorId,
|
||||||
|
);
|
||||||
|
}
|
||||||
await this.clearanceEvents.record({
|
await this.clearanceEvents.record({
|
||||||
bookingId,
|
bookingId,
|
||||||
action: 'OPERATION_REQUESTED',
|
action: "OPERATION_RESCHEDULED",
|
||||||
label: `Requested operation for shipment day ${scheduledDate}`,
|
label:
|
||||||
actorType: 'CUSTOMER',
|
`Operations moved the shipment day ` +
|
||||||
actorId: opts?.userId ?? null,
|
`${previousDay ? `from ${previousDay} ` : ""}to ${eatDay(date)}` +
|
||||||
metadata: { scheduledDate },
|
(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);
|
const fresh = await this.bookingsService.findById(bookingId);
|
||||||
this.notifier.operationRequestedToStaff(fresh);
|
this.notifier.operationRescheduled(fresh, previousDay, note);
|
||||||
return fresh;
|
return fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ import {
|
|||||||
ReviewDocumentDto,
|
ReviewDocumentDto,
|
||||||
RequestOperationDto,
|
RequestOperationDto,
|
||||||
OperationReviewDto,
|
OperationReviewDto,
|
||||||
|
RescheduleOperationDto,
|
||||||
StaffRejectDto,
|
StaffRejectDto,
|
||||||
} from "./dto/request-changes.dto";
|
} from "./dto/request-changes.dto";
|
||||||
import { ContractViewDto } from "./dto/contract-view.dto";
|
import { ContractViewDto } from "./dto/contract-view.dto";
|
||||||
@@ -1344,6 +1345,29 @@ export class BookingsController {
|
|||||||
return this.transitionService.enrichBookingResponse(booking);
|
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")
|
@Post(":id/clearance/review")
|
||||||
@BookingStaff(FREIGHT_PERMS.bookings.reviewDocuments)
|
@BookingStaff(FREIGHT_PERMS.bookings.reviewDocuments)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
|
|||||||
@@ -107,6 +107,38 @@ export class RequestOperationDto {
|
|||||||
trainScheduleId?: string;
|
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 {
|
export class OperationReviewDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -289,6 +289,99 @@ export function bookingCloseCutoff(
|
|||||||
return new Date(departure.getTime() - offsetMinutes * 60_000);
|
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 {
|
export interface InitialWindowTimes {
|
||||||
windowOpensAt: Date;
|
windowOpensAt: Date;
|
||||||
windowClosesAt: Date;
|
windowClosesAt: Date;
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ import { AvailableDaysQueryDto } from "../dto/available-days-query.dto";
|
|||||||
import { AvailableDaysForCargoQueryDto } from "../dto/available-days-for-cargo-query.dto";
|
import { AvailableDaysForCargoQueryDto } from "../dto/available-days-for-cargo-query.dto";
|
||||||
import { UpdateTrainSchedulingGlobalRulesDto } from "../dto/update-train-scheduling-global-rules.dto";
|
import { UpdateTrainSchedulingGlobalRulesDto } from "../dto/update-train-scheduling-global-rules.dto";
|
||||||
import { UpdateScheduleWindowRuleDto } from "../dto/update-schedule-window-rule.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 { UpdateScheduleDateDto } from "../dto/update-schedule-date.dto";
|
||||||
import { MergeScheduleTrainDto } from "../dto/merge-schedule-train.dto";
|
import { MergeScheduleTrainDto } from "../dto/merge-schedule-train.dto";
|
||||||
import { UpdateScheduleTrainNumberDto } from "../dto/update-schedule-train-number.dto";
|
import { UpdateScheduleTrainNumberDto } from "../dto/update-schedule-train-number.dto";
|
||||||
@@ -985,6 +986,20 @@ export class TrainSchedulingController {
|
|||||||
return this.trainSchedulingService.getContainerTrainScheduleById(id);
|
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")
|
@Patch("schedules/:id/schedule-date")
|
||||||
@TrainSchedulingUpdate()
|
@TrainSchedulingUpdate()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 8–17: 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -112,6 +112,7 @@ import {
|
|||||||
UploadImportDjiboutiDocumentDto,
|
UploadImportDjiboutiDocumentDto,
|
||||||
} from '../dto/import-djibouti-operation.dto';
|
} from '../dto/import-djibouti-operation.dto';
|
||||||
import { UpdateScheduleWindowRuleDto } from '../dto/update-schedule-window-rule.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 { UpdateScheduleDateDto } from '../dto/update-schedule-date.dto';
|
||||||
import { MergeScheduleTrainDto } from '../dto/merge-schedule-train.dto';
|
import { MergeScheduleTrainDto } from '../dto/merge-schedule-train.dto';
|
||||||
import { UpdateScheduleTrainNumberDto } from '../dto/update-schedule-train-number.dto';
|
import { UpdateScheduleTrainNumberDto } from '../dto/update-schedule-train-number.dto';
|
||||||
@@ -197,12 +198,15 @@ import { orderConsistWagons } from '../consist-order.util';
|
|||||||
import {
|
import {
|
||||||
bookingCloseCutoff,
|
bookingCloseCutoff,
|
||||||
clampCloseToOfficeHours,
|
clampCloseToOfficeHours,
|
||||||
|
closeOffsetReopenCheck,
|
||||||
computeExportWindowTimes,
|
computeExportWindowTimes,
|
||||||
computeImportWindowTimes,
|
computeImportWindowTimes,
|
||||||
earliestSchedulableDeparture,
|
earliestSchedulableDeparture,
|
||||||
eatDay,
|
eatDay,
|
||||||
eatDayToUtc,
|
eatDayToUtc,
|
||||||
|
nextCycleOpensAt,
|
||||||
shiftEatDay,
|
shiftEatDay,
|
||||||
|
type OfficeHours,
|
||||||
} from '../batch-window.util';
|
} from '../batch-window.util';
|
||||||
import { TrainCheckpointEvent } from '../entities/train-checkpoint-event.entity';
|
import { TrainCheckpointEvent } from '../entities/train-checkpoint-event.entity';
|
||||||
import { BookingJourneyService } from '../booking-journey.service';
|
import { BookingJourneyService } from '../booking-journey.service';
|
||||||
@@ -239,6 +243,19 @@ const HANDLING_FIELDS = [
|
|||||||
|
|
||||||
type HandlingField = (typeof HANDLING_FIELDS)[number][0];
|
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. */
|
/** Drops the keys a partial override left undefined, so `...` merges keep the base value. */
|
||||||
function pickDefined<T extends object>(source: T): Partial<T> {
|
function pickDefined<T extends object>(source: T): Partial<T> {
|
||||||
return Object.fromEntries(
|
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
|
* The booking-window config a specific schedule runs under: its frozen rule
|
||||||
* snapshot (open/close hour, duration, lead, reopen gap) overlaid on the live
|
* snapshot (open/close hour, duration, lead, reopen gap) overlaid on the live
|
||||||
@@ -1024,6 +1051,152 @@ export class TrainSchedulingService {
|
|||||||
return fresh ?? schedule;
|
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
|
* Correct a departure's operational run identifiers — the train number and
|
||||||
* voyage number yards and customs quote.
|
* voyage number yards and customs quote.
|
||||||
@@ -6313,8 +6486,11 @@ export class TrainSchedulingService {
|
|||||||
skip,
|
skip,
|
||||||
take,
|
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 {
|
return {
|
||||||
items: schedules.map((s) => this.mapScheduleListItem(s)),
|
items: schedules.map((s) => this.mapScheduleListItem(s, liveCfg)),
|
||||||
meta: buildPaginationMeta(total, page, pageSize),
|
meta: buildPaginationMeta(total, page, pageSize),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -8857,7 +9033,11 @@ export class TrainSchedulingService {
|
|||||||
throw new ConflictException('Could not allocate a unique schedule reference');
|
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) —
|
// Wagon figures must match the detail page's wagon plan (WagonPlanGrid) —
|
||||||
// see computeScheduleWagonUsage for why the stored counter cannot be used.
|
// see computeScheduleWagonUsage for why the stored counter cannot be used.
|
||||||
const { wagonsUsed, wagonsTotal, wagonsReserved, wagonsRemaining } =
|
const { wagonsUsed, wagonsTotal, wagonsReserved, wagonsRemaining } =
|
||||||
@@ -8921,6 +9101,18 @@ export class TrainSchedulingService {
|
|||||||
freightType: this.resolveScheduleFreightType(schedule),
|
freightType: this.resolveScheduleFreightType(schedule),
|
||||||
status: schedule.status,
|
status: schedule.status,
|
||||||
bookingWindowStatus: schedule.bookingWindowStatus ?? 'OPEN',
|
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,
|
cancellationReason: schedule.cancellationReason ?? null,
|
||||||
cancelledAt: schedule.cancelledAt ?? null,
|
cancelledAt: schedule.cancelledAt ?? null,
|
||||||
maxWagons: schedule.maxWagons ?? 0,
|
maxWagons: schedule.maxWagons ?? 0,
|
||||||
@@ -10965,6 +11157,14 @@ export class TrainSchedulingService {
|
|||||||
// settings" editor on the ops board (prefill + save one schedule's
|
// settings" editor on the ops board (prefill + save one schedule's
|
||||||
// override). docReview/payment are not snapshotted per schedule (only their
|
// override). docReview/payment are not snapshotted per schedule (only their
|
||||||
// sum, as the frozen reopen gap), so the editor prefills them from live config.
|
// 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: {
|
windowRule: {
|
||||||
windowOpenHour: schedule.ruleWindowOpenHour ?? null,
|
windowOpenHour: schedule.ruleWindowOpenHour ?? null,
|
||||||
windowCloseHour: schedule.ruleWindowCloseHour ?? null,
|
windowCloseHour: schedule.ruleWindowCloseHour ?? null,
|
||||||
@@ -10974,6 +11174,12 @@ export class TrainSchedulingService {
|
|||||||
: null,
|
: null,
|
||||||
importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null,
|
importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null,
|
||||||
exportBookingLeadHours: schedule.ruleExportBookingLeadHours ?? 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,
|
docReviewMinutes: windowCfg.docReviewMinutes,
|
||||||
// Editor prefill: this schedule's own override when staff set one,
|
// Editor prefill: this schedule's own override when staff set one,
|
||||||
// else the live global for the schedule's direction (import/export
|
// else the live global for the schedule's direction (import/export
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { ExternalLink, MoreHorizontal, Receipt } from "lucide-react";
|
import { ExternalLink, MoreHorizontal, Receipt } from "lucide-react";
|
||||||
import { Button, Menu, ActionIcon, Group, Text } from "@mantine/core";
|
import { Button, Menu, ActionIcon, Group, Text } from "@mantine/core";
|
||||||
|
|
||||||
import { BookingConfirmDialog } from "./BookingConfirmDialog";
|
import { BookingConfirmDialog } from "./BookingConfirmDialog";
|
||||||
|
import { OperationRescheduleModal } from "./OperationRescheduleModal";
|
||||||
import { useBookingActionDialog } from "./useBookingActionDialog";
|
import { useBookingActionDialog } from "./useBookingActionDialog";
|
||||||
import { useAuth } from "@/auth/useAuth";
|
import { useAuth } from "@/auth/useAuth";
|
||||||
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions";
|
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions";
|
||||||
@@ -10,6 +12,7 @@ import {
|
|||||||
isAllocateAction,
|
isAllocateAction,
|
||||||
isClearanceNavAction,
|
isClearanceNavAction,
|
||||||
isContractNavAction,
|
isContractNavAction,
|
||||||
|
isRescheduleAction,
|
||||||
listRowHasActions,
|
listRowHasActions,
|
||||||
type BookingActionContext,
|
type BookingActionContext,
|
||||||
} from "@/features/bookings/booking-actions.config";
|
} from "@/features/bookings/booking-actions.config";
|
||||||
@@ -44,6 +47,8 @@ export function BookingActionsMenu({
|
|||||||
|
|
||||||
const flow = useBookingActionDialog(row.id, context);
|
const flow = useBookingActionDialog(row.id, context);
|
||||||
const { actions, pendingAction, mutations } = flow;
|
const { actions, pendingAction, mutations } = flow;
|
||||||
|
// Day / train reschedule has its own modal (date + export train picker).
|
||||||
|
const [rescheduleOpen, setRescheduleOpen] = useState(false);
|
||||||
|
|
||||||
const goToContract = () =>
|
const goToContract = () =>
|
||||||
navigate(`/dashboard/booking-requests/${row.id}/contract`);
|
navigate(`/dashboard/booking-requests/${row.id}/contract`);
|
||||||
@@ -67,6 +72,8 @@ export function BookingActionsMenu({
|
|||||||
goToClearanceTab();
|
goToClearanceTab();
|
||||||
} else if (isAllocateAction(action.id)) {
|
} else if (isAllocateAction(action.id)) {
|
||||||
onAllocateBooking?.();
|
onAllocateBooking?.();
|
||||||
|
} else if (isRescheduleAction(action.id)) {
|
||||||
|
setRescheduleOpen(true);
|
||||||
} else {
|
} else {
|
||||||
flow.openAction(action);
|
flow.openAction(action);
|
||||||
}
|
}
|
||||||
@@ -109,6 +116,14 @@ export function BookingActionsMenu({
|
|||||||
consolidationPartnerId={row.consolidationPartnerId}
|
consolidationPartnerId={row.consolidationPartnerId}
|
||||||
consolidationPartnerReference={row.consolidationPartnerReference}
|
consolidationPartnerReference={row.consolidationPartnerReference}
|
||||||
/>
|
/>
|
||||||
|
<OperationRescheduleModal
|
||||||
|
bookingId={row.id}
|
||||||
|
opened={rescheduleOpen}
|
||||||
|
onClose={() => {
|
||||||
|
onSuppressRowClick?.();
|
||||||
|
setRescheduleOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -183,6 +198,14 @@ export function BookingActionsMenu({
|
|||||||
consolidationPartnerId={row.consolidationPartnerId}
|
consolidationPartnerId={row.consolidationPartnerId}
|
||||||
consolidationPartnerReference={row.consolidationPartnerReference}
|
consolidationPartnerReference={row.consolidationPartnerReference}
|
||||||
/>
|
/>
|
||||||
|
<OperationRescheduleModal
|
||||||
|
bookingId={row.id}
|
||||||
|
opened={rescheduleOpen}
|
||||||
|
onClose={() => {
|
||||||
|
onSuppressRowClick?.();
|
||||||
|
setRescheduleOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Modal,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Textarea,
|
||||||
|
ThemeIcon,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { DateInput } from "@mantine/dates";
|
||||||
|
import { CalendarClock, Info } from "lucide-react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
import {
|
||||||
|
useBookingDetail,
|
||||||
|
useBookingMutations,
|
||||||
|
} from "@/hooks/bookings/useBookings";
|
||||||
|
|
||||||
|
const EAT = "Africa/Addis_Ababa";
|
||||||
|
|
||||||
|
/** YYYY-MM-DD of an instant in East Africa Time — the booking day key. */
|
||||||
|
function eatDay(value: string | Date): string {
|
||||||
|
const date = typeof value === "string" ? new Date(value) : value;
|
||||||
|
return new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone: EAT,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEat(value: string | Date | null | undefined): string {
|
||||||
|
if (!value) return "—";
|
||||||
|
const date = typeof value === "string" ? new Date(value) : value;
|
||||||
|
if (Number.isNaN(date.getTime())) return "—";
|
||||||
|
return new Intl.DateTimeFormat("en-GB", {
|
||||||
|
timeZone: EAT,
|
||||||
|
weekday: "short",
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors the API road-service rule: ServiceType.code ROAD, TRUCK, ROAD_*, TRUCK_* */
|
||||||
|
function isRoadServiceCode(code: string | null | undefined): boolean {
|
||||||
|
const c = (code ?? "").toUpperCase();
|
||||||
|
return (
|
||||||
|
c === "ROAD" ||
|
||||||
|
c === "TRUCK" ||
|
||||||
|
c.startsWith("ROAD_") ||
|
||||||
|
c.startsWith("TRUCK_")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OperationRescheduleModalProps {
|
||||||
|
bookingId: string;
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operations moves a pending operation request to another shipment day and,
|
||||||
|
* for export rail, another train — instead of returning it to the customer.
|
||||||
|
* The server re-runs the customer's own gates (open departure that day, wagon
|
||||||
|
* that can carry the cargo, export train with room) and refuses with the
|
||||||
|
* reason if the new day does not work.
|
||||||
|
*/
|
||||||
|
export function OperationRescheduleModal({
|
||||||
|
bookingId,
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
}: OperationRescheduleModalProps) {
|
||||||
|
const detailQuery = useBookingDetail(opened ? bookingId : undefined);
|
||||||
|
const booking = detailQuery.data;
|
||||||
|
const mutations = useBookingMutations(bookingId);
|
||||||
|
|
||||||
|
const isExportRail =
|
||||||
|
booking?.tradeDirection === "EXPORT" &&
|
||||||
|
!isRoadServiceCode(booking.serviceType?.code);
|
||||||
|
|
||||||
|
const [day, setDay] = useState<Date | null>(null);
|
||||||
|
const [trainId, setTrainId] = useState<string | null>(null);
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
|
||||||
|
// Seed from the booking each time the modal opens: the current day and, for
|
||||||
|
// export, the train the customer picked (the detail's requested/allocated train).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!opened || !booking) return;
|
||||||
|
setDay(booking.scheduledDate ? new Date(booking.scheduledDate) : null);
|
||||||
|
setTrainId(booking.trainScheduleSummary?.id ?? null);
|
||||||
|
setNote("");
|
||||||
|
}, [opened, booking]);
|
||||||
|
|
||||||
|
const dayKey = day ? eatDay(day) : null;
|
||||||
|
const currentDayKey = booking?.scheduledDate
|
||||||
|
? eatDay(booking.scheduledDate)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Days with an open departure on the booking's route — a planning hint; the
|
||||||
|
// server still validates the pick.
|
||||||
|
const daysQuery = useQuery({
|
||||||
|
...api.trainScheduling.availableDays.queryOptions({
|
||||||
|
input: {
|
||||||
|
originYardId: booking?.originYard?.id ?? null,
|
||||||
|
destinationYardId: booking?.destinationYard?.id ?? null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
enabled:
|
||||||
|
opened &&
|
||||||
|
Boolean(booking?.originYard?.id && booking?.destinationYard?.id),
|
||||||
|
});
|
||||||
|
const availableDays = useMemo(
|
||||||
|
() => new Set((daysQuery.data ?? []).map((d) => eatDay(d))),
|
||||||
|
[daysQuery.data],
|
||||||
|
);
|
||||||
|
const dayHasDeparture = dayKey ? availableDays.has(dayKey) : false;
|
||||||
|
|
||||||
|
// Export rail: the day's export trains with free space, so staff pick one.
|
||||||
|
const trainsQuery = useQuery({
|
||||||
|
...api.trainScheduling.exportTrains.queryOptions({
|
||||||
|
input: { bookingId, date: day ? day.toISOString() : "" },
|
||||||
|
}),
|
||||||
|
enabled: opened && isExportRail && Boolean(day),
|
||||||
|
});
|
||||||
|
const trainOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
(trainsQuery.data ?? []).map((t) => ({
|
||||||
|
value: t.scheduleId,
|
||||||
|
label:
|
||||||
|
`${t.trainNumber ?? t.trainName ?? "Train"} · departs ${formatEat(t.departure)} · ` +
|
||||||
|
`${t.freeWagons} free / needs ${t.neededWagons}` +
|
||||||
|
(!t.isOpen ? " · closed" : !t.fits ? " · no room" : ""),
|
||||||
|
disabled: !t.isOpen || !t.fits,
|
||||||
|
})),
|
||||||
|
[trainsQuery.data],
|
||||||
|
);
|
||||||
|
// A train belongs to one day: changing the day drops a pick from another day.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isExportRail || !trainsQuery.data) return;
|
||||||
|
if (trainId && !trainsQuery.data.some((t) => t.scheduleId === trainId)) {
|
||||||
|
setTrainId(null);
|
||||||
|
}
|
||||||
|
}, [isExportRail, trainsQuery.data, trainId]);
|
||||||
|
|
||||||
|
const unchanged =
|
||||||
|
dayKey != null &&
|
||||||
|
dayKey === currentDayKey &&
|
||||||
|
(!isExportRail || trainId === (booking?.trainScheduleSummary?.id ?? null));
|
||||||
|
const canSave =
|
||||||
|
Boolean(day) && !unchanged && (!isExportRail || Boolean(trainId));
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!day || !canSave) return;
|
||||||
|
mutations.rescheduleOperation.mutate(
|
||||||
|
{
|
||||||
|
scheduledDate: day.toISOString(),
|
||||||
|
...(isExportRail && trainId ? { trainScheduleId: trainId } : {}),
|
||||||
|
...(note.trim() ? { note: note.trim() } : {}),
|
||||||
|
},
|
||||||
|
{ onSuccess: () => onClose() },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
centered
|
||||||
|
radius="lg"
|
||||||
|
size="md"
|
||||||
|
title={
|
||||||
|
<Group gap="sm">
|
||||||
|
<ThemeIcon variant="light" color="edr-green" radius="md" size={34}>
|
||||||
|
<CalendarClock size={18} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Box>
|
||||||
|
<Text fw={600} lh={1.2}>
|
||||||
|
Change train / shipment day
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" lh={1.2}>
|
||||||
|
{booking?.reference ?? "Booking"}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{detailQuery.isLoading || !booking ? (
|
||||||
|
<Group justify="center" py="xl">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert variant="light" color="blue" icon={<Info size={16} />}>
|
||||||
|
Sets the shipment day
|
||||||
|
{isExportRail ? " and the export train " : " "}
|
||||||
|
for the customer, so nothing has to go back to them. The request
|
||||||
|
stays under review for the normal accept, and the customer is told
|
||||||
|
the new day.
|
||||||
|
{!isExportRail
|
||||||
|
? " Import and domestic trains are assigned by the batch engine on the chosen day."
|
||||||
|
: ""}
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Group gap="xs" wrap="wrap">
|
||||||
|
<Badge variant="light" color="gray">
|
||||||
|
Currently {currentDayKey ?? "no day"}
|
||||||
|
</Badge>
|
||||||
|
{booking.trainScheduleSummary ? (
|
||||||
|
<Badge variant="light" color="gray">
|
||||||
|
{booking.trainScheduleSummary.trainNumber ??
|
||||||
|
booking.trainScheduleSummary.reference ??
|
||||||
|
"train"}
|
||||||
|
{booking.trainScheduleSummary.isRequested ? " (requested)" : ""}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<DateInput
|
||||||
|
label="New shipment day"
|
||||||
|
description={
|
||||||
|
daysQuery.data && daysQuery.data.length
|
||||||
|
? "Days with an open departure on this route are selectable."
|
||||||
|
: "Pick the train departure day."
|
||||||
|
}
|
||||||
|
value={day}
|
||||||
|
onChange={(v) => setDay(v ? new Date(v) : null)}
|
||||||
|
minDate={new Date()}
|
||||||
|
excludeDate={
|
||||||
|
daysQuery.data && daysQuery.data.length
|
||||||
|
? (d) => !availableDays.has(eatDay(d))
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
popoverProps={{ withinPortal: true }}
|
||||||
|
/>
|
||||||
|
{day &&
|
||||||
|
daysQuery.data &&
|
||||||
|
daysQuery.data.length &&
|
||||||
|
!dayHasDeparture ? (
|
||||||
|
<Text size="xs" c="red">
|
||||||
|
No open departure on this route for {dayKey}.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isExportRail ? (
|
||||||
|
<Select
|
||||||
|
label="Export train"
|
||||||
|
placeholder={
|
||||||
|
!day
|
||||||
|
? "Pick a day first"
|
||||||
|
: trainsQuery.isLoading
|
||||||
|
? "Loading trains…"
|
||||||
|
: "Select a train with room"
|
||||||
|
}
|
||||||
|
data={trainOptions}
|
||||||
|
value={trainId}
|
||||||
|
onChange={setTrainId}
|
||||||
|
disabled={!day || trainsQuery.isLoading}
|
||||||
|
nothingFoundMessage="No export train on this day"
|
||||||
|
comboboxProps={{ withinPortal: true }}
|
||||||
|
searchable
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
label="Note to customer (optional)"
|
||||||
|
placeholder="Why the day is changing…"
|
||||||
|
value={note}
|
||||||
|
onChange={(e) => setNote(e.currentTarget.value)}
|
||||||
|
autosize
|
||||||
|
minRows={2}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group justify="flex-end" mt="xs">
|
||||||
|
<Button variant="default" radius="md" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
radius="md"
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<CalendarClock size={16} />}
|
||||||
|
loading={mutations.rescheduleOperation.isPending}
|
||||||
|
disabled={!canSave}
|
||||||
|
onClick={handleSave}
|
||||||
|
>
|
||||||
|
Save new day
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OperationRescheduleModal;
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Modal,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { isAxiosError } from "axios";
|
||||||
|
import { Info, Unlock } from "lucide-react";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import DurationField from "@/components/trainScheduling/DurationField";
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
const EAT = "Africa/Addis_Ababa";
|
||||||
|
|
||||||
|
/** "3 days" / "2 hr" / "45 min" for a minute count. */
|
||||||
|
function describeMinutes(minutes: number): string {
|
||||||
|
if (minutes <= 0) return "at departure";
|
||||||
|
if (minutes % 1440 === 0) {
|
||||||
|
const d = minutes / 1440;
|
||||||
|
return `${d} day${d === 1 ? "" : "s"}`;
|
||||||
|
}
|
||||||
|
if (minutes % 60 === 0) {
|
||||||
|
const h = minutes / 60;
|
||||||
|
return `${h} hr`;
|
||||||
|
}
|
||||||
|
return `${minutes} min`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEat(value: string | Date | null | undefined): string {
|
||||||
|
if (!value) return "—";
|
||||||
|
const date = typeof value === "string" ? new Date(value) : value;
|
||||||
|
if (Number.isNaN(date.getTime())) return "—";
|
||||||
|
return new Intl.DateTimeFormat("en-GB", {
|
||||||
|
timeZone: EAT,
|
||||||
|
weekday: "short",
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseError(error: unknown, fallback: string): string {
|
||||||
|
if (isAxiosError(error)) {
|
||||||
|
const message = error.response?.data?.message;
|
||||||
|
if (Array.isArray(message)) return message.join(", ");
|
||||||
|
if (typeof message === "string") return message;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReduceCloseOffsetModalProps {
|
||||||
|
scheduleId: string | null;
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
/** Called after a successful save (e.g. to refetch a list). */
|
||||||
|
onSaved?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reopen a schedule whose booking shut ONLY because of its close offset, by
|
||||||
|
* shortening that offset (3 days → 1 day, 2 hours, …). The API decides
|
||||||
|
* eligibility (`closeOffsetReopen`); every other kind of closed window is
|
||||||
|
* explained and left alone.
|
||||||
|
*/
|
||||||
|
export default function ReduceCloseOffsetModal({
|
||||||
|
scheduleId,
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: ReduceCloseOffsetModalProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const detailQuery = useQuery({
|
||||||
|
...api.trainScheduling.scheduleDetail.queryOptions({
|
||||||
|
input: { id: scheduleId ?? "" },
|
||||||
|
}),
|
||||||
|
enabled: opened && Boolean(scheduleId),
|
||||||
|
});
|
||||||
|
const schedule = detailQuery.data;
|
||||||
|
const reopen = schedule?.closeOffsetReopen ?? null;
|
||||||
|
const currentOffset = reopen?.offsetMinutes ?? null;
|
||||||
|
|
||||||
|
const save = useMutation(
|
||||||
|
api.trainScheduling.reduceScheduleCloseOffset.mutationOptions(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// New offset, in minutes (what the API stores). Seeded to the current offset
|
||||||
|
// so the field reads as "shorten this", never as an empty box.
|
||||||
|
const [offsetMinutes, setOffsetMinutes] = useState<number | "">("");
|
||||||
|
useEffect(() => {
|
||||||
|
if (!opened) return;
|
||||||
|
setOffsetMinutes(currentOffset ?? "");
|
||||||
|
}, [opened, currentOffset]);
|
||||||
|
|
||||||
|
const departure = schedule?.scheduledDepartureDate
|
||||||
|
? new Date(schedule.scheduledDepartureDate)
|
||||||
|
: null;
|
||||||
|
const newCutoff = useMemo(() => {
|
||||||
|
if (!departure || offsetMinutes === "") return null;
|
||||||
|
const n = Number(offsetMinutes);
|
||||||
|
if (!Number.isFinite(n) || n < 0) return null;
|
||||||
|
return new Date(departure.getTime() - n * 60_000);
|
||||||
|
}, [departure, offsetMinutes]);
|
||||||
|
|
||||||
|
const value = offsetMinutes === "" ? NaN : Number(offsetMinutes);
|
||||||
|
const isShorter =
|
||||||
|
Number.isFinite(value) && currentOffset != null && value < currentOffset;
|
||||||
|
const cutoffInPast = newCutoff != null && newCutoff.getTime() <= Date.now();
|
||||||
|
const canSave =
|
||||||
|
reopen?.eligible === true && isShorter && value >= 0 && !cutoffInPast;
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!scheduleId || !canSave) return;
|
||||||
|
try {
|
||||||
|
await save.mutateAsync({
|
||||||
|
id: scheduleId,
|
||||||
|
payload: { closeOffsetMinutes: Math.round(value) },
|
||||||
|
});
|
||||||
|
toast({
|
||||||
|
title: "Booking window reopened",
|
||||||
|
description: `Booking now closes ${describeMinutes(Math.round(value))} before departure.`,
|
||||||
|
});
|
||||||
|
onSaved?.();
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: "Could not reopen booking",
|
||||||
|
description: parseError(err, "The close offset was not changed."),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
centered
|
||||||
|
radius="lg"
|
||||||
|
size="md"
|
||||||
|
title={
|
||||||
|
<Group gap="sm">
|
||||||
|
<ThemeIcon variant="light" color="edr-green" radius="md" size={34}>
|
||||||
|
<Unlock size={18} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Box>
|
||||||
|
<Text fw={600} lh={1.2}>
|
||||||
|
Reopen booking — shorten close offset
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" lh={1.2}>
|
||||||
|
{schedule?.route?.name ?? "This schedule"}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{detailQuery.isLoading || !schedule ? (
|
||||||
|
<Group justify="center" py="xl">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Group>
|
||||||
|
) : !reopen?.eligible ? (
|
||||||
|
<Alert
|
||||||
|
variant="light"
|
||||||
|
color="yellow"
|
||||||
|
icon={<Info size={16} />}
|
||||||
|
title="The close offset is not what closed this train"
|
||||||
|
>
|
||||||
|
{reopen?.reason ??
|
||||||
|
"This schedule cannot be reopened by shortening its close offset."}
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Stack gap="lg">
|
||||||
|
<Alert variant="light" color="blue" icon={<Info size={16} />}>
|
||||||
|
Booking on this train closed early because of its close offset — the
|
||||||
|
train itself has not left. Shorten the offset and the desk reopens
|
||||||
|
at its next opening (right away if it is open now) until the new
|
||||||
|
cutoff.
|
||||||
|
{schedule.direction !== "EXPORT"
|
||||||
|
? " Every train on this route departing the same day that is closed for the same reason reopens with it."
|
||||||
|
: ""}
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Text size="sm" fw={600} mb={6}>
|
||||||
|
Current close
|
||||||
|
</Text>
|
||||||
|
<Group gap="xs" wrap="wrap">
|
||||||
|
<Badge variant="light" color="red">
|
||||||
|
Closes {describeMinutes(currentOffset ?? 0)} before departure
|
||||||
|
</Badge>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
closed {formatEat(reopen.cutoffAt)} EAT · departs{" "}
|
||||||
|
{formatEat(departure)} EAT
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<DurationField
|
||||||
|
label="New close offset (before departure)"
|
||||||
|
description="Must be shorter than the current offset. 0 = booking stays open until the train departs."
|
||||||
|
value={offsetMinutes}
|
||||||
|
nativeUnit="minutes"
|
||||||
|
min={0}
|
||||||
|
onChange={setOffsetMinutes}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{newCutoff ? (
|
||||||
|
<Text size="sm">
|
||||||
|
Booking would now close{" "}
|
||||||
|
<Text span fw={600}>
|
||||||
|
{formatEat(newCutoff)} EAT
|
||||||
|
</Text>
|
||||||
|
{cutoffInPast ? (
|
||||||
|
<Text span c="red">
|
||||||
|
{" "}
|
||||||
|
— that is already in the past; shorten it further.
|
||||||
|
</Text>
|
||||||
|
) : !isShorter ? (
|
||||||
|
<Text span c="red">
|
||||||
|
{" "}
|
||||||
|
— not shorter than the current offset.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Group justify="flex-end" mt="xs">
|
||||||
|
<Button variant="default" radius="md" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
radius="md"
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<Unlock size={16} />}
|
||||||
|
loading={save.isPending}
|
||||||
|
disabled={!canSave}
|
||||||
|
onClick={() => void handleSave()}
|
||||||
|
>
|
||||||
|
Reopen booking
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -263,6 +263,8 @@ export const URL_CONSTANTS = {
|
|||||||
`/bookings/${id}/clearance/export-release`,
|
`/bookings/${id}/clearance/export-release`,
|
||||||
// Re-request operation after Operations sent the booking back for changes.
|
// Re-request operation after Operations sent the booking back for changes.
|
||||||
CLEARANCE_PROCEED: (id: string) => `/bookings/${id}/clearance/proceed`,
|
CLEARANCE_PROCEED: (id: string) => `/bookings/${id}/clearance/proceed`,
|
||||||
|
// Operations changes a pending request's shipment day / train themselves.
|
||||||
|
OPERATION_RESCHEDULE: (id: string) => `/bookings/${id}/operation/reschedule`,
|
||||||
CLEARANCE_ET_QUEUE: "/bookings/clearance/et-queue",
|
CLEARANCE_ET_QUEUE: "/bookings/clearance/et-queue",
|
||||||
CLEARANCE_DJ_QUEUE: "/bookings/clearance/dj-queue",
|
CLEARANCE_DJ_QUEUE: "/bookings/clearance/dj-queue",
|
||||||
},
|
},
|
||||||
@@ -435,6 +437,8 @@ export const URL_CONSTANTS = {
|
|||||||
`/train-scheduling/schedules/${id}/booking-window`,
|
`/train-scheduling/schedules/${id}/booking-window`,
|
||||||
WINDOW_RULE: (id: string) =>
|
WINDOW_RULE: (id: string) =>
|
||||||
`/train-scheduling/schedules/${id}/window-rule`,
|
`/train-scheduling/schedules/${id}/window-rule`,
|
||||||
|
CLOSE_OFFSET: (id: string) =>
|
||||||
|
`/train-scheduling/schedules/${id}/close-offset`,
|
||||||
SCHEDULE_DATE: (id: string) =>
|
SCHEDULE_DATE: (id: string) =>
|
||||||
`/train-scheduling/schedules/${id}/schedule-date`,
|
`/train-scheduling/schedules/${id}/schedule-date`,
|
||||||
MERGE_PREVIEW: (id: string, targetTrainId: string) =>
|
MERGE_PREVIEW: (id: string, targetTrainId: string) =>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Ban,
|
Ban,
|
||||||
|
CalendarClock,
|
||||||
Check,
|
Check,
|
||||||
MessageSquareWarning,
|
MessageSquareWarning,
|
||||||
Play,
|
Play,
|
||||||
@@ -28,6 +29,7 @@ export type BookingActionId =
|
|||||||
| "complete"
|
| "complete"
|
||||||
| "operationAccept"
|
| "operationAccept"
|
||||||
| "operationRequestChanges"
|
| "operationRequestChanges"
|
||||||
|
| "operationReschedule"
|
||||||
| "cancel";
|
| "cancel";
|
||||||
|
|
||||||
export type BookingActionInputKind =
|
export type BookingActionInputKind =
|
||||||
@@ -128,6 +130,22 @@ const SUBMITTED_ACTIONS: BookingActionDef[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operations changes the shipment day / train themselves, instead of returning
|
||||||
|
* the request to the customer. Opens its own modal (day + export train picker),
|
||||||
|
* not the generic confirm dialog — see isRescheduleAction.
|
||||||
|
*/
|
||||||
|
const OPERATION_RESCHEDULE_ACTION: BookingActionDef = {
|
||||||
|
id: "operationReschedule",
|
||||||
|
label: "Change train / shipment day",
|
||||||
|
shortLabel: "Reschedule",
|
||||||
|
description: "Move the request to another shipment day or train yourself",
|
||||||
|
confirmTitle: "",
|
||||||
|
confirmDescription: "",
|
||||||
|
variant: "outline",
|
||||||
|
icon: CalendarClock,
|
||||||
|
};
|
||||||
|
|
||||||
// Marketing/operations review of a drawdown order's operation request.
|
// Marketing/operations review of a drawdown order's operation request.
|
||||||
const OPERATION_REVIEW_ACTIONS: BookingActionDef[] = [
|
const OPERATION_REVIEW_ACTIONS: BookingActionDef[] = [
|
||||||
{
|
{
|
||||||
@@ -156,6 +174,7 @@ const OPERATION_REVIEW_ACTIONS: BookingActionDef[] = [
|
|||||||
inputLabel: "Message to customer",
|
inputLabel: "Message to customer",
|
||||||
inputPlaceholder: "Describe what needs to change…",
|
inputPlaceholder: "Describe what needs to change…",
|
||||||
},
|
},
|
||||||
|
OPERATION_RESCHEDULE_ACTION,
|
||||||
];
|
];
|
||||||
|
|
||||||
const CANCEL_ACTION: BookingActionDef = {
|
const CANCEL_ACTION: BookingActionDef = {
|
||||||
@@ -202,6 +221,7 @@ const ACTION_PERMISSION: Partial<Record<BookingActionId, string>> = {
|
|||||||
complete: FREIGHT_PERMS.bookings.operations,
|
complete: FREIGHT_PERMS.bookings.operations,
|
||||||
operationAccept: FREIGHT_PERMS.bookings.operations,
|
operationAccept: FREIGHT_PERMS.bookings.operations,
|
||||||
operationRequestChanges: FREIGHT_PERMS.bookings.operations,
|
operationRequestChanges: FREIGHT_PERMS.bookings.operations,
|
||||||
|
operationReschedule: FREIGHT_PERMS.bookings.operations,
|
||||||
allocateBooking: FREIGHT_PERMS.trainScheduling.update,
|
allocateBooking: FREIGHT_PERMS.trainScheduling.update,
|
||||||
cancel: FREIGHT_PERMS.bookings.cancel,
|
cancel: FREIGHT_PERMS.bookings.cancel,
|
||||||
};
|
};
|
||||||
@@ -253,6 +273,11 @@ export function getBookingActions(
|
|||||||
case "OPERATION_REQUEST_PENDING":
|
case "OPERATION_REQUEST_PENDING":
|
||||||
actions = withCancel(OPERATION_REVIEW_ACTIONS);
|
actions = withCancel(OPERATION_REVIEW_ACTIONS);
|
||||||
break;
|
break;
|
||||||
|
case "OPERATION_CHANGES_REQUESTED":
|
||||||
|
// Waiting on the customer — but Operations may also resolve their own
|
||||||
|
// change request by setting the day / train directly.
|
||||||
|
actions = [OPERATION_RESCHEDULE_ACTION];
|
||||||
|
break;
|
||||||
case "PAID":
|
case "PAID":
|
||||||
// Allocate is handled by the Operations "Ready to allocate" queue, not the
|
// Allocate is handled by the Operations "Ready to allocate" queue, not the
|
||||||
// per-booking action menu. Start transit was removed entirely. No per-row
|
// per-booking action menu. Start transit was removed entirely. No per-row
|
||||||
@@ -300,6 +325,11 @@ export function isAllocateAction(id: BookingActionId): boolean {
|
|||||||
return id === "allocateBooking";
|
return id === "allocateBooking";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Opens the day / train reschedule modal instead of the generic confirm dialog. */
|
||||||
|
export function isRescheduleAction(id: BookingActionId): boolean {
|
||||||
|
return id === "operationReschedule";
|
||||||
|
}
|
||||||
|
|
||||||
/** Opens the booking detail on the Clearance tab without a confirm dialog. */
|
/** Opens the booking detail on the Clearance tab without a confirm dialog. */
|
||||||
export function isClearanceNavAction(id: BookingActionId): boolean {
|
export function isClearanceNavAction(id: BookingActionId): boolean {
|
||||||
return id === "reviewClearance";
|
return id === "reviewClearance";
|
||||||
|
|||||||
@@ -64,6 +64,17 @@ export function useBookingMutations(bookingId: string) {
|
|||||||
onError: (error) => toast.error(parseApiError(error, "Failed to reject booking")),
|
onError: (error) => toast.error(parseApiError(error, "Failed to reject booking")),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const rescheduleOperation = useMutation({
|
||||||
|
mutationFn: (payload: {
|
||||||
|
scheduledDate: string;
|
||||||
|
trainScheduleId?: string;
|
||||||
|
note?: string;
|
||||||
|
}) => api.bookings.rescheduleOperation.call({ id: bookingId, ...payload }),
|
||||||
|
onSuccess: (data) => onSuccess(data, "Shipment day updated"),
|
||||||
|
onError: (error) =>
|
||||||
|
toast.error(parseApiError(error, "Failed to change the shipment day")),
|
||||||
|
});
|
||||||
|
|
||||||
const reviewOperation = useMutation({
|
const reviewOperation = useMutation({
|
||||||
mutationFn: (payload: {
|
mutationFn: (payload: {
|
||||||
decision: "ACCEPT" | "REQUEST_CHANGES";
|
decision: "ACCEPT" | "REQUEST_CHANGES";
|
||||||
@@ -162,6 +173,7 @@ export function useBookingMutations(bookingId: string) {
|
|||||||
startTransit.isPending ||
|
startTransit.isPending ||
|
||||||
complete.isPending ||
|
complete.isPending ||
|
||||||
reviewOperation.isPending ||
|
reviewOperation.isPending ||
|
||||||
|
rescheduleOperation.isPending ||
|
||||||
cancel.isPending;
|
cancel.isPending;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -170,6 +182,7 @@ export function useBookingMutations(bookingId: string) {
|
|||||||
requestChanges,
|
requestChanges,
|
||||||
staffReject,
|
staffReject,
|
||||||
reviewOperation,
|
reviewOperation,
|
||||||
|
rescheduleOperation,
|
||||||
generateContract,
|
generateContract,
|
||||||
signContract,
|
signContract,
|
||||||
payBooking,
|
payBooking,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
Ruler,
|
Ruler,
|
||||||
Send,
|
Send,
|
||||||
Train,
|
Train,
|
||||||
|
Unlock,
|
||||||
Weight,
|
Weight,
|
||||||
Workflow as WorkflowIcon,
|
Workflow as WorkflowIcon,
|
||||||
Warehouse,
|
Warehouse,
|
||||||
@@ -77,6 +78,7 @@ import { StationWorkControls } from "@/components/trainScheduling/StationWorkCon
|
|||||||
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
|
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
|
||||||
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
|
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
|
||||||
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
|
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
|
||||||
|
import ReduceCloseOffsetModal from "@/components/trainScheduling/ReduceCloseOffsetModal";
|
||||||
// import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
|
// import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
|
||||||
import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep";
|
import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep";
|
||||||
import { SwitchGovernmentBookingModal } from "@/components/trainScheduling/SwitchGovernmentBookingModal";
|
import { SwitchGovernmentBookingModal } from "@/components/trainScheduling/SwitchGovernmentBookingModal";
|
||||||
@@ -135,6 +137,8 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
const [containerPlacements, setContainerPlacements] = useState<ContainerPlacement[]>([]);
|
const [containerPlacements, setContainerPlacements] = useState<ContainerPlacement[]>([]);
|
||||||
const [maintenanceOpen, setMaintenanceOpen] = useState(false);
|
const [maintenanceOpen, setMaintenanceOpen] = useState(false);
|
||||||
const [windowSettingsOpen, setWindowSettingsOpen] = useState(false);
|
const [windowSettingsOpen, setWindowSettingsOpen] = useState(false);
|
||||||
|
// Reopen a train whose booking shut only because of its close offset.
|
||||||
|
const [closeOffsetOpen, setCloseOffsetOpen] = useState(false);
|
||||||
const [mergeModalOpen, setMergeModalOpen] = useState(false);
|
const [mergeModalOpen, setMergeModalOpen] = useState(false);
|
||||||
const [gatepassSecuredAt, setGatepassSecuredAt] = useState("");
|
const [gatepassSecuredAt, setGatepassSecuredAt] = useState("");
|
||||||
const [gatepassReference, setGatepassReference] = useState("");
|
const [gatepassReference, setGatepassReference] = useState("");
|
||||||
@@ -1441,6 +1445,15 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
Window settings
|
Window settings
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
) : null}
|
) : null}
|
||||||
|
{schedule.closeOffsetReopen?.eligible ? (
|
||||||
|
<Menu.Item
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<Unlock size={15} />}
|
||||||
|
onClick={() => setCloseOffsetOpen(true)}
|
||||||
|
>
|
||||||
|
Reopen booking (shorten close offset)
|
||||||
|
</Menu.Item>
|
||||||
|
) : null}
|
||||||
{["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
|
{["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
|
||||||
<Menu.Item onClick={() => setMaintenanceOpen(true)}>
|
<Menu.Item onClick={() => setMaintenanceOpen(true)}>
|
||||||
Reschedule train
|
Reschedule train
|
||||||
@@ -1820,6 +1833,13 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
onSaved={() => void detailQuery.refetch()}
|
onSaved={() => void detailQuery.refetch()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ReduceCloseOffsetModal
|
||||||
|
scheduleId={scheduleId ?? null}
|
||||||
|
opened={closeOffsetOpen}
|
||||||
|
onClose={() => setCloseOffsetOpen(false)}
|
||||||
|
onSaved={() => void detailQuery.refetch()}
|
||||||
|
/>
|
||||||
|
|
||||||
<LoadEmptyContainersModal
|
<LoadEmptyContainersModal
|
||||||
opened={loadEmptiesOpen}
|
opened={loadEmptiesOpen}
|
||||||
onClose={() => setLoadEmptiesOpen(false)}
|
onClose={() => setLoadEmptiesOpen(false)}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
Send,
|
Send,
|
||||||
Table2,
|
Table2,
|
||||||
Train,
|
Train,
|
||||||
|
Unlock,
|
||||||
Users,
|
Users,
|
||||||
Weight,
|
Weight,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -58,6 +59,7 @@ import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
|||||||
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
||||||
import { directionColor, directionRowStyle } from "@/components/trainBuilder/trainStatus";
|
import { directionColor, directionRowStyle } from "@/components/trainBuilder/trainStatus";
|
||||||
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
|
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
|
||||||
|
import ReduceCloseOffsetModal from "@/components/trainScheduling/ReduceCloseOffsetModal";
|
||||||
import CreateScheduleWindowFields, {
|
import CreateScheduleWindowFields, {
|
||||||
buildWindowRulePayload,
|
buildWindowRulePayload,
|
||||||
type WindowFormState,
|
type WindowFormState,
|
||||||
@@ -145,6 +147,9 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
const { viewMode, setViewMode } = useFleetViewMode("train-scheduling-v2");
|
const { viewMode, setViewMode } = useFleetViewMode("train-scheduling-v2");
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [windowSettingsId, setWindowSettingsId] = useState<string | null>(null);
|
const [windowSettingsId, setWindowSettingsId] = useState<string | null>(null);
|
||||||
|
// "Shorten close offset": reopens a train whose booking shut only because
|
||||||
|
// of its close offset (the API flags exactly those rows).
|
||||||
|
const [closeOffsetId, setCloseOffsetId] = useState<string | null>(null);
|
||||||
// Dispatch is irreversible from this screen, so it goes through an explicit
|
// Dispatch is irreversible from this screen, so it goes through an explicit
|
||||||
// confirmation.
|
// confirmation.
|
||||||
const [dispatchTarget, setDispatchTarget] = useState<TrainScheduleListItem | null>(null);
|
const [dispatchTarget, setDispatchTarget] = useState<TrainScheduleListItem | null>(null);
|
||||||
@@ -434,6 +439,15 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
Booking window settings
|
Booking window settings
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
) : null}
|
) : null}
|
||||||
|
{schedule.closeOffsetReopen?.eligible ? (
|
||||||
|
<Menu.Item
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<Unlock size={15} />}
|
||||||
|
onClick={() => setCloseOffsetId(schedule.id)}
|
||||||
|
>
|
||||||
|
Reopen booking (shorten close offset)
|
||||||
|
</Menu.Item>
|
||||||
|
) : null}
|
||||||
{/* Start the run. Same transition as the detail page's
|
{/* Start the run. Same transition as the detail page's
|
||||||
Dispatch button — that page also shows unassigned-wagon
|
Dispatch button — that page also shows unassigned-wagon
|
||||||
and not-loaded warnings, so it stays the fuller surface. */}
|
and not-loaded warnings, so it stays the fuller surface. */}
|
||||||
@@ -805,6 +819,13 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
onSaved={() => void schedulesQuery.refetch()}
|
onSaved={() => void schedulesQuery.refetch()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ReduceCloseOffsetModal
|
||||||
|
scheduleId={closeOffsetId}
|
||||||
|
opened={closeOffsetId != null}
|
||||||
|
onClose={() => setCloseOffsetId(null)}
|
||||||
|
onSaved={() => void schedulesQuery.refetch()}
|
||||||
|
/>
|
||||||
|
|
||||||
<EditScheduleDateModal
|
<EditScheduleDateModal
|
||||||
scheduleId={editDateSchedule?.id ?? null}
|
scheduleId={editDateSchedule?.id ?? null}
|
||||||
currentDate={editDateSchedule?.scheduleDate ?? null}
|
currentDate={editDateSchedule?.scheduleDate ?? null}
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ import type {
|
|||||||
TrainScheduleFilters,
|
TrainScheduleFilters,
|
||||||
TrainScheduleListFilters,
|
TrainScheduleListFilters,
|
||||||
TrainScheduleListResponse,
|
TrainScheduleListResponse,
|
||||||
|
ReduceScheduleCloseOffsetPayload,
|
||||||
UpdateScheduleWindowRulePayload,
|
UpdateScheduleWindowRulePayload,
|
||||||
TrainSchedulePreviewPayload,
|
TrainSchedulePreviewPayload,
|
||||||
TrainSchedulePreviewResponse,
|
TrainSchedulePreviewResponse,
|
||||||
@@ -713,6 +714,18 @@ export const api = {
|
|||||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
reduceScheduleCloseOffset: endpoint<
|
||||||
|
{ id: string; payload: ReduceScheduleCloseOffsetPayload },
|
||||||
|
TrainScheduleDetail
|
||||||
|
>(
|
||||||
|
"train-scheduling",
|
||||||
|
"reduce-schedule-close-offset",
|
||||||
|
({ id, payload }) =>
|
||||||
|
trainSchedulingService.reduceScheduleCloseOffset(id, payload),
|
||||||
|
undefined,
|
||||||
|
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||||
|
),
|
||||||
|
|
||||||
updateScheduleDate: endpoint<
|
updateScheduleDate: endpoint<
|
||||||
{ id: string; scheduleDate: string },
|
{ id: string; scheduleDate: string },
|
||||||
TrainScheduleDetail
|
TrainScheduleDetail
|
||||||
@@ -3220,6 +3233,18 @@ export const api = {
|
|||||||
bookingsService.reviewOperation(id, decision, { note }),
|
bookingsService.reviewOperation(id, decision, { note }),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
rescheduleOperation: endpoint<
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
scheduledDate: string;
|
||||||
|
trainScheduleId?: string;
|
||||||
|
note?: string;
|
||||||
|
},
|
||||||
|
BookingDetail
|
||||||
|
>("bookings", "rescheduleOperation", ({ id, ...payload }) =>
|
||||||
|
bookingsService.rescheduleOperation(id, payload),
|
||||||
|
),
|
||||||
|
|
||||||
proceedToOperation: endpoint<
|
proceedToOperation: endpoint<
|
||||||
{ id: string; scheduledDate: string },
|
{ id: string; scheduledDate: string },
|
||||||
BookingDetail
|
BookingDetail
|
||||||
|
|||||||
@@ -358,6 +358,16 @@ export const bookingsService = {
|
|||||||
proceedToOperation: (id: string, scheduledDate: string) =>
|
proceedToOperation: (id: string, scheduledDate: string) =>
|
||||||
postBooking<BookingDetail>(B.CLEARANCE_PROCEED(id), { scheduledDate }),
|
postBooking<BookingDetail>(B.CLEARANCE_PROCEED(id), { scheduledDate }),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operations changes the shipment day (and, for export rail, the train) of a
|
||||||
|
* pending operation request on the customer's behalf — the booking stays at
|
||||||
|
* OPERATION_REQUEST_PENDING for the normal accept.
|
||||||
|
*/
|
||||||
|
rescheduleOperation: (
|
||||||
|
id: string,
|
||||||
|
payload: { scheduledDate: string; trainScheduleId?: string; note?: string },
|
||||||
|
) => postBooking<BookingDetail>(B.OPERATION_RESCHEDULE(id), payload),
|
||||||
|
|
||||||
generateContract: (id: string) =>
|
generateContract: (id: string) =>
|
||||||
postBooking<BookingDetail>(B.CONTRACT_GENERATE(id)),
|
postBooking<BookingDetail>(B.CONTRACT_GENERATE(id)),
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import type {
|
|||||||
MarshallingStop,
|
MarshallingStop,
|
||||||
ScheduleMergePreview,
|
ScheduleMergePreview,
|
||||||
TrainScheduleDetail,
|
TrainScheduleDetail,
|
||||||
|
ReduceScheduleCloseOffsetPayload,
|
||||||
UpdateScheduleWindowRulePayload,
|
UpdateScheduleWindowRulePayload,
|
||||||
TrainScheduleFilters,
|
TrainScheduleFilters,
|
||||||
TrainScheduleListFilters,
|
TrainScheduleListFilters,
|
||||||
@@ -307,6 +308,17 @@ export const trainSchedulingService = {
|
|||||||
return unwrap(response.data);
|
return unwrap(response.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
reduceScheduleCloseOffset: async (
|
||||||
|
scheduleId: string,
|
||||||
|
payload: ReduceScheduleCloseOffsetPayload,
|
||||||
|
): Promise<TrainScheduleDetail> => {
|
||||||
|
const response = await client.patch<TrainScheduleDetail>(
|
||||||
|
URL_CONSTANTS.TRAIN_SCHEDULING.CLOSE_OFFSET(scheduleId),
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
updateScheduleDate: async (
|
updateScheduleDate: async (
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
scheduleDate: string,
|
scheduleDate: string,
|
||||||
|
|||||||
@@ -250,6 +250,9 @@ export interface TrainScheduleListItem {
|
|||||||
totalLengthMeters: number;
|
totalLengthMeters: number;
|
||||||
bookingsCount: number;
|
bookingsCount: number;
|
||||||
status: TrainScheduleStatus | string;
|
status: TrainScheduleStatus | string;
|
||||||
|
windowPhase?: BookingWindowPhase | string | null;
|
||||||
|
/** Set when the API computed it: is this row shut only by its close offset? */
|
||||||
|
closeOffsetReopen?: CloseOffsetReopenInfo | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TrainScheduleSortField =
|
export type TrainScheduleSortField =
|
||||||
@@ -593,11 +596,35 @@ export interface ScheduleWindowRule {
|
|||||||
windowDurationHours: number | null;
|
windowDurationHours: number | null;
|
||||||
importWindowLeadDays: number | null;
|
importWindowLeadDays: number | null;
|
||||||
exportBookingLeadHours: number | null;
|
exportBookingLeadHours: number | null;
|
||||||
|
/** Effective minutes-before-departure booking closes (import); null = at departure. */
|
||||||
|
importCloseOffsetMinutes?: number | null;
|
||||||
|
/** Effective minutes-before-departure booking closes (export); null = at departure. */
|
||||||
|
exportCloseOffsetMinutes?: number | null;
|
||||||
/** Live global values (not snapshotted per schedule) — editor prefill baseline. */
|
/** Live global values (not snapshotted per schedule) — editor prefill baseline. */
|
||||||
docReviewMinutes: number;
|
docReviewMinutes: number;
|
||||||
paymentWindowMinutes: number;
|
paymentWindowMinutes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a schedule's booking shut ONLY because of its close offset — the one
|
||||||
|
* closed state staff can undo from the board by shortening that offset.
|
||||||
|
*/
|
||||||
|
export interface CloseOffsetReopenInfo {
|
||||||
|
eligible: boolean;
|
||||||
|
/** Why it is not eligible; null when it is. */
|
||||||
|
reason: string | null;
|
||||||
|
/** Minutes before departure booking currently closes; null without an offset. */
|
||||||
|
offsetMinutes: number | null;
|
||||||
|
/** ISO instant booking closed at (departure − offset); null without an offset. */
|
||||||
|
cutoffAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shorten a schedule's close offset so its booking window reopens. */
|
||||||
|
export interface ReduceScheduleCloseOffsetPayload {
|
||||||
|
/** New minutes-before-departure booking closes; 0 = close at departure. */
|
||||||
|
closeOffsetMinutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
/** Editable window-rule override for one schedule; every field optional. */
|
/** Editable window-rule override for one schedule; every field optional. */
|
||||||
export interface UpdateScheduleWindowRulePayload {
|
export interface UpdateScheduleWindowRulePayload {
|
||||||
windowOpenHour?: number;
|
windowOpenHour?: number;
|
||||||
@@ -672,6 +699,8 @@ export interface TrainScheduleDetail {
|
|||||||
paymentPhaseEndsAt?: string | null;
|
paymentPhaseEndsAt?: string | null;
|
||||||
/** Booking-window rule snapshot — prefills the per-schedule settings editor. */
|
/** Booking-window rule snapshot — prefills the per-schedule settings editor. */
|
||||||
windowRule?: ScheduleWindowRule | null;
|
windowRule?: ScheduleWindowRule | null;
|
||||||
|
/** Is booking shut only by the close offset? Drives "shorten close offset". */
|
||||||
|
closeOffsetReopen?: CloseOffsetReopenInfo | null;
|
||||||
route?: {
|
route?: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user