mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 22:58:17 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user