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);
}

View File

@@ -29,6 +29,7 @@ import { ClearanceWorkflowService } from './clearance-workflow.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { GlOperationsService } from './gl-operations.service';
import { GlExchangeService } from './gl-exchange.service';
import { TransitAgentCountry } from '../transit-agents/entities/transit-agent.entity';
import { TransitAgentsService } from '../transit-agents/transit-agents.service';
import { TransitAssignmentsService } from '../transit-assignments/transit-assignments.service';
import { YardScopeService } from '../rule-engine/services/yard-scope.service';
@@ -581,19 +582,33 @@ export class BookingClearanceService {
* rejected unless the agent is active and inside its validity window.
* Answering unblocks the declaration for Ethiopia. A later call overwrites
* the name (reassignment) and re-notifies.
*
* `byAssignedAgent`: the clearing agent the customer assigned (a freight
* forwarder) names the Djibouti officer itself. That is a without-customs
* booking with no phased workflow and no GL request to answer, so neither
* gate applies — only that the officer is an active Djiboutian entry. The
* caller has already established the actor is assigned to this booking.
*/
async assignTransitAssignee(
bookingId: string,
transitAgentId: string,
userId?: string,
opts: { byAssignedAgent?: boolean } = {},
): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (!booking.transitAssigneeRequestedAt) {
const booking = opts.byAssignedAgent
? await this.bookingsService.findById(bookingId)
: await this.loadBooking(bookingId);
if (!opts.byAssignedAgent && !booking.transitAssigneeRequestedAt) {
throw new BadRequestException(
'GL Ethiopia has not requested a transit assignee for this shipment yet.',
);
}
const agent = await this.transitAgentsService.getAssignable(transitAgentId);
if (opts.byAssignedAgent && agent.country !== TransitAgentCountry.Djibouti) {
throw new BadRequestException(
`${agent.name} is not a Djibouti transit agent — pick one from the Djibouti roster.`,
);
}
const previous = booking.transitAssigneeName ?? null;
await this.bookingsRepository.update(bookingId, {
@@ -619,7 +634,14 @@ export class BookingClearanceService {
metadata: { transitAgentId, agentName: agent.name, previous },
});
this.notifier.transitAssigneeAssigned(booking, agent, previous);
this.notifier.transitAssigneeAssigned(
booking,
agent,
previous,
opts.byAssignedAgent
? booking.customsClearingAgent ?? 'The clearing agent'
: undefined,
);
return this.bookingsService.findById(bookingId);
}

View File

@@ -1,4 +1,4 @@
import { BadRequestException } from "@nestjs/common";
import { BadRequestException, ConflictException } from "@nestjs/common";
import { TrainSchedulingService } from "./services/train-scheduling.service";
@@ -266,3 +266,121 @@ describe('dispatchSchedule — the whole consist travels, not just loaded slots'
expect(boundAtDispatch(['w1', null, 'w2'], [])).toEqual(['w1', 'w2']);
});
});
/**
* Shedding is a WRITE that commits on its own, so it must be the last thing
* before the departure write — not the first thing dispatch does. When it ran
* first, a dispatch the wagon-yard gate then rejected (409) had already pulled
* the unticked bookings off the train: MANUAL_ONLY, allocations gone, customer
* told to rebook — for a train that never left (BK-2026-000341).
*/
describe('dispatchSchedule — sheds left-behind boarders only once every gate has passed', () => {
type Slot = {
physicalWagonId: string | null;
allocations: Array<{ bookingId: string }>;
};
type OutElsewhereQuery = { where: { id: { value: string[] } } };
/** The real dispatchSchedule over stubs for everything around the gates. */
const buildService = (opts: { yardGate?: () => Promise<void>; slots?: Slot[] }) => {
const events: string[] = [];
let outElsewhere: OutElsewhereQuery | null = null;
const schedule = {
id: 'sched-1',
status: 'SCHEDULED',
originStationId: 'yard-a',
stationWorkLogs: {
'yard-a': {
loading: { startedAt: '2026-09-08T05:00:00Z', endedAt: '2026-09-08T06:00:00Z' },
},
},
trainSet: { wagons: opts.slots ?? [] },
scheduleBookings: [],
};
const svc = Object.create(TrainSchedulingService.prototype) as TrainSchedulingService;
const stubs = svc as unknown as Record<string, unknown>;
stubs.trainSchedulesRepository = {
findByIdWithFullGraph: async () => {
events.push('load-graph');
return schedule;
},
};
stubs.unloadedOriginBoarderIds = async () => ['b1', 'b2'];
stubs.unassignBooking = async (_scheduleId: string, bookingId: string) => {
events.push(`unassign:${bookingId}`);
};
stubs.assertImportDjiboutiMayDepart = async () => undefined;
stubs.locomotivesOfTrainSet = () => [];
stubs.assertLocomotivesNotDispatchedElsewhere = async () => undefined;
stubs.assertPlannedYardsAligned = async () => {
events.push('gate:yards');
await opts.yardGate?.();
};
stubs.assertNoPartiallyLoadedBookings = async () => undefined;
stubs.dataSource = {
getRepository: () => ({
find: async (query: OutElsewhereQuery) => {
outElsewhere = query;
return [];
},
}),
transaction: async () => {
events.push('depart');
},
};
stubs.isImportDjiboutiSchedule = () => false;
stubs.emitWindowState = async () => undefined;
stubs.notifyScheduleBookings = async () => undefined;
stubs.getTrainScheduleById = async () => ({ trainNumber: '9001' });
return { svc, events, outElsewhere: () => outElsewhere };
};
it('leaves every unticked boarder on the train when a gate rejects the dispatch', async () => {
const { svc, events } = buildService({
yardGate: async () => {
throw new ConflictException(
'Cannot dispatch: 39 wagon(s) are not at the yard this schedule planned them for',
);
},
});
await expect(
svc.dispatchSchedule('sched-1', { loadedBookingIds: [] }, 'user-1'),
).rejects.toBeInstanceOf(ConflictException);
expect(events).toEqual(['load-graph', 'gate:yards']);
});
it('sheds after the last gate and before the departure write', async () => {
const { svc, events } = buildService({});
await svc.dispatchSchedule('sched-1', { loadedBookingIds: [] }, 'user-1');
expect(events).toEqual([
'load-graph',
'gate:yards',
'unassign:b1',
'unassign:b2',
'load-graph',
'depart',
]);
});
it('sheds nobody when the client omits the list', async () => {
const { svc, events } = buildService({});
await svc.dispatchSchedule('sched-1', {}, 'user-1');
expect(events).toEqual(['load-graph', 'gate:yards', 'depart']);
});
it('judges the out-on-another-train gate on the wagons that will actually depart', async () => {
const slots: Slot[] = [
{ physicalWagonId: 'w1', allocations: [{ bookingId: 'b1' }] }, // shed-only → released
{ physicalWagonId: 'w2', allocations: [{ bookingId: 'b3' }] },
{ physicalWagonId: 'w3', allocations: [{ bookingId: 'b1' }, { bookingId: 'b3' }] },
];
const ticked = buildService({ slots });
await ticked.svc.dispatchSchedule('sched-1', { loadedBookingIds: [] }, 'user-1');
expect(ticked.outElsewhere()?.where.id.value).toEqual(['w2', 'w3']);
// No list → nothing is shed, so every pinned slot is judged.
const legacy = buildService({ slots });
await legacy.svc.dispatchSchedule('sched-1', {}, 'user-1');
expect(legacy.outElsewhere()?.where.id.value).toEqual(['w1', 'w2', 'w3']);
});
});

