mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 01:55:41 +00:00
fix issues
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user