Merge pull request #1336 from Tria-plc/freight/nati-2

lifecycle
This commit is contained in:
Nathnael Wondisha
2026-08-18 16:14:38 +03:00
committed by GitHub
3 changed files with 79 additions and 13 deletions

View File

@@ -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],
});
});
});

View File

@@ -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`,

View File

@@ -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 ? (
<BookingChangesRequestedAlert
bookingId={id!}
reference={booking?.reference}
note={changeRequestNote}
scheduledDate={booking?.scheduledDate}
canResubmit={isGlBookingOwner}
editHref={
booking?.contractId
? `/dashboard/contracts/${booking.contractId}/bookings/${id}/complete?copyFrom=${id}`
: undefined
}
onResubmitted={() => void refetch()}
/>
) : null}
<KpiStrip items={kpis} />
{requestedLines ? (