feat: enhance customs clearance process for forwarders

- Added  logic in  to determine if a company can clear its own customs.
- Updated  component to support upload-only mode for forwarders.
- Removed customs clearing agent fields from  and related schema.
- Introduced  component to handle customs clearing agent selection.
- Created  for viewing details of assigned bookings.
- Implemented  and modal for document uploads by forwarders.
- Updated API services to accommodate new customs clearing logic.
This commit is contained in:
marshal
2026-09-08 03:54:48 +00:00
parent 7c422ff600
commit 8314b3bd45
19 changed files with 977 additions and 286 deletions

View File

@@ -408,10 +408,14 @@ export class BookingsController {
) {
const booking = await this.bookingsService.findById(id);
// Staff see any booking; Global Logistics (clearance:view) may inspect any
// booking for the clearance gate; customers only their own company's.
// booking for the clearance gate; customers only their own company's
// plus the transit agent / forwarder the booking was assigned to, which
// uploads the clearance documents for the customer and needs the same
// booking view to do it.
if (
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
!hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView)
!hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView) &&
!(await this.bookingsService.isTransitAgentForBooking(user?.id, id))
) {
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,

View File

@@ -2253,6 +2253,16 @@ ${footer}
return rows.length > 0;
}
/**
* Two kinds of account act as the assigned agent. A Djibouti transit officer
* signs in AS the agent (`transit_agents.user_id`). A freight forwarder is a
* customer company that registered itself as an Ethiopian transit agent
* (`companies.transit_agent_id`); any of its users acts for it — but only
* once its roster role (transit agent or forwarder) is approved, matching
* the write gate in TransitAssignmentsService.requireAgentForUser. The
* forwarder clears customs for the customer, so it is the one uploading the
* booking's import/export documents.
*/
async isTransitAgentForBooking(
userId: string | undefined,
bookingId: string,
@@ -2262,10 +2272,25 @@ ${footer}
`SELECT 1 AS one
FROM freight.transit_assignments ta
JOIN freight.transit_agents a ON a.id = ta.transit_agent_id
WHERE a.user_id = $1
AND ta.booking_id = $2
WHERE ta.booking_id = $2
AND ta.deleted_at IS NULL
AND a.deleted_at IS NULL
AND (
a.user_id = $1
OR EXISTS (
SELECT 1
FROM freight.external_profiles ep
JOIN freight.companies c ON c.id = ep.company_id
JOIN freight.company_profiles cp ON cp.company_id = c.id
WHERE ep.user_id = $1
AND c.transit_agent_id = a.id
AND cp.type IN ('transit_agent', 'freight_forwarder')
AND cp.status = 'active'
AND ep.deleted_at IS NULL
AND c.deleted_at IS NULL
AND cp.deleted_at IS NULL
)
)
LIMIT 1`,
[userId, bookingId],
);

View File

