diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.spec.ts index 9a5960295..c9bdc4845 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.spec.ts @@ -36,9 +36,13 @@ describe('BookingLifecycleNotifierService — operation changes requested', () = ); }); - it('sends a GL-created booking back to the GL who created it, not the customer', async () => { + it('sends a GL-created customs booking back to the GL who created it, not the customer', async () => { service.operationChangesRequested( - booking({ createdByRole: 'GL_ET', createdByUserId: 'gl-user-1' }), + booking({ + customsClearingEnabled: true, + createdByRole: 'GL_ET', + createdByUserId: 'gl-user-1', + }), 'Cargo weight does not match the declaration', ); await flush(); @@ -54,7 +58,7 @@ describe('BookingLifecycleNotifierService — operation changes requested', () = expect(notifications.directSend).not.toHaveBeenCalled(); }); - it('still tells the customer when the booking is their own', async () => { + it('still tells the customer when the booking is a non-customs self-service booking', async () => { service.operationChangesRequested(booking(), 'Please attach the packing list'); await flush(); @@ -65,14 +69,38 @@ describe('BookingLifecycleNotifierService — operation changes requested', () = expect(notifications.directSend).toHaveBeenCalled(); }); - it('falls back to the customer when the GL creator is unknown (legacy rows)', async () => { + it('routes a customer-opened customs booking to the clearance desk, not the customer', async () => { + // Path B lets the customer open the ONE_TIME shipment instance themselves + // (contract-booking.service assertGate's customerMayInitiate) — createdByRole + // stays 'CUSTOMER', but GL still owns completing/resubmitting it. service.operationChangesRequested( - booking({ createdByRole: 'GL_ET', createdByUserId: null }), + booking({ customsClearingEnabled: true, createdByRole: 'CUSTOMER' }), 'Fix the declaration', ); await flush(); - expect(inbox.notify.mock.calls[0][0].recipients).toEqual({ companyId: 'co-1' }); + const sent = inbox.notify.mock.calls[0][0]; + expect(sent.recipients).toEqual({ + permissionKeys: [FREIGHT_PERMS.bookings.clearanceGetNotification], + }); + expect(sent.audience).toBe('BACKOFFICE'); + expect(notifications.directSend).not.toHaveBeenCalled(); + }); + + it('falls back to the clearance desk when the GL creator is unknown (legacy rows)', async () => { + service.operationChangesRequested( + booking({ + customsClearingEnabled: true, + createdByRole: 'GL_ET', + createdByUserId: null, + }), + 'Fix the declaration', + ); + await flush(); + + expect(inbox.notify.mock.calls[0][0].recipients).toEqual({ + permissionKeys: [FREIGHT_PERMS.bookings.clearanceGetNotification], + }); }); }); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts index 01f0fd053..030f1115f 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts @@ -247,20 +247,27 @@ export class BookingLifecycleNotifierService { /** * Operations returned the operation request for changes. * - * A customs (Path B) booking was created BY GL Ethiopia on the customer's - * behalf — the customer cannot edit or resubmit it, so telling them to "update - * from the portal" is a dead end. Those go to the GL who created it, linking - * the contract clearance page they work from. Everything else (customer-made - * bookings) keeps the portal message. + * A customs (Path B) booking is completed by GL Ethiopia on the customer's + * behalf regardless of who opened the shipment instance — the customer-opened + * ONE_TIME case (see contract-booking.service assertGate) still stamps + * createdByRole 'CUSTOMER', so gate on customsClearingEnabled, not on who + * created it. The customer cannot edit or resubmit a customs booking, so + * telling them to "update from the portal" is a dead end. Those go to the GL + * who created it when known, else the clearance desk, linking the contract + * clearance page they work from. Everything else (customer-made bookings) + * keeps the portal message. */ operationChangesRequested(b: Booking, note: string): void { - if (b.createdByRole === 'GL_ET' && b.createdByUserId) { + if (b.customsClearingEnabled) { const msg = `Operations returned booking ${b.reference} for changes: ${note}. ` + `Address it on the contract clearance page and resubmit to Operations.`; this.logger.log(`OPERATION CHANGES REQUESTED (to GL) — ${this.ref(b)}`); void this.inbox.notify({ - recipients: { userIds: [b.createdByUserId] }, + recipients: + b.createdByRole === 'GL_ET' && b.createdByUserId + ? { userIds: [b.createdByUserId] } + : CLEARANCE_DESK, audience: NotificationAudience.BACKOFFICE, type: NotificationType.BOOKING_STATUS, title: `Booking ${b.reference} needs changes`, diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx index 48ce552a8..39a0e9d77 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx @@ -27,6 +27,7 @@ import { import type { Freight } from "@edr/types"; import { ClearanceOpsTabs } from "@/components/contracts/ClearanceOpsTabs"; +import { BookingChangesRequestedAlert } from "@/components/contracts/BookingChangesRequestedAlert"; import { PageContainer, PageHeader, KpiStrip } from "@/components/page"; import type { KpiItem } from "@/components/page"; import { @@ -134,6 +135,20 @@ export default function DocumentClearanceDetailPage() { hasPermission(user, FREIGHT_PERMS.contracts.createBooking) && !isDjiboutiGl(user); + // Operations sent this GL-created booking back for changes. Customs bookings + // are never self-booked (see BookingChangesRequestedAlert) — the note and the + // resubmit belong here, on the page GL works from, not the customer's portal. + const bookingNeedsChanges = booking?.status === "OPERATION_CHANGES_REQUESTED"; + const isGlBookingOwner = + hasPermission(user, FREIGHT_PERMS.contracts.createBooking) && + !isDjiboutiGl(user); + const changeRequestNote = + [...(booking?.reviewNotes ?? [])] + .filter((n) => n.type === "CHANGES_REQUESTED") + .sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + )[0]?.note ?? null; + const docsPhaseComplete = clearance?.milestones?.some( (m) => m.milestoneCode === "DOCUMENTS_APPROVED" && m.status === "COMPLETED", @@ -279,6 +294,22 @@ export default function DocumentClearanceDetailPage() { } /> + {bookingNeedsChanges ? ( + void refetch()} + /> + ) : null} + {requestedLines ? (