View File

@@ -3169,22 +3169,20 @@ export class TrainSchedulingService {
// deallocated from its wagon and returned to the booking pool — so the
// origin auto-load below only ever touches confirmed cargo. Government
// bookings cannot be unassigned and keep the historic auto-load.
//
// DECIDED here, SHED only after every gate below has passed. Unassign
// commits in its own transaction, so shedding first meant a dispatch the
// next gate rejected (loading window, wagons off their planned yard, …)
// had already stripped the bookings off the train — MANUAL_ONLY,
// allocations gone, customer told to rebook — while the train never left.
// Staff clear the blocker and dispatch again; the dialog re-sends the list.
let leftBehind: string[] = [];
if (dto.loadedBookingIds) {
const keep = new Set(dto.loadedBookingIds);
const candidates = await this.unloadedOriginBoarderIds(scheduleId, schedule.originStationId);
const leftBehind = candidates.filter((id) => !keep.has(id));
for (const bookingId of leftBehind) {
await this.unassignBooking(scheduleId, bookingId, userId);
}
if (leftBehind.length) {
// Unassign deleted allocations and slots — reload the graph dispatch works on.
const reloaded = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!reloaded) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
schedule = reloaded;
}
leftBehind = candidates.filter((id) => !keep.has(id));
}
const shed = new Set(leftBehind);
// Dispatch requires the origin's loading window to be COMPLETE: started
// and ended. Not started or still open both block — a train departs only
// after loading was formally opened and closed.
@@ -3220,8 +3218,14 @@ export class TrainSchedulingService {
const setLocomotiveIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
await this.assertLocomotivesNotDispatchedElsewhere(setLocomotiveIds, scheduleId);
// Same rule for wagons: many schedules may pin the same wagon, but it can
// only be OUT on one dispatched train at a time.
// only be OUT on one dispatched train at a time. Judged on the train that
// will actually depart: shedding releases every slot left with no
// allocation, so those slots' wagons are not this train's concern.
const pinnedPhysicalIds = (schedule.trainSet?.wagons ?? [])
.filter(
(slot) =>
shed.size === 0 || (slot.allocations ?? []).some((a) => !shed.has(a.bookingId)),
)
.map((slot) => slot.physicalWagonId)
.filter((id): id is string => Boolean(id));
if (pinnedPhysicalIds.length) {
@@ -3245,6 +3249,19 @@ export class TrainSchedulingService {
action: 'dispatch',
});
// Every gate passed — the train is leaving. Shed the unticked boarders now.
for (const bookingId of leftBehind) {
await this.unassignBooking(scheduleId, bookingId, userId);
}
if (leftBehind.length) {
// Unassign deleted allocations and slots — reload the graph dispatch works on.
const reloaded = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!reloaded) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
schedule = reloaded;
}
await this.dataSource.transaction(async (manager) => {
const trainNumber = await this.assignTrainNumber(manager, schedule);
if (setLocomotiveIds.length) {

View File

@@ -74,6 +74,20 @@ export class TransitAgentsController {
return this.transitAgentsService.findForwarderOptions();
}
/**
* The Djibouti roster, id + name only, for the clearing agent on a booking
* to name the transit officer. Also before `:id` for the same reason.
*/
@Get("djibouti-options")
@PortalCustomer()
@ApiOperation({
summary:
"List active Djibouti transit agents (id + name) an assigned clearing agent can hand the transit leg to",
})
findDjiboutiOptions() {
return this.transitAgentsService.findDjiboutiOptions();
}
@Get(":id")
@RuleEngineView("transit-agents")
@ApiOperation({ summary: "Get a transit agent by ID" })

View File

@@ -41,6 +41,19 @@ export class TransitAgentsRepository extends BaseRepository<TransitAgent> {
});
}
/**
* The Djibouti roster, `{ id, name }` only, for the clearing agent on a
* booking to hand the transit leg to — served to portal customers, so no
* contact details. Suspended officers are left out.
*/
findDjiboutiOptions(): Promise<ForwarderTransitAgentOption[]> {
return this.repository.find({
select: { id: true, name: true },
where: { isActive: true, country: TransitAgentCountry.Djibouti },
order: { name: "ASC" },
});
}
/** The transit agent signed in as `userId`, or null for any other account. */
findByUserId(userId: string): Promise<TransitAgent | null> {
return this.repository.findOne({ where: { userId } });

View File

@@ -114,6 +114,11 @@ export class TransitAgentsService {
return this.transitAgentsRepository.findForwarderOptions();
}
/** The Djibouti roster an assigned clearing agent picks the transit officer from. */
findDjiboutiOptions(): Promise<ForwarderTransitAgentOption[]> {
return this.transitAgentsRepository.findDjiboutiOptions();
}
async findById(id: string): Promise<TransitAgentView> {
const agent = await this.transitAgentsRepository.findById(id);
if (!agent) {

View File

@@ -770,10 +770,19 @@ export class TransitAssignmentsService {
const existing =
await this.assignmentsRepository.findByBooking(bookingId);
// A shipment carries one agent PER COUNTRY: the Ethiopian clearing agent
// (the forwarder the customer picked) and the Djibouti transit officer
// work side by side. Only a predecessor in the same role is retired; the
// other country's row is left alone, or naming the officer would knock
// the forwarder off the booking it is clearing.
const incoming = await this.transitAgentsRepository.findById(transitAgentId);
for (const row of existing) {
if (
row.transitAgentId !== transitAgentId &&
row.status !== TransitAssignmentStatus.Finished
row.status !== TransitAssignmentStatus.Finished &&
(!incoming ||
!row.transitAgent ||
row.transitAgent.country === incoming.country)
) {
await this.assignmentsRepository.softDelete(row.id);
}