feat(companies): add Transit Agent service linked to transit-agent roster; forwarder/agent onboarding step, assigned-bookings tab, booking assignment + notify, drop agent validity window

This commit is contained in:
marshal
2026-09-06 21:41:29 +00:00
parent 6d4a919f39
commit 1eb9f10354
67 changed files with 2528 additions and 302 deletions

View File

@@ -20,6 +20,9 @@ import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingPricingService } from '../bookings/booking-pricing.service';
import { BookingTransitionService } from '../bookings/booking-transition.service';
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
import { TransitAgentCountry } from '../transit-agents/entities/transit-agent.entity';
import { TransitAgentsRepository } from '../transit-agents/transit-agents.repository';
import { TransitAssignmentsService } from '../transit-assignments/transit-assignments.service';
import { ConsolidationService } from '../bookings/consolidation.service';
import { ConsolidationApprovalService } from '../bookings/consolidation-approval.service';
import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
@@ -136,6 +139,8 @@ export class ContractBookingService {
private readonly bookingTransitionService: BookingTransitionService,
@Inject(forwardRef(() => ConsolidationApprovalService))
private readonly consolidationApprovalService: ConsolidationApprovalService,
private readonly transitAgentsRepository: TransitAgentsRepository,
private readonly transitAssignmentsService: TransitAssignmentsService,
) {}
async createUnderContract(
@@ -857,34 +862,66 @@ export class ContractBookingService {
if (!dto.scheduledDate) {
throw new BadRequestException('A binding shipment day is required');
}
// Without-customs import/export: the customer's own clearing agent (name,
// email, phone) is captured per booking at completion. A resubmit may omit
// the fields and keep what the booking already stored. Customs contracts
// (GL clears) and intercity (no border) never collect an agent.
// 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.
let assignedTransitAgent: { id: string; name: string } | null = null;
if (
!contract.customsClearingEnabled &&
contract.tradeDirection !== 'DOMESTIC'
) {
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.',
);
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);
}
await this.bookingsRepository.update(booking.id, {
customsClearingAgent: agentName,
customsClearingAgentEmail: agentEmail,
customsClearingAgentPhone: agentPhone,
} as never);
}
// No expiry gate here on purpose: this booking was already initiated
// before the contract lapsed (createUnderContract/initiateUnderContract
@@ -1092,6 +1129,18 @@ export class ContractBookingService {
dto.scheduledDate,
dto.trainScheduleId ?? null,
);
// The forwarder's work list and its notice come AFTER the booking is
// committed: a customer must never be told a forwarder has the job when
// the completion itself was refused a line above.
if (assignedTransitAgent) {
await this.transitAssignmentsService.ensureAssignment(
booking.id,
assignedTransitAgent.id,
(actorPermissions as { id?: string } | undefined)?.id,
);
void this.bookingNotifier.transitAgentAssigned(completed, assignedTransitAgent);
}
return { booking: completed, warnings };
}