@@ -458,7 +458,14 @@ export class ContractBookingService {
*/
async initiateUnderContract(
contractId: string,
dto: Pick<CreateBookingUnderContractDto, 'contractRouteId'>,
dto: Pick<
CreateBookingUnderContractDto,
| 'contractRouteId'
| 'transitAgentId'
| 'customsClearingAgent'
| 'customsClearingAgentEmail'
| 'customsClearingAgentPhone'
>,
user?: { id?: string } | null,
actorPermissions?: unknown,
): Promise<CreateBookingUnderContractResult> {
@@ -504,6 +511,24 @@ export class ContractBookingService {
const route = await this.resolveRoute(contract, dto.contractRouteId);
// Without-customs import/export: the customer names who clears customs
// here, at initiation, because that party — not the customer — uploads the
// clearance documents on the bare instance that follows. A company that
// is itself a transit agent / freight forwarder clears its own customs, so
// it is recorded as the agent unless it named somebody else. GL may open
// one without any (the backoffice has no such field); a customer may not.
// Intercity was rejected above and customs contracts are cleared by GL.
const collectsClearingAgent = !contract.customsClearingEnabled;
const clearingAgent = collectsClearingAgent
? (await this.resolveClearingAgent(dto)) ??
(await this.ownClearingAgent(contract.companyId ?? null))
: null;
if (collectsClearingAgent && !clearingAgent && !isGlActor) {
throw new BadRequestException(
"Pick the registered transit agent handling customs for this booking, or enter your clearing agent's name, email and phone.",
);
}
// Bare instance: no cargo, no date, no price. Draws no contract capacity
// until the customer completes it after clearance.
const booking = await insertWithGeneratedReference(
@@ -527,7 +552,14 @@ export class ContractBookingService {
paymentCurrency: this.resolveShipmentCurrency(contract, null),
contractType: 'NEW',
customsClearingEnabled: contract.customsClearingEnabled,
customsClearingAgent: contract.customsClearingAgent ?? null,
customsClearingAgent:
clearingAgent?.fields.customsClearingAgent ??
contract.customsClearingAgent ??
null,
customsClearingAgentEmail:
clearingAgent?.fields.customsClearingAgentEmail ?? null,
customsClearingAgentPhone:
clearingAgent?.fields.customsClearingAgentPhone ?? null,
equipmentReturn: contract.equipmentReturn ?? 'WITHOUT_RETURN',
originYardId: route?.originYardId ?? null,
destinationYardId: route?.destinationYardId ?? null,
@@ -557,10 +589,149 @@ export class ContractBookingService {
}
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
// The forwarder's work list and its notice come AFTER the instance is
// committed, so it is never told about a booking that was refused above.
// From here on the forwarder can open the booking and upload its documents.
if (clearingAgent?.assigned) {
await this.transitAssignmentsService.ensureAssignment(
booking.id,
clearingAgent.assigned.id,
user?.id,
);
void this.bookingNotifier.transitAgentAssigned(
result ?? booking,
clearingAgent.assigned,
);
}
this.bookingNotifier.createdToStaff(result ?? booking);
return { booking: result ?? booking, warnings: [] };
}
/**
* The booking company as its own clearing agent — when it holds the transit
* agent or freight forwarder role, it clears its own customs, and its own
* name and contact go on the booking. No assignment: it already owns the
* booking and uploads the documents as the customer. Null for any other
* company, so the caller falls through to requiring a named agent.
*/
private async ownClearingAgent(companyId: string | null): Promise<{
fields: Pick<
Booking,
| 'customsClearingAgent'
| 'customsClearingAgentEmail'
| 'customsClearingAgentPhone'
>;
assigned: null;
} | null> {
if (!companyId) return null;
const [company]: Array<{
name: string;
email: string | null;
phone: string | null;
}> = await this.dataSource.query(
`SELECT c.name, c.email, c.phone
FROM freight.companies c
WHERE c.id = $1
AND c.deleted_at IS NULL
AND EXISTS (
SELECT 1
FROM freight.company_profiles cp
WHERE cp.company_id = c.id
AND cp.type IN ('transit_agent', 'freight_forwarder')
AND cp.deleted_at IS NULL
)
LIMIT 1`,
[companyId],
);
if (!company) return null;
return {
fields: {
customsClearingAgent: company.name,
customsClearingAgentEmail: company.email ?? null,
customsClearingAgentPhone: company.phone ?? null,
},
assigned: null,
};
}
/**
* Who clears customs for a without-customs import/export booking, as the
* customer named it — one of two ways. Either a registered Ethiopian transit
* agent (a freight forwarder on the platform): the booking is assigned to it
* and the forwarder is told, and the forwarder company's own contact goes on
* the booking so the customer sees who to reach (an Ethiopian agent row
* carries none). Or the customer's own clearing agent typed in, for which
* all three of name, email and phone are required.
*
* Returns the booking columns to write plus who to assign, or null when the
* payload names nobody — the caller decides whether that is allowed.
*/
private async resolveClearingAgent(
dto: Pick<
CreateBookingUnderContractDto,
| 'transitAgentId'
| 'customsClearingAgent'
| 'customsClearingAgentEmail'
| 'customsClearingAgentPhone'
>,
): Promise<{
fields: Pick<
Booking,
| 'customsClearingAgent'
| 'customsClearingAgentEmail'
| 'customsClearingAgentPhone'
>;
assigned: { id: string; name: string } | null;
} | null> {
if (dto.transitAgentId) {
const agent = await this.transitAgentsRepository.findById(dto.transitAgentId);
if (
!agent ||
!agent.isActive ||
agent.country !== TransitAgentCountry.Ethiopia
) {
throw new BadRequestException(
'The selected transit agent is not an active Ethiopian transit agent — pick another one or enter your clearing agent details.',
);
}
const [forwarder]: Array<{ email: string | null; phone: string | null }> =
await this.dataSource.query(
`SELECT email, phone FROM freight.companies
WHERE transit_agent_id = $1 AND deleted_at IS NULL
LIMIT 1`,
[agent.id],
);
return {
fields: {
customsClearingAgent: agent.name,
customsClearingAgentEmail: forwarder?.email ?? null,
customsClearingAgentPhone: forwarder?.phone ?? null,
},
assigned: { id: agent.id, name: agent.name },
};
}
const agentName = dto.customsClearingAgent?.trim() || null;
const agentEmail = dto.customsClearingAgentEmail?.trim() || null;
const agentPhone = dto.customsClearingAgentPhone?.trim() || null;
if (!agentName && !agentEmail && !agentPhone) return null;
if (!agentName || !agentEmail || !agentPhone) {
throw new BadRequestException(
'Customs clearing agent name, email and phone are all required — or pick a registered transit agent.',
);
}
return {
fields: {
customsClearingAgent: agentName,
customsClearingAgentEmail: agentEmail,
customsClearingAgentPhone: agentPhone,
},
assigned: null,
};
}
/**
* Initiate a BARE booking instance for a GENERAL + customs shipment request
* (Path B, clearance-first). Called by BookingRequestService.submit AFTER it
@@ -862,65 +1033,23 @@ export class ContractBookingService {
if (!dto.scheduledDate) {
throw new BadRequestException('A binding shipment day is required');
}
// Without-customs import/export: the customer names who clears customs for
// this booking, one of two ways. Either a registered Ethiopian transit
// agent (a freight forwarder on the platform) — the booking is assigned to
// it and the forwarder is told — or their own clearing agent typed in
// (name, email, phone). A resubmit may omit the typed fields and keep what
// the booking already stored. Customs contracts (GL clears) and intercity
// (no border) never collect an agent.
// The clearing agent was named when the booking was initiated (see
// initiateUnderContract). A resubmit may restate it — a registered transit
// agent or typed details — and then it replaces what the booking stored;
// otherwise the stored one stands. Customs contracts (GL clears) and
// intercity (no border) never carry one.
let assignedTransitAgent: { id: string; name: string } | null = null;
if (
!contract.customsClearingEnabled &&
contract.tradeDirection !== 'DOMESTIC'
) {
if (dto.transitAgentId) {
const agent = await this.transitAgentsRepository.findById(dto.transitAgentId);
if (
!agent ||
!agent.isActive ||
agent.country !== TransitAgentCountry.Ethiopia
) {
throw new BadRequestException(
'The selected transit agent is not an active Ethiopian transit agent — pick another one or enter your clearing agent details.',
);
}
// The forwarder company's own contact goes on the booking, so the
// customer sees who to reach; an Ethiopian agent row carries none.
const [forwarder]: Array<{ email: string | null; phone: string | null }> =
await this.dataSource.query(
`SELECT email, phone FROM freight.companies
WHERE transit_agent_id = $1 AND deleted_at IS NULL
LIMIT 1`,
[agent.id],
);
await this.bookingsRepository.update(booking.id, {
customsClearingAgent: agent.name,
customsClearingAgentEmail: forwarder?.email ?? null,
customsClearingAgentPhone: forwarder?.phone ?? null,
} as never);
assignedTransitAgent = { id: agent.id, name: agent.name };
} else {
const agentName =
dto.customsClearingAgent?.trim() || booking.customsClearingAgent || null;
const agentEmail =
dto.customsClearingAgentEmail?.trim() ||
booking.customsClearingAgentEmail ||
null;
const agentPhone =
dto.customsClearingAgentPhone?.trim() ||
booking.customsClearingAgentPhone ||
null;
if (!agentName || !agentEmail || !agentPhone) {
throw new BadRequestException(
'Customs clearing agent name, email and phone are required to complete this booking — or pick a registered transit agent.',
);
}
await this.bookingsRepository.update(booking.id, {
customsClearingAgent: agentName,
customsClearingAgentEmail: agentEmail,
customsClearingAgentPhone: agentPhone,
} as never);
const clearingAgent = await this.resolveClearingAgent(dto);
if (clearingAgent) {
await this.bookingsRepository.update(
booking.id,
clearingAgent.fields as never,
);
assignedTransitAgent = clearingAgent.assigned;
}
}
// No expiry gate here on purpose: this booking was already initiated

View File

@@ -1183,7 +1183,7 @@ export class ContractsController {
@MixedAudience(FREIGHT_PERMS.contracts.createBooking)
@ApiOperation({
summary:
'Initiate a bare booking instance under an import/export contract (ONE_TIME or GENERAL) — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS). ONE_TIME customs instances are opened by the customer (or GL); GENERAL customs comes from a shipment request.',
'Initiate a bare booking instance under an import/export contract (ONE_TIME or GENERAL) — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS). A without-customs instance names its clearing agent here (transitAgentId, or the typed agent fields). ONE_TIME customs instances are opened by the customer (or GL); GENERAL customs comes from a shipment request.',
})
async initiateBooking(
@Param('id', ParseUUIDPipe) id: string,
@@ -1198,7 +1198,16 @@ export class ContractsController {
}
return this.contractBookingService.initiateUnderContract(
id,
{ contractRouteId: dto?.contractRouteId },
{
contractRouteId: dto?.contractRouteId,
// Who clears customs is named here, at initiation — the forwarder
// uploads the documents on the bare instance, so it must be on the
// job before that phase, not at completion.
transitAgentId: dto?.transitAgentId,
customsClearingAgent: dto?.customsClearingAgent,
customsClearingAgentEmail: dto?.customsClearingAgentEmail,
customsClearingAgentPhone: dto?.customsClearingAgentPhone,
},
{ id: user?.id ?? user?.sub },
user,
);

View File

@@ -236,8 +236,9 @@ export class CreateBookingUnderContractDto {
@ApiPropertyOptional({
maxLength: 200,
description:
'Customs clearing agent name. Required at completion of a without-customs ' +
'import/export booking (the service enforces it); ignored on customs contracts.',
'Customs clearing agent name. A without-customs import/export booking names its ' +
'clearing agent when it is initiated (this, with email and phone, or transitAgentId); ' +
'ignored on customs contracts.',
})
@IsOptional()
@IsString()