fix issues

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

View File

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

View File

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

View File

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

View File

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

View File

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