changes on transit agent

This commit is contained in:
marshal
2026-09-08 06:58:14 +00:00
parent 92697386e4
commit 850e0753d2
14 changed files with 1528 additions and 115 deletions

View File

@@ -417,12 +417,14 @@ export class BookingLifecycleNotifierService {
b: Booking,
agent: { id: string; name: string },
previous: string | null,
/** Who named the officer — GL Djibouti unless the clearing agent did. */
by = 'GL Djibouti',
): void {
const assignee = agent.name;
const msg = previous
? `GL Djibouti changed the transit assignee for shipment ${b.reference} from ` +
? `${by} changed the transit assignee for shipment ${b.reference} from ` +
`"${previous}" to "${assignee}".`
: `GL Djibouti assigned ${assignee} to handle shipment ${b.reference} in transit. ` +
: `${by} assigned ${assignee} to handle shipment ${b.reference} in transit. ` +
`The customs declaration can now be filed.`;
this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${this.ref(b)}`);
this.inAppStaff(b, `Transit assignee set — ${b.reference}`, msg, {
@@ -441,7 +443,7 @@ export class BookingLifecycleNotifierService {
{
title: 'New shipment assigned to you',
body:
`GL Djibouti assigned shipment ${b.reference} to ${assignee} for transit. ` +
`${by} assigned shipment ${b.reference} to ${assignee} for transit. ` +
`Open it in the portal to see what is needed.`,
officerLink: `/transit-agent/bookings/${b.id}`,
forwarderLink: '/forwarder/assigned-bookings',

View File

@@ -878,6 +878,33 @@ export class BookingsController {
}
}
/**
* Gate a GL review action that the assigned clearing agent may also take.
*
* On a without-customs booking the freight forwarder the customer assigned
* clears customs in GL's place: it reviews the customer's documents, asks
* for missing ones and finalizes. Staff pass on their permission; a portal
* caller must be assigned to THIS booking and the booking must be one GL
* does not clear — a customs booking stays with Global Logistics.
*/
private async assertStaffOrClearingAgent(
bookingId: string,
user: TCurrentUser,
staffPermission: string,
): Promise<void> {
if (hasFreightPermission(user, staffPermission)) return;
const booking = await this.bookingsService.findById(bookingId);
if (
booking.customsClearingEnabled ||
!(await this.bookingsService.isTransitAgentForBooking(
user?.id,
bookingId,
))
) {
throw new NotFoundException(`Booking ${bookingId} not found`);
}
}
private async assertWagonCancellationActor(
cancellationId: string,
user: TCurrentUser,
@@ -1372,16 +1399,24 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
// Also the clearing agent assigned to a without-customs booking — see
// assertStaffOrClearingAgent.
@Post(":id/clearance/review")
@BookingStaff(FREIGHT_PERMS.bookings.reviewDocuments)
@MixedAudience(FREIGHT_PERMS.bookings.reviewDocuments)
@ApiOperation({
summary: "GL reviews a clearance document (Approve | Query)",
summary:
"GL — or the assigned clearing agent — reviews a clearance document (Approve | Query)",
})
async reviewClearanceDocument(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: ReviewDocumentDto,
@CurrentUser() user: AuthUserPayload,
@CurrentUser() user: TCurrentUser,
) {
await this.assertStaffOrClearingAgent(
id,
user,
FREIGHT_PERMS.bookings.reviewDocuments,
);
const booking = await this.transitionService.reviewDocument(
id,
dto.fileKey,
@@ -1393,16 +1428,21 @@ export class BookingsController {
}
@Post(":id/clearance/doc-requests")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@MixedAudience(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({
summary:
"GL asks the customer for additional clearance document(s) shown on the portal with author and time",
"GL — or the assigned clearing agent — asks the customer for additional clearance document(s); shown on the portal with author and time",
})
async requestAdditionalDocuments(
@Param("id", ParseUUIDPipe) id: string,
@Body("note") note: string,
@CurrentUser() user: AuthUserPayload,
@CurrentUser() user: TCurrentUser,
) {
await this.assertStaffOrClearingAgent(
id,
user,
FREIGHT_PERMS.contracts.clearanceEtActions,
);
await this.transitionService.requestAdditionalDocuments(
id,
note,
@@ -1686,15 +1726,20 @@ export class BookingsController {
}
@Post(":id/clearance/finalize")
@BookingStaff(FREIGHT_PERMS.bookings.finalizeClearance)
@MixedAudience(FREIGHT_PERMS.bookings.finalizeClearance)
@ApiOperation({
summary:
"GL finalizes clearance (requires 100% approved) → CLEARANCE_READY",
"GL — or the assigned clearing agent — finalizes clearance (requires 100% approved) → CLEARANCE_READY",
})
async finalizeClearance(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
@CurrentUser() user: TCurrentUser,
) {
await this.assertStaffOrClearingAgent(
id,
user,
FREIGHT_PERMS.bookings.finalizeClearance,
);
const booking = await this.transitionService.finalizeClearance(
id,
resolveAuthUserId(user),
@@ -1721,21 +1766,36 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
// Also the clearing agent (freight forwarder) the customer assigned: on a
// without-customs booking there is no GL Djibouti desk in the loop, so the
// forwarder names the Djibouti officer itself. Any other portal caller is
// rejected below, hidden behind a NotFound like the ownership checks.
@Post(":id/clearance/transit-assignee/assign")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions)
@ApiOperation({
summary:
"GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns",
"GL Djibouti — or the assigned clearing agent — picks the Djibouti transit officer from the roster; calling again reassigns",
})
async assignBookingTransitAssignee(
@Param("id", ParseUUIDPipe) id: string,
@Body("transitAgentId", ParseUUIDPipe) transitAgentId: string,
@CurrentUser() user: AuthUserPayload,
@CurrentUser() user: TCurrentUser,
) {
const isStaff = hasFreightPermission(
user,
FREIGHT_PERMS.contracts.clearanceDjActions,
);
if (
!isStaff &&
!(await this.bookingsService.isTransitAgentForBooking(user?.id, id))
) {
throw new NotFoundException(`Booking ${id} not found`);
}
const booking = await this.bookingClearanceService.assignTransitAssignee(
id,
transitAgentId,
resolveAuthUserId(user),
{ byAssignedAgent: !isStaff },
);
return this.transitionService.enrichBookingResponse(booking);
}