mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 22:58:17 +00:00
changes on transit agent
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user