mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 14:15:44 +00:00
Merge pull request #1514 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { RatesService } from '../modules/rule-engine/services/rates.service';
|
||||
import { Rate } from '../modules/rule-engine/entities/rate.entity';
|
||||
import { Rate, isContainerHazardRate } from '../modules/rule-engine/entities/rate.entity';
|
||||
import {
|
||||
ContractDirection,
|
||||
ContractFreight,
|
||||
@@ -109,12 +109,21 @@ export class ContractRateScheduleBuilder {
|
||||
// Fuel is sold per lane + commodity — only lanes matching the contract's
|
||||
// direction belong on its schedule, labeled with their leg.
|
||||
if (rate.trigger === 'FUEL') {
|
||||
if (this.fuelDirectionMatches(rate, direction)) {
|
||||
if (this.laneDirectionMatches(rate, direction)) {
|
||||
surcharges.push(this.fuelRow(rate));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// The container hazard surcharge is sold per lane (+ box size) — only a
|
||||
// container contract on a matching direction shows it, with its leg.
|
||||
if (isContainerHazardRate(rate.trigger, rate.rateUnit)) {
|
||||
if (freight === 'CON' && this.laneDirectionMatches(rate, direction)) {
|
||||
surcharges.push(this.lanedSurchargeRow(rate));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Everything left is a trigger-based charge (surcharge / demurrage / customs).
|
||||
surcharges.push(this.surchargeRow(rate));
|
||||
}
|
||||
@@ -203,12 +212,28 @@ export class ContractRateScheduleBuilder {
|
||||
};
|
||||
}
|
||||
|
||||
private fuelDirectionMatches(rate: Rate, direction: ContractDirection): boolean {
|
||||
/** Lane-sold surcharges (fuel, container hazard) match on the contract's direction. */
|
||||
private laneDirectionMatches(rate: Rate, direction: ContractDirection): boolean {
|
||||
const want =
|
||||
direction === 'IMP' ? 'IMPORT' : direction === 'EXP' ? 'EXPORT' : 'DOMESTIC';
|
||||
return rate.tradeDirection === want;
|
||||
}
|
||||
|
||||
/** A lane-sold surcharge row — the leg rides along in the charge label. */
|
||||
private lanedSurchargeRow(rate: Rate): RateScheduleRow {
|
||||
const origin = rate.originYard?.label ?? rate.originYard?.code ?? '—';
|
||||
const destination =
|
||||
rate.destinationYard?.label ?? rate.destinationYard?.code ?? '—';
|
||||
const label = TRIGGER_ROUTE_LABELS[rate.trigger] ?? this.titleCase(rate.trigger);
|
||||
return {
|
||||
route: `${label} (${origin} → ${destination})`,
|
||||
cargo: this.cargoLabel(rate),
|
||||
currency: rate.currency,
|
||||
amount: this.formatAmount(rate.rateValue),
|
||||
unit: this.unitLabel(rate.rateUnit),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuel row — the lane matters, so it rides along in the charge label.
|
||||
* Per-liter collapses to one flat total (base liters × rate value); the
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Links a freight-forwarder company to the transit agent it is registered as.
|
||||
*
|
||||
* An Ethiopian transit agent and a freight forwarder are the same business seen
|
||||
* from two sides: the roster GL uses to assign an officer, and the customer
|
||||
* that signs contracts on other companies' behalf. Until now nothing tied the
|
||||
* two rows together, so a forwarder could onboard under any name and staff had
|
||||
* no way to tell which roster entry it was.
|
||||
*
|
||||
* Two changes:
|
||||
* - `transit_agents.country` — the roster was Djibouti-only, so every existing
|
||||
* row defaults to `DJ`. Only `ET` agents are offered to a forwarder onboarding.
|
||||
* - `companies.transit_agent_id` — nullable: importers and exporters have no
|
||||
* agent, and pre-existing forwarders stay unlinked until they are edited.
|
||||
*/
|
||||
export class CompanyTransitAgentLink3930000000000 implements MigrationInterface {
|
||||
name = 'CompanyTransitAgentLink3930000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.transit_agents
|
||||
ADD COLUMN IF NOT EXISTS country varchar(2) NOT NULL DEFAULT 'DJ'
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_transit_agents_country"
|
||||
ON freight.transit_agents (country)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
ADD COLUMN IF NOT EXISTS transit_agent_id uuid
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_companies_transit_agent_id"
|
||||
ON freight.companies (transit_agent_id)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'FK_companies_transit_agent'
|
||||
) THEN
|
||||
ALTER TABLE freight.companies
|
||||
ADD CONSTRAINT "FK_companies_transit_agent"
|
||||
FOREIGN KEY (transit_agent_id)
|
||||
REFERENCES freight.transit_agents (id)
|
||||
ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
DROP CONSTRAINT IF EXISTS "FK_companies_transit_agent"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DROP INDEX IF EXISTS freight."IDX_companies_transit_agent_id"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
DROP COLUMN IF EXISTS transit_agent_id
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DROP INDEX IF EXISTS freight."IDX_transit_agents_country"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.transit_agents
|
||||
DROP COLUMN IF EXISTS country
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Drops the transit-agent validity window.
|
||||
*
|
||||
* `valid_from` / `valid_to` gated which officers GL could assign, on top of the
|
||||
* `is_active` switch. In practice the dates were never maintained — an agent
|
||||
* whose window lapsed was simply suspended — and with Ethiopian agents now
|
||||
* doubling as the roster a freight forwarder registers itself against, a
|
||||
* date range that silently hides a live business is worse than no range.
|
||||
* `is_active` is the single switch from here on.
|
||||
*
|
||||
* `down()` re-adds the columns as nullable: the dates themselves are gone.
|
||||
*/
|
||||
export class DropTransitAgentValidity3940000000000 implements MigrationInterface {
|
||||
name = 'DropTransitAgentValidity3940000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.transit_agents
|
||||
DROP COLUMN IF EXISTS valid_from,
|
||||
DROP COLUMN IF EXISTS valid_to
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.transit_agents
|
||||
ADD COLUMN IF NOT EXISTS valid_from date,
|
||||
ADD COLUMN IF NOT EXISTS valid_to date
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Reference sequence for the new `transit_agent` operational profile.
|
||||
*
|
||||
* `company_profiles.type` is a plain varchar, so the role itself needs no DDL;
|
||||
* what it needs is its own reference series (`TA-A00001`, …), minted on
|
||||
* approval exactly like IM/EX/FF. Mirrors the baseline's sequences.
|
||||
*/
|
||||
export class TransitAgentProfileSequence3950000000000 implements MigrationInterface {
|
||||
name = "TransitAgentProfileSequence3950000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_ta
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP SEQUENCE IF EXISTS freight.seq_company_profile_ta`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* The container hazardous-cargo surcharge (HAZARDOUS billed PER_CONTAINER) is
|
||||
* now sold per trade direction + origin → destination lane, optionally per
|
||||
* container type (20ft / 40ft) — the same shape as the empty-return service.
|
||||
* The per-ton (bulk) hazard rate keeps its global, unscoped shape.
|
||||
*
|
||||
* - CK_rates_yard_scope gains the per-container hazard rate in its
|
||||
* yard-carrying branch. Drop-and-recreate is the established shape for this
|
||||
* constraint — see 3890000000000-EmptyContainerRateScope.
|
||||
* - Existing lane-less per-container hazard rows cannot satisfy the new
|
||||
* branch and no longer match the way the engine prices container hazard
|
||||
* (per lane + size), so they are SUPERSEDED — kept for the audit trail, out
|
||||
* of the unique pattern index and out of pricing. The rates team re-enters
|
||||
* the surcharge per lane; until then a hazardous container booking on that
|
||||
* lane hard-blocks rather than shipping the service for free.
|
||||
*/
|
||||
export class ContainerHazardRateScope3960000000000 implements MigrationInterface {
|
||||
name = "ContainerHazardRateScope3960000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.rates
|
||||
SET status = 'SUPERSEDED'
|
||||
WHERE trigger = 'HAZARDOUS'
|
||||
AND rate_unit = 'PER_CONTAINER'
|
||||
AND (origin_yard_id IS NULL OR destination_yard_id IS NULL)
|
||||
AND deleted_at IS NULL
|
||||
AND status <> 'SUPERSEDED'
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
|
||||
deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
|
||||
CASE
|
||||
WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'EMPTY_CONTAINER', 'INTERCITY'))
|
||||
OR trigger IN ('CUSTOMS_CLEARANCE', 'ETHIOPIAN_CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL')
|
||||
OR (trigger = 'HAZARDOUS' AND rate_unit = 'PER_CONTAINER')
|
||||
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
|
||||
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
|
||||
END
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// Lane-scoped per-container hazard rows have no place under the old
|
||||
// constraint (surcharges carried no yards) — retire them the same way.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.rates
|
||||
SET status = 'SUPERSEDED'
|
||||
WHERE trigger = 'HAZARDOUS'
|
||||
AND rate_unit = 'PER_CONTAINER'
|
||||
AND (origin_yard_id IS NOT NULL OR destination_yard_id IS NOT NULL)
|
||||
AND deleted_at IS NULL
|
||||
AND status <> 'SUPERSEDED'
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
|
||||
deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
|
||||
CASE
|
||||
WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'EMPTY_CONTAINER', 'INTERCITY'))
|
||||
OR trigger IN ('CUSTOMS_CLEARANCE', 'ETHIOPIAN_CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL')
|
||||
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
|
||||
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
|
||||
END
|
||||
)
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -656,4 +656,67 @@ export class BookingLifecycleNotifierService {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The customer picked a registered transit agent (a freight forwarder on the
|
||||
* platform) to clear this booking. Tell the forwarder company — every one of
|
||||
* its portal users in the bell, plus SMS and email to its contact — so the
|
||||
* job shows up in its Assigned Bookings tab and it can start preparing
|
||||
* documents. The company is found through its transit-agent link; an agent
|
||||
* nobody has registered against gets no message, since there is nobody to
|
||||
* send it to. Never throws: a failed notice must not undo a completed booking.
|
||||
*/
|
||||
async transitAgentAssigned(
|
||||
b: Booking,
|
||||
agent: { id: string; name: string },
|
||||
): Promise<void> {
|
||||
try {
|
||||
const [forwarder]: Array<{ id: string }> = await this.dataSource.query(
|
||||
`SELECT id FROM freight.companies
|
||||
WHERE transit_agent_id = $1 AND deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[agent.id],
|
||||
);
|
||||
if (!forwarder) {
|
||||
this.logger.warn(
|
||||
`Transit agent ${agent.name} has no forwarder company — assignment notice for ${this.ref(b)} not sent`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const title = 'New booking assigned to you';
|
||||
const body = `Booking ${b.reference} has been assigned to ${agent.name} for customs clearance. Open Assigned Bookings in the portal to see it.`;
|
||||
void this.inbox.notify({
|
||||
recipients: { companyId: forwarder.id },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.BOOKING_STATUS,
|
||||
title,
|
||||
body,
|
||||
link: '/forwarder/assigned-bookings',
|
||||
data: { bookingId: b.id, reference: b.reference, transitAgentId: agent.id },
|
||||
});
|
||||
const { phone, email } = await resolveCompanyNotifyContact(
|
||||
this.dataSource,
|
||||
forwarder.id,
|
||||
);
|
||||
const message = `EDR Freight: ${body}`;
|
||||
if (phone) {
|
||||
try {
|
||||
await this.notifications.directSend('sms', phone, message);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Forwarder SMS failed for ${this.ref(b)}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
if (email) {
|
||||
try {
|
||||
await this.notifications.directSend('email', email, message);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Forwarder email failed for ${this.ref(b)}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`transitAgentAssigned failed for ${this.ref(b)}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,6 +330,7 @@ export class CompaniesController {
|
||||
dto.nationality,
|
||||
dto.cooperative,
|
||||
dto.investorLicence,
|
||||
dto.transitAgentId,
|
||||
);
|
||||
return new CompanyInfoResponseDto(profile, company);
|
||||
}
|
||||
@@ -363,6 +364,7 @@ export class CompaniesController {
|
||||
dto.type,
|
||||
dto.businessLicense,
|
||||
dto.licenceNumber,
|
||||
dto.transitAgentId,
|
||||
);
|
||||
return new ResponseCompanyProfileDto(profile);
|
||||
}
|
||||
|
||||
@@ -80,6 +80,9 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
||||
|
||||
const company = () => ({
|
||||
id: "company-1",
|
||||
// Already named its roster entry: taking the forwarder role needs one,
|
||||
// and these tests are about the PoA gate, not the transit-agent link.
|
||||
transitAgentId: "ta-et",
|
||||
status: ctx.status,
|
||||
nationality: ctx.nationality,
|
||||
attributes: ctx.attributes,
|
||||
@@ -175,6 +178,14 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
||||
deps.companyNotifier as never,
|
||||
{} as never,
|
||||
deps.verifayda as never,
|
||||
{
|
||||
findById: jest.fn(async () => ({
|
||||
id: "ta-et",
|
||||
name: "Abyssinia Transit",
|
||||
isActive: true,
|
||||
country: "ET",
|
||||
})),
|
||||
} as never, // transitAgentsRepo
|
||||
);
|
||||
|
||||
jest
|
||||
|
||||
@@ -66,6 +66,7 @@ function makeService(company: Record<string, unknown> | null) {
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
jest
|
||||
|
||||
@@ -101,6 +101,7 @@ function makeService(
|
||||
{ changeRequestSubmitted: jest.fn() } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
jest
|
||||
|
||||
@@ -46,6 +46,9 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
||||
|
||||
const company = () => ({
|
||||
id: "company-1",
|
||||
// Already named its roster entry: taking the forwarder role needs one,
|
||||
// and these tests are about the PoA gate, not the transit-agent link.
|
||||
transitAgentId: "ta-et",
|
||||
status: ctx.status,
|
||||
attributes: ctx.attributes,
|
||||
companyProfiles: ctx.profileTypes.map((type, i) => ({
|
||||
@@ -146,6 +149,14 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
||||
deps.companyNotifier as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{
|
||||
findById: jest.fn(async () => ({
|
||||
id: "ta-et",
|
||||
name: "Abyssinia Transit",
|
||||
isActive: true,
|
||||
country: "ET",
|
||||
})),
|
||||
} as never, // transitAgentsRepo
|
||||
);
|
||||
|
||||
// getCompanyInfoByUserId does its own lookups; the stubs above are enough for
|
||||
|
||||
@@ -75,6 +75,7 @@ function makeService(status: ProfileStatus) {
|
||||
companyNotifier as never,
|
||||
dataSource as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
return { service, profile, written, companyProfilesRepo };
|
||||
|
||||
@@ -70,6 +70,7 @@ function makeService(attributes: Record<string, unknown> = {}) {
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
jest
|
||||
|
||||
@@ -54,6 +54,7 @@ function makeService(existing: ExistingProfile[]) {
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
jest
|
||||
|
||||
@@ -31,6 +31,31 @@ import {
|
||||
POA_DELEGATION_PENDING_CODE,
|
||||
} from "../file-upload-settings/poa-delegation.constants";
|
||||
import { VerifaydaService } from "../verifayda/verifayda.service";
|
||||
import {
|
||||
TransitAgent,
|
||||
TransitAgentCountry,
|
||||
} from "../transit-agents/entities/transit-agent.entity";
|
||||
import { TransitAgentsRepository } from "../transit-agents/transit-agents.repository";
|
||||
|
||||
/**
|
||||
* The roles that ARE an Ethiopian transit-agent roster entry. Both name the
|
||||
* company's `transitAgentId`; the difference is what else the company does —
|
||||
* a forwarder also trades on other companies' behalf, a plain transit agent
|
||||
* only clears customs for bookings customers assign to it.
|
||||
*/
|
||||
function isAgentRole(type: ProfileType): boolean {
|
||||
// A function rather than a module-level array: the entity module is still
|
||||
// initialising when this file loads (company-profile → company → …), so
|
||||
// reading the enum at load time throws.
|
||||
return (
|
||||
type === ProfileType.freightForwarder || type === ProfileType.transitAgent
|
||||
);
|
||||
}
|
||||
|
||||
/** True when the company does nothing but act as a transit agent. */
|
||||
function isTransitAgentOnly(types: ProfileType[]): boolean {
|
||||
return types.length > 0 && types.every((t) => t === ProfileType.transitAgent);
|
||||
}
|
||||
import {
|
||||
buildCompanyIdentityState,
|
||||
CompanyIdentityStateDto,
|
||||
@@ -204,6 +229,7 @@ export class CompaniesService {
|
||||
private readonly companyNotifier: CompanyNotifierService,
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly verifaydaService: VerifaydaService,
|
||||
private readonly transitAgentsRepo: TransitAgentsRepository,
|
||||
) { }
|
||||
|
||||
/**
|
||||
@@ -387,21 +413,21 @@ export class CompaniesService {
|
||||
nationality?: CompanyNationality,
|
||||
cooperative?: boolean,
|
||||
investorLicence?: boolean,
|
||||
transitAgentId?: string,
|
||||
): Promise<{ profile: ExternalProfile; company: Company }> {
|
||||
const needsAgent = roles.some(isAgentRole);
|
||||
// A company that is ONLY a transit agent has nothing else to tell us: its
|
||||
// registration IS the roster entry it picked, so onboarding ends here.
|
||||
const transitAgentOnly = isTransitAgentOnly(roles);
|
||||
// Already started — reuse the existing draft, just ensure roles exist and
|
||||
// keep the nationality up to date if it was (re)selected.
|
||||
const existing = await this.profilesRepo.findByUserId(identity.userId);
|
||||
if (existing) {
|
||||
const companyId = existing.company?.id ?? existing.companyId;
|
||||
// Only load the row when the answer actually depends on it: to merge the
|
||||
// flag into `attributes`, or to read a stored one the caller didn't send.
|
||||
const needsCompany =
|
||||
cooperative !== undefined ||
|
||||
investorLicence !== undefined ||
|
||||
roles.includes(ProfileType.freightForwarder);
|
||||
const current = needsCompany
|
||||
? await this.companiesRepo.findById(companyId)
|
||||
: null;
|
||||
// The stored row decides more than one answer here: the flags the caller
|
||||
// didn't send, the transit agent a forwarder already linked, and whether
|
||||
// dropping the forwarder role has a link to clear.
|
||||
const current = await this.companiesRepo.findById(companyId);
|
||||
const isCoop = cooperative ?? isCooperative(current);
|
||||
const isInvestor = investorLicence ?? hasInvestorLicence(current);
|
||||
this.assertRolesAllowedForCooperative(isCoop, roles);
|
||||
@@ -411,8 +437,38 @@ export class CompaniesService {
|
||||
isCoop,
|
||||
nationality ?? current?.nationality ?? undefined,
|
||||
);
|
||||
// A re-run that keeps an agent role may omit the agent it already
|
||||
// picked; one that drops both agent roles drops the link with it, so a
|
||||
// company that later re-adds one is asked again rather than inheriting a
|
||||
// stale answer.
|
||||
const linkedAgent = needsAgent
|
||||
? await this.resolveLinkedTransitAgent(
|
||||
transitAgentId ?? current?.transitAgentId ?? undefined,
|
||||
)
|
||||
: null;
|
||||
const before = await this.companyProfilesRepo.findByCompanyId(companyId);
|
||||
const wasTransitAgentOnly = isTransitAgentOnly(before.map((p) => p.type));
|
||||
await this.syncCompanyProfiles(companyId, companyType, roles);
|
||||
const updates: Partial<Company> = {};
|
||||
if ((linkedAgent?.id ?? null) !== (current?.transitAgentId ?? null)) {
|
||||
updates.transitAgentId = linkedAgent?.id ?? null;
|
||||
}
|
||||
const profilePatch: Partial<ExternalProfile> = {};
|
||||
if (transitAgentOnly && linkedAgent) {
|
||||
// The company is the agent — it gets the roster's name, and there is
|
||||
// no company/owner/documents step left to take.
|
||||
updates.name = linkedAgent.name;
|
||||
if (!existing.onboardingCompleted) {
|
||||
profilePatch.onboardingCompleted = true;
|
||||
profilePatch.onboardingStep = "transit-agent";
|
||||
}
|
||||
} else if (existing.onboardingCompleted && wasTransitAgentOnly) {
|
||||
// Adding a licensed role to a transit-agent-only company reopens the
|
||||
// wizard at the company step: importing or forwarding needs the TIN,
|
||||
// owner, contact and documents the transit agent never had to give.
|
||||
profilePatch.onboardingCompleted = false;
|
||||
profilePatch.onboardingStep = "company";
|
||||
}
|
||||
if (nationality) updates.nationality = nationality;
|
||||
// Ticking the box on a draft that was saved as foreign has to correct the
|
||||
// stored nationality too, or the company keeps resolving to the foreign
|
||||
@@ -446,10 +502,9 @@ export class CompaniesService {
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await this.companiesRepo.update(companyId, updates);
|
||||
}
|
||||
if (backToEtrade) {
|
||||
await this.profilesRepo.update(existing.id, {
|
||||
onboardingStep: "company",
|
||||
});
|
||||
if (backToEtrade) profilePatch.onboardingStep = "company";
|
||||
if (Object.keys(profilePatch).length > 0) {
|
||||
await this.profilesRepo.update(existing.id, profilePatch);
|
||||
}
|
||||
return this.getCompanyInfoByUserId(identity.userId);
|
||||
}
|
||||
@@ -463,16 +518,23 @@ export class CompaniesService {
|
||||
);
|
||||
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
|
||||
const chosenTypes = roles.filter((t) => allowedTypes.includes(t));
|
||||
const linkedAgent = needsAgent
|
||||
? await this.resolveLinkedTransitAgent(transitAgentId)
|
||||
: null;
|
||||
|
||||
const company = await this.companiesRepo.create({
|
||||
name: identity.firstName
|
||||
? `${identity.firstName}'s company`
|
||||
: "New company",
|
||||
name:
|
||||
transitAgentOnly && linkedAgent
|
||||
? linkedAgent.name
|
||||
: identity.firstName
|
||||
? `${identity.firstName}'s company`
|
||||
: "New company",
|
||||
type: companyType,
|
||||
tin: await this.generateDraftTin(),
|
||||
country: "Ethiopia",
|
||||
nationality: nationality ?? CompanyNationality.Ethiopian,
|
||||
status: CompanyStatus.Pending,
|
||||
transitAgentId: linkedAgent?.id ?? null,
|
||||
...(cooperative || investorLicence
|
||||
? {
|
||||
attributes: {
|
||||
@@ -489,8 +551,10 @@ export class CompaniesService {
|
||||
firstName: identity.firstName,
|
||||
lastName: identity.lastName,
|
||||
isPrimaryContact: true,
|
||||
onboardingStep: "company",
|
||||
onboardingCompleted: false,
|
||||
// A transit-agent-only company is done the moment it picks its roster
|
||||
// entry — see `transitAgentOnly` above.
|
||||
onboardingStep: transitAgentOnly ? "transit-agent" : "company",
|
||||
onboardingCompleted: transitAgentOnly,
|
||||
});
|
||||
|
||||
await this.syncCompanyProfiles(company.id, companyType, chosenTypes);
|
||||
@@ -498,6 +562,75 @@ export class CompaniesService {
|
||||
return this.getCompanyInfoByUserId(identity.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The transit agent a freight forwarder registers itself as.
|
||||
*
|
||||
* A forwarder and an Ethiopian transit agent are the same business, so the
|
||||
* role cannot be taken without naming which roster entry it is: a company
|
||||
* that is not on the roster asks support to be added first, which is what
|
||||
* the portal's "didn't find my company" note says. Foreign and suspended
|
||||
* entries are refused for the same reason a missing one is — none of them
|
||||
* is a forwarder EDR will assign work to.
|
||||
*/
|
||||
private async resolveLinkedTransitAgent(
|
||||
transitAgentId: string | undefined,
|
||||
): Promise<TransitAgent> {
|
||||
if (!transitAgentId) {
|
||||
throw new BadRequestException(
|
||||
"Select your company from the transit agent list to register as a transit agent or freight forwarder. If it is not listed, contact support to be added.",
|
||||
);
|
||||
}
|
||||
const agent = await this.transitAgentsRepo.findById(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 contact support.",
|
||||
);
|
||||
}
|
||||
return agent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make `agent` the company's transit agent if it is not already. Shared by
|
||||
* every add-role path: the link is per company, so a forwarder that already
|
||||
* picked its roster entry is not asked again when it adds the transit agent
|
||||
* role, and vice versa.
|
||||
*/
|
||||
private async linkTransitAgent(
|
||||
company: Company,
|
||||
transitAgentId: string | undefined,
|
||||
): Promise<void> {
|
||||
const agent = await this.resolveLinkedTransitAgent(
|
||||
transitAgentId ?? company.transitAgentId ?? undefined,
|
||||
);
|
||||
if (company.transitAgentId === agent.id) return;
|
||||
await this.companiesRepo.update(company.id, { transitAgentId: agent.id });
|
||||
company.transitAgentId = agent.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* A transit-agent-only company that takes on a licensed role has to go
|
||||
* back through the wizard: importing, exporting or forwarding needs the TIN,
|
||||
* owner, contact and documents the transit agent never had to give. The
|
||||
* portal reopens the wizard at the company step the moment this flips.
|
||||
*/
|
||||
private async reopenOnboardingForLicensedRole(
|
||||
profile: ExternalProfile,
|
||||
profilesBefore: CompanyProfile[],
|
||||
addedTypes: ProfileType[],
|
||||
): Promise<void> {
|
||||
if (!profile.onboardingCompleted) return;
|
||||
if (!isTransitAgentOnly(profilesBefore.map((p) => p.type))) return;
|
||||
if (!addedTypes.some((t) => t !== ProfileType.transitAgent)) return;
|
||||
await this.profilesRepo.update(profile.id, {
|
||||
onboardingCompleted: false,
|
||||
onboardingStep: "company",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A co-operative union or farm cannot hold the freight-forwarder role.
|
||||
*
|
||||
@@ -517,6 +650,11 @@ export class CompaniesService {
|
||||
"A co-operative union or farm cannot register as a freight forwarder — that role requires a business licence.",
|
||||
);
|
||||
}
|
||||
if (roles.includes(ProfileType.transitAgent)) {
|
||||
throw new BadRequestException(
|
||||
"A co-operative union or farm cannot register as a transit agent — that is licensed customs work.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -675,6 +813,7 @@ export class CompaniesService {
|
||||
// External profiles carry the onboarding flag the backoffice gates
|
||||
// approval decisions on (see ResponseCompanyDto.onboardingCompleted).
|
||||
company.profiles = await this.profilesRepo.findByCompanyId(id);
|
||||
await this.attachTransitAgent(company);
|
||||
return company;
|
||||
}
|
||||
|
||||
@@ -715,10 +854,22 @@ export class CompaniesService {
|
||||
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(
|
||||
company.id,
|
||||
);
|
||||
await this.attachTransitAgent(company);
|
||||
|
||||
return { profile, company };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hang the linked transit agent off the company so responses can name it.
|
||||
* A separate read rather than a relation join: `findById` on the repository
|
||||
* loads no relations, and every other caller of it has no use for the agent.
|
||||
*/
|
||||
private async attachTransitAgent(company: Company): Promise<void> {
|
||||
company.transitAgent = company.transitAgentId
|
||||
? await this.transitAgentsRepo.findById(company.transitAgentId)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard KPIs for the portal home (MyPortalPage), aggregated from the
|
||||
* current user's company bookings. All figures are scoped to that company.
|
||||
@@ -1799,6 +1950,7 @@ export class CompaniesService {
|
||||
ProfileType.importer,
|
||||
ProfileType.exporter,
|
||||
ProfileType.freightForwarder,
|
||||
ProfileType.transitAgent,
|
||||
];
|
||||
case "freight_forwarder":
|
||||
return [ProfileType.freightForwarder];
|
||||
@@ -2154,7 +2306,11 @@ export class CompaniesService {
|
||||
*/
|
||||
async addCompanyProfilesForUser(
|
||||
userId: string,
|
||||
inputs: Array<{ type: ProfileType; licenceNumber?: string }>,
|
||||
inputs: Array<{
|
||||
type: ProfileType;
|
||||
licenceNumber?: string;
|
||||
transitAgentId?: string;
|
||||
}>,
|
||||
): Promise<CompanyProfile[]> {
|
||||
const types = inputs.map((i) => i.type);
|
||||
const profile = await this.profilesRepo.findByUserId(userId);
|
||||
@@ -2164,6 +2320,8 @@ export class CompaniesService {
|
||||
const companyId = profile.company?.id ?? profile.companyId;
|
||||
const company = await this.findCompanyById(companyId);
|
||||
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
|
||||
const before = company.companyProfiles ?? [];
|
||||
const wasTransitAgentOnly = isTransitAgentOnly(before.map((p) => p.type));
|
||||
|
||||
for (const type of types) {
|
||||
if (!allowedTypes.includes(type)) {
|
||||
@@ -2191,14 +2349,31 @@ export class CompaniesService {
|
||||
);
|
||||
}
|
||||
|
||||
// A transit agent or forwarder IS a roster entry — name it, once per
|
||||
// company, before the role exists.
|
||||
if (isAgentRole(type)) {
|
||||
this.assertRolesAllowedForCooperative(isCooperative(company), [type]);
|
||||
await this.linkTransitAgent(
|
||||
company,
|
||||
inputs.find((i) => i.type === type)?.transitAgentId,
|
||||
);
|
||||
}
|
||||
|
||||
// Which eTrade business this role operates as. Resolved (and rejected if
|
||||
// absent) BEFORE the row is created, so a role never lands unattached on
|
||||
// a company that has licences to pick from.
|
||||
const etradeBusiness = await this.resolveProfileBusiness(
|
||||
company,
|
||||
inputs.find((i) => i.type === type)?.licenceNumber,
|
||||
type,
|
||||
);
|
||||
// a company that has licences to pick from. A transit agent has none —
|
||||
// and a transit-agent-only company has no eTrade record to pick from
|
||||
// yet: its first licensed role attaches the business on the wizard's
|
||||
// licence step, once the TIN has been looked up, exactly like a role
|
||||
// picked at onboarding.
|
||||
const etradeBusiness =
|
||||
type === ProfileType.transitAgent || wasTransitAgentOnly
|
||||
? null
|
||||
: await this.resolveProfileBusiness(
|
||||
company,
|
||||
inputs.find((i) => i.type === type)?.licenceNumber,
|
||||
type,
|
||||
);
|
||||
|
||||
// Self-service role adds start Pending and carry no reference — a reference
|
||||
// is minted only when a backoffice reviewer approves the role.
|
||||
@@ -2210,6 +2385,7 @@ export class CompaniesService {
|
||||
});
|
||||
}
|
||||
|
||||
await this.reopenOnboardingForLicensedRole(profile, before, types);
|
||||
return this.companyProfilesRepo.findByCompanyId(companyId);
|
||||
}
|
||||
|
||||
@@ -2224,6 +2400,7 @@ export class CompaniesService {
|
||||
type: ProfileType,
|
||||
businessLicense?: string,
|
||||
licenceNumber?: string,
|
||||
transitAgentId?: string,
|
||||
): Promise<CompanyProfile> {
|
||||
const profile = await this.profilesRepo.findByUserId(userId);
|
||||
if (!profile)
|
||||
@@ -2248,12 +2425,18 @@ export class CompaniesService {
|
||||
await this.effectivePoaAttributes(company),
|
||||
);
|
||||
}
|
||||
if (!created && isAgentRole(type)) {
|
||||
this.assertRolesAllowedForCooperative(isCooperative(company), [type]);
|
||||
await this.linkTransitAgent(company, transitAgentId);
|
||||
}
|
||||
if (!created) {
|
||||
const etradeBusiness = await this.resolveProfileBusiness(
|
||||
company,
|
||||
licenceNumber,
|
||||
type,
|
||||
);
|
||||
// See addCompanyProfilesForUser: no business for a transit agent, nor
|
||||
// for a transit-agent-only company's first licensed role.
|
||||
const etradeBusiness =
|
||||
type === ProfileType.transitAgent ||
|
||||
isTransitAgentOnly((company.companyProfiles ?? []).map((p) => p.type))
|
||||
? null
|
||||
: await this.resolveProfileBusiness(company, licenceNumber, type);
|
||||
// New self-service roles start Pending (awaiting backoffice approval) and
|
||||
// carry no reference until approved.
|
||||
created = await this.companyProfilesRepo.create({
|
||||
@@ -2263,6 +2446,11 @@ export class CompaniesService {
|
||||
etradeBusiness,
|
||||
status: ProfileStatus.Pending,
|
||||
});
|
||||
await this.reopenOnboardingForLicensedRole(
|
||||
profile,
|
||||
company.companyProfiles ?? [],
|
||||
[type],
|
||||
);
|
||||
}
|
||||
|
||||
return created;
|
||||
@@ -2331,9 +2519,13 @@ export class CompaniesService {
|
||||
}));
|
||||
const missingDocs = documents.filter((d) => d.isRequired && !d.uploaded);
|
||||
|
||||
// 3. Per-operational-profile business licenses (FileRecord-backed).
|
||||
// 3. Per-operational-profile business licenses (FileRecord-backed). A
|
||||
// transit agent holds none here — its roster entry is its registration —
|
||||
// so it owes neither a licence nor an eTrade business.
|
||||
const licenseProfiles = await Promise.all(
|
||||
(company.companyProfiles ?? []).map(async (p) => {
|
||||
(company.companyProfiles ?? [])
|
||||
.filter((p) => p.type !== ProfileType.transitAgent)
|
||||
.map(async (p) => {
|
||||
const records = await this.filesService.findByResource(
|
||||
p.id,
|
||||
LICENSE_RESOURCE,
|
||||
@@ -3775,7 +3967,11 @@ export class CompaniesService {
|
||||
companyId: string,
|
||||
tradeDirection: string,
|
||||
): Promise<string | null> {
|
||||
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
|
||||
// A transit agent profile never carries a booking — it is the roster
|
||||
// side of the company, not a trade role.
|
||||
const profiles = (
|
||||
await this.companyProfilesRepo.findByCompanyId(companyId)
|
||||
).filter((p) => p.type !== ProfileType.transitAgent);
|
||||
if (profiles.length === 0) return null;
|
||||
|
||||
const naturalType =
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
|
||||
import { TransitAgentCountry } from "../transit-agents/entities/transit-agent.entity";
|
||||
import { CompaniesService } from "./companies.service";
|
||||
import { CompanyType } from "./entities/company.entity";
|
||||
import { ProfileStatus, ProfileType } from "./entities/company-profile.entity";
|
||||
|
||||
/**
|
||||
* A freight forwarder IS an Ethiopian transit agent, so taking the role means
|
||||
* naming which roster entry the company is. These lock the rule at the door:
|
||||
* no agent → refused; a foreign or suspended one → refused; and the link
|
||||
* follows the role, both on a fresh draft and when a draft is re-run.
|
||||
*/
|
||||
|
||||
interface ExistingProfile {
|
||||
id: string;
|
||||
type: ProfileType;
|
||||
status: ProfileStatus;
|
||||
}
|
||||
|
||||
const ETHIOPIAN = {
|
||||
id: "ta-et",
|
||||
name: "Abyssinia Transit",
|
||||
isActive: true,
|
||||
country: TransitAgentCountry.Ethiopia,
|
||||
};
|
||||
const DJIBOUTIAN = {
|
||||
id: "ta-dj",
|
||||
name: "Ahmed Bourhan",
|
||||
isActive: true,
|
||||
country: TransitAgentCountry.Djibouti,
|
||||
};
|
||||
const SUSPENDED = { ...ETHIOPIAN, id: "ta-off", isActive: false };
|
||||
const AGENTS = [ETHIOPIAN, DJIBOUTIAN, SUSPENDED];
|
||||
|
||||
function makeService(opts: {
|
||||
existing?: ExistingProfile[] | null;
|
||||
linkedAgentId?: string | null;
|
||||
/** The draft's owner already submitted onboarding (transit-agent-only). */
|
||||
onboardingCompleted?: boolean;
|
||||
}) {
|
||||
const companyProfilesRepo = {
|
||||
findByCompanyId: jest.fn(async () => opts.existing ?? []),
|
||||
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||
id: "new",
|
||||
...row,
|
||||
})),
|
||||
softDelete: jest.fn(async () => undefined),
|
||||
};
|
||||
const companiesRepo = {
|
||||
update: jest.fn(async () => null),
|
||||
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||
id: "company-1",
|
||||
...row,
|
||||
})),
|
||||
findById: jest.fn(async () => ({
|
||||
id: "company-1",
|
||||
attributes: {},
|
||||
transitAgentId: opts.linkedAgentId ?? null,
|
||||
})),
|
||||
};
|
||||
const profilesRepo = {
|
||||
findByUserId: jest.fn(async () =>
|
||||
opts.existing === null
|
||||
? null
|
||||
: {
|
||||
id: "external-1",
|
||||
companyId: "company-1",
|
||||
company: { id: "company-1" },
|
||||
onboardingCompleted: opts.onboardingCompleted ?? false,
|
||||
},
|
||||
),
|
||||
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||
id: "external-1",
|
||||
...row,
|
||||
})),
|
||||
update: jest.fn(async () => null),
|
||||
};
|
||||
const transitAgentsRepo = {
|
||||
findById: jest.fn(async (id: string) => AGENTS.find((a) => a.id === id) ?? null),
|
||||
};
|
||||
|
||||
const service = new CompaniesService(
|
||||
companiesRepo as never,
|
||||
companyProfilesRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
profilesRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
transitAgentsRepo as never,
|
||||
);
|
||||
|
||||
jest
|
||||
.spyOn(service, "getCompanyInfoByUserId")
|
||||
.mockImplementation(
|
||||
async () =>
|
||||
({ profile: { id: "external-1" }, company: { id: "company-1" } }) as never,
|
||||
);
|
||||
// The draft TIN is random and irrelevant here.
|
||||
jest
|
||||
.spyOn(service as never, "generateDraftTin" as never)
|
||||
.mockImplementation((async () => "D000000001") as never);
|
||||
|
||||
return { service, companiesRepo, companyProfilesRepo, profilesRepo };
|
||||
}
|
||||
|
||||
const identity = { userId: "user-1", firstName: "Abebe", lastName: "K" };
|
||||
|
||||
const start = (
|
||||
service: CompaniesService,
|
||||
roles: ProfileType[],
|
||||
transitAgentId?: string,
|
||||
) =>
|
||||
service.startOnboarding(
|
||||
identity as never,
|
||||
CompanyType.Customer,
|
||||
roles,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
transitAgentId,
|
||||
);
|
||||
|
||||
describe("a freight forwarder must name its transit agent", () => {
|
||||
it("refuses the forwarder role without an agent on a fresh draft", async () => {
|
||||
const { service, companiesRepo } = makeService({ existing: null });
|
||||
|
||||
await expect(
|
||||
start(service, [ProfileType.freightForwarder]),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(companiesRepo.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a Djiboutian agent", async () => {
|
||||
const { service } = makeService({ existing: null });
|
||||
|
||||
await expect(
|
||||
start(service, [ProfileType.freightForwarder], DJIBOUTIAN.id),
|
||||
).rejects.toThrow(/not an active Ethiopian transit agent/);
|
||||
});
|
||||
|
||||
it("refuses a suspended agent", async () => {
|
||||
const { service } = makeService({ existing: null });
|
||||
|
||||
await expect(
|
||||
start(service, [ProfileType.freightForwarder], SUSPENDED.id),
|
||||
).rejects.toThrow(/not an active Ethiopian transit agent/);
|
||||
});
|
||||
|
||||
it("stores the Ethiopian agent on the new draft company", async () => {
|
||||
const { service, companiesRepo } = makeService({ existing: null });
|
||||
|
||||
await start(
|
||||
service,
|
||||
[ProfileType.importer, ProfileType.freightForwarder],
|
||||
ETHIOPIAN.id,
|
||||
);
|
||||
|
||||
expect(companiesRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ transitAgentId: ETHIOPIAN.id }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not ask an importer for an agent, and stores none", async () => {
|
||||
const { service, companiesRepo } = makeService({ existing: null });
|
||||
|
||||
await start(service, [ProfileType.importer], ETHIOPIAN.id);
|
||||
|
||||
expect(companiesRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ transitAgentId: null }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("re-running role selection keeps the link in step with the role", () => {
|
||||
const importerOnly: ExistingProfile[] = [
|
||||
{ id: "p-imp", type: ProfileType.importer, status: ProfileStatus.Pending },
|
||||
];
|
||||
|
||||
it("links the agent when forwarding is added to an existing draft", async () => {
|
||||
const { service, companiesRepo } = makeService({ existing: importerOnly });
|
||||
|
||||
await start(
|
||||
service,
|
||||
[ProfileType.importer, ProfileType.freightForwarder],
|
||||
ETHIOPIAN.id,
|
||||
);
|
||||
|
||||
expect(companiesRepo.update).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
expect.objectContaining({ transitAgentId: ETHIOPIAN.id }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the agent already linked when the re-run omits it", async () => {
|
||||
const { service, companiesRepo } = makeService({
|
||||
existing: importerOnly,
|
||||
linkedAgentId: ETHIOPIAN.id,
|
||||
});
|
||||
|
||||
await start(service, [ProfileType.importer, ProfileType.freightForwarder]);
|
||||
|
||||
expect(companiesRepo.update).not.toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
expect.objectContaining({ transitAgentId: expect.anything() }),
|
||||
);
|
||||
});
|
||||
|
||||
it("still refuses a re-run that adds forwarding with nothing linked", async () => {
|
||||
const { service } = makeService({ existing: importerOnly });
|
||||
|
||||
await expect(
|
||||
start(service, [ProfileType.importer, ProfileType.freightForwarder]),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("clears the link when the forwarder role is dropped", async () => {
|
||||
const { service, companiesRepo } = makeService({
|
||||
existing: importerOnly,
|
||||
linkedAgentId: ETHIOPIAN.id,
|
||||
});
|
||||
|
||||
await start(service, [ProfileType.importer]);
|
||||
|
||||
expect(companiesRepo.update).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
expect.objectContaining({ transitAgentId: null }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A company that is ONLY a transit agent has nothing else to tell us: its
|
||||
* registration is the roster entry it picked, so onboarding ends right there
|
||||
* — and reopens if it later takes on a licensed role.
|
||||
*/
|
||||
describe("a transit-agent-only company finishes onboarding on the roster pick", () => {
|
||||
it("names the draft after the agent and completes onboarding at once", async () => {
|
||||
const { service, companiesRepo, profilesRepo } = makeService({
|
||||
existing: null,
|
||||
});
|
||||
|
||||
await start(service, [ProfileType.transitAgent], ETHIOPIAN.id);
|
||||
|
||||
expect(companiesRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: ETHIOPIAN.name,
|
||||
transitAgentId: ETHIOPIAN.id,
|
||||
}),
|
||||
);
|
||||
expect(profilesRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
onboardingCompleted: true,
|
||||
onboardingStep: "transit-agent",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("still refuses the role without an agent", async () => {
|
||||
const { service, companiesRepo } = makeService({ existing: null });
|
||||
|
||||
await expect(
|
||||
start(service, [ProfileType.transitAgent]),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(companiesRepo.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the full wizard when a trade role is picked alongside", async () => {
|
||||
const { service, companiesRepo, profilesRepo } = makeService({
|
||||
existing: null,
|
||||
});
|
||||
|
||||
await start(
|
||||
service,
|
||||
[ProfileType.transitAgent, ProfileType.importer],
|
||||
ETHIOPIAN.id,
|
||||
);
|
||||
|
||||
expect(companiesRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ transitAgentId: ETHIOPIAN.id }),
|
||||
);
|
||||
expect(companiesRepo.create).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: ETHIOPIAN.name }),
|
||||
);
|
||||
expect(profilesRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
onboardingCompleted: false,
|
||||
onboardingStep: "company",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("completes an existing transit-agent-only draft on the pick", async () => {
|
||||
const { service, companiesRepo, profilesRepo } = makeService({
|
||||
existing: [
|
||||
{ id: "p-ta", type: ProfileType.transitAgent, status: ProfileStatus.Pending },
|
||||
],
|
||||
});
|
||||
|
||||
await start(service, [ProfileType.transitAgent], ETHIOPIAN.id);
|
||||
|
||||
expect(companiesRepo.update).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
expect.objectContaining({
|
||||
transitAgentId: ETHIOPIAN.id,
|
||||
name: ETHIOPIAN.name,
|
||||
}),
|
||||
);
|
||||
expect(profilesRepo.update).toHaveBeenCalledWith(
|
||||
"external-1",
|
||||
expect.objectContaining({
|
||||
onboardingCompleted: true,
|
||||
onboardingStep: "transit-agent",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("reopens onboarding at the company step when a licensed role is added", async () => {
|
||||
const { service, profilesRepo } = makeService({
|
||||
existing: [
|
||||
{ id: "p-ta", type: ProfileType.transitAgent, status: ProfileStatus.Active },
|
||||
],
|
||||
linkedAgentId: ETHIOPIAN.id,
|
||||
onboardingCompleted: true,
|
||||
});
|
||||
|
||||
await start(service, [ProfileType.transitAgent, ProfileType.importer]);
|
||||
|
||||
expect(profilesRepo.update).toHaveBeenCalledWith(
|
||||
"external-1",
|
||||
expect.objectContaining({
|
||||
onboardingCompleted: false,
|
||||
onboardingStep: "company",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ const SEQUENCE_MAP: Record<ProfileType, string> = {
|
||||
[ProfileType.freightForwarder]: "seq_company_profile_ffe",
|
||||
[ProfileType.djFreightForwarder]: "seq_company_profile_fwj",
|
||||
[ProfileType.transporter]: "seq_company_profile_tr",
|
||||
[ProfileType.transitAgent]: "seq_company_profile_ta",
|
||||
};
|
||||
|
||||
const PREFIX_MAP: Record<ProfileType, string> = {
|
||||
@@ -18,6 +19,7 @@ const PREFIX_MAP: Record<ProfileType, string> = {
|
||||
[ProfileType.freightForwarder]: "FF",
|
||||
[ProfileType.djFreightForwarder]: "FWJ",
|
||||
[ProfileType.transporter]: "TR",
|
||||
[ProfileType.transitAgent]: "TA",
|
||||
};
|
||||
|
||||
const SERIES_LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
|
||||
@@ -86,16 +86,6 @@ export class TransitAgentInfoResponseDto {
|
||||
@ApiProperty()
|
||||
isActive: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Start of the agent's validity window (yyyy-MM-dd)",
|
||||
})
|
||||
validFrom: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: "End of the agent's validity window (yyyy-MM-dd)",
|
||||
})
|
||||
validTo: string;
|
||||
|
||||
/** Always null — see {@link ShippingLineInfoResponseDto.company}. */
|
||||
@ApiProperty({ nullable: true })
|
||||
company: null = null;
|
||||
@@ -112,8 +102,6 @@ export class TransitAgentInfoResponseDto {
|
||||
this.email = entity.email ?? null;
|
||||
this.phoneNumber = entity.phoneNumber ?? null;
|
||||
this.isActive = entity.isActive;
|
||||
this.validFrom = entity.validFrom;
|
||||
this.validTo = entity.validTo;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
@@ -26,6 +27,14 @@ export class AddCompanyProfileInputDto {
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
licenceNumber?: string;
|
||||
|
||||
/**
|
||||
* Which Ethiopian transit agent the company is. Required for the transit
|
||||
* agent and freight forwarder roles unless the company is already linked.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
transitAgentId?: string;
|
||||
}
|
||||
|
||||
export class AddCompanyProfilesDto {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { IsEnum, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
|
||||
import { ProfileType } from '../entities/company-profile.entity';
|
||||
|
||||
export class CreateCompanyProfileDto {
|
||||
@@ -19,4 +19,12 @@ export class CreateCompanyProfileDto {
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
licenceNumber?: string;
|
||||
|
||||
/**
|
||||
* Which Ethiopian transit agent the company is. Required for the transit
|
||||
* agent and freight forwarder roles unless the company is already linked.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
transitAgentId?: string;
|
||||
}
|
||||
|
||||
@@ -87,6 +87,13 @@ export class ResponseCompanyDto {
|
||||
email?: string | null;
|
||||
website?: string | null;
|
||||
attributes?: Record<string, any> | null;
|
||||
/**
|
||||
* The transit-agent roster entry a freight forwarder registered itself as
|
||||
* (`Company.transitAgentId`). The name rides along when the relation was
|
||||
* loaded, so the portal and backoffice can show it without a second lookup.
|
||||
*/
|
||||
transitAgentId: string | null;
|
||||
transitAgent: { id: string; name: string } | null;
|
||||
profiles?: ResponseExternalProfileDto[];
|
||||
companyProfiles?: ResponseCompanyProfileDto[];
|
||||
/**
|
||||
@@ -144,6 +151,10 @@ export class ResponseCompanyDto {
|
||||
this.email = company.email;
|
||||
this.website = company.website;
|
||||
this.attributes = company.attributes;
|
||||
this.transitAgentId = company.transitAgentId ?? null;
|
||||
this.transitAgent = company.transitAgent
|
||||
? { id: company.transitAgent.id, name: company.transitAgent.name }
|
||||
: null;
|
||||
this.profiles = company.profiles?.map((p) => new ResponseExternalProfileDto(p));
|
||||
this.companyProfiles = company.companyProfiles?.map(
|
||||
(p) => new ResponseCompanyProfileDto(p),
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsUUID,
|
||||
} from "class-validator";
|
||||
import { CompanyNationality, CompanyType } from "../entities/company.entity";
|
||||
import { ProfileType } from "../entities/company-profile.entity";
|
||||
@@ -42,4 +43,14 @@ export class StartOnboardingDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
investorLicence?: boolean;
|
||||
|
||||
/**
|
||||
* Which Ethiopian transit agent this company is. Required whenever `roles`
|
||||
* includes the freight forwarder — the two are the same business — and
|
||||
* refused for any other agent (foreign, suspended, or unknown). Ignored when
|
||||
* the forwarder role is not selected.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
transitAgentId?: string;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,14 @@ export enum ProfileType {
|
||||
freightForwarder = "freight_forwarder",
|
||||
djFreightForwarder = "dj_freight_forwarder",
|
||||
transporter = "transporter",
|
||||
/**
|
||||
* An Ethiopian transit agent registering on the portal as itself — the
|
||||
* business customers pick to clear customs on a booking. Holds no trade
|
||||
* licence or eTrade business here: its identity is the roster entry
|
||||
* (`Company.transitAgentId`), and a company with ONLY this role finishes
|
||||
* onboarding right after picking it.
|
||||
*/
|
||||
transitAgent = "transit_agent",
|
||||
}
|
||||
|
||||
export enum ProfileStatus {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity, Index, OneToMany } from "typeorm";
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from "typeorm";
|
||||
import { TransitAgent } from "../../transit-agents/entities/transit-agent.entity";
|
||||
import { ExternalProfile } from "./external-profile.entity";
|
||||
import { CompanyProfile } from "./company-profile.entity";
|
||||
|
||||
@@ -235,6 +236,22 @@ export class Company extends BaseEntity {
|
||||
@Column({ name: "etrade_phone", type: "varchar", length: 20, nullable: true })
|
||||
etradePhone?: string | null;
|
||||
|
||||
/**
|
||||
* The transit-agent roster entry this company IS, when it holds the
|
||||
* freight-forwarder role. An Ethiopian transit agent and a freight forwarder
|
||||
* are one business seen from two sides — the roster GL assigns officers
|
||||
* from, and the customer signing contracts on other companies' behalf — and
|
||||
* this is what ties the two rows together. Required at onboarding for a
|
||||
* forwarder; null for every importer/exporter and for forwarders linked
|
||||
* before the column existed.
|
||||
*/
|
||||
@Column({ name: "transit_agent_id", type: "uuid", nullable: true })
|
||||
transitAgentId?: string | null;
|
||||
|
||||
@ManyToOne(() => TransitAgent, { nullable: true })
|
||||
@JoinColumn({ name: "transit_agent_id" })
|
||||
transitAgent?: TransitAgent | null;
|
||||
|
||||
@OneToMany(() => ExternalProfile, (profile) => profile.company)
|
||||
profiles?: ExternalProfile[];
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ describe('ContractBookingService — quantity-cap completion', () => {
|
||||
{} as never, // bookingBatchService
|
||||
{} as never, // bookingTransitionService
|
||||
{} as never, // consolidationApprovalService
|
||||
{} as never, // transitAgentsRepository
|
||||
{} as never, // transitAssignmentsService
|
||||
);
|
||||
return { service, contractsRepository };
|
||||
}
|
||||
@@ -158,7 +160,9 @@ describe('ContractBookingService — quantity-cap completion', () => {
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never, // consolidationApprovalService
|
||||
);
|
||||
{} as never, // transitAgentsRepository
|
||||
{} as never, // transitAssignmentsService
|
||||
);
|
||||
return { service, contractsRepository };
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,8 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
|
||||
{} as never, // bookingBatchService
|
||||
{} as never, // bookingTransitionService
|
||||
{} as never, // consolidationApprovalService
|
||||
{} as never, // transitAgentsRepository
|
||||
{} as never, // transitAssignmentsService
|
||||
);
|
||||
return {
|
||||
service,
|
||||
|
||||
@@ -27,6 +27,8 @@ describe('ContractBookingService — customs booking gate', () => {
|
||||
{} as never, // bookingBatchService
|
||||
{} as never, // bookingTransitionService
|
||||
{} as never, // consolidationApprovalService
|
||||
{} as never, // transitAgentsRepository
|
||||
{} as never, // transitAssignmentsService
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,8 @@ describe('ContractBookingService — manual odd-20ft consolidation', () => {
|
||||
// The pairing is parked for approval rather than going straight to
|
||||
// Operations; the gate itself is covered by its own spec.
|
||||
{ requestApproval: jest.fn().mockResolvedValue({ id: 'ap-1' }) } as never,
|
||||
{} as never, // transitAgentsRepository
|
||||
{} as never, // transitAssignmentsService
|
||||
);
|
||||
return { service, bookingsRepository, dataSource };
|
||||
}
|
||||
|
||||
@@ -60,6 +60,8 @@ describe('ContractBookingService — changes-requested resubmit restating cargo'
|
||||
{} as never, // bookingBatchService
|
||||
{} as never, // bookingTransitionService
|
||||
{} as never, // consolidationApprovalService
|
||||
{} as never, // transitAgentsRepository
|
||||
{} as never, // transitAssignmentsService
|
||||
);
|
||||
return { service, bookingsRepository, invoiceService };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -215,9 +215,58 @@ export class ContractPricingService {
|
||||
|
||||
// Conditional surcharges — shown only when the contract toggles them on AND
|
||||
// the rate has a non-zero value (a 0 rate means "no surcharge").
|
||||
if (contract.isHazardous) {
|
||||
if (contract.isHazardous && contract.freightType === 'CONTAINER') {
|
||||
// Container hazard is sold per direction + route + container type, like
|
||||
// the empty-return service — one display line per contract size that has
|
||||
// a configured rate (size-specific wins over the lane's catch-all). A
|
||||
// size with no rate shows nothing here and hard-blocks at booking time.
|
||||
// ponytail: bookings bill the live route rate, not a frozen snapshot.
|
||||
const onLeg = route
|
||||
? liveRates.filter(
|
||||
(r) =>
|
||||
r.rateType === 'HAZARD_SURCHARGE' &&
|
||||
r.rateUnit === 'PER_CONTAINER' &&
|
||||
r.currency === 'USD' &&
|
||||
r.tradeDirection === contract.tradeDirection &&
|
||||
r.originYardId === route.originYardId &&
|
||||
r.destinationYardId === route.destinationYardId,
|
||||
)
|
||||
: [];
|
||||
if (onLeg.length > 0) {
|
||||
const sizes = (contract.cargoScope ?? [])
|
||||
.map((c) => c.containerSize)
|
||||
.filter((s): s is string => !!s);
|
||||
const { items: containerTypes } = await this.containerTypesService.findAll({
|
||||
isActive: true,
|
||||
pageSize: 100,
|
||||
});
|
||||
for (const size of sizes) {
|
||||
const sizeFt = size === '40ft' ? 40 : 20;
|
||||
const matchedIds = new Set(
|
||||
containerTypes.filter((ct) => ct.sizeFt === sizeFt).map((ct) => ct.id),
|
||||
);
|
||||
const rate =
|
||||
onLeg.find((r) => r.containerTypeId && matchedIds.has(r.containerTypeId)) ??
|
||||
onLeg.find((r) => !r.containerTypeId);
|
||||
if (!rate || Number(rate.rateValue) <= 0) continue;
|
||||
lineItems.push({
|
||||
code: 'HAZARD_SURCHARGE',
|
||||
label: `Hazardous surcharge (${size})`,
|
||||
unit: toContractUnit(rate.rateUnit),
|
||||
unitPrice: convert(Number(rate.rateValue)),
|
||||
containerSize: size,
|
||||
conditionalOn: 'is_hazardous',
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (contract.isHazardous) {
|
||||
// Bulk hazard is the global per-ton rate; the per-container rows belong
|
||||
// to container lanes and must not price a bulk contract.
|
||||
const hazard = liveRates.find(
|
||||
(r) => r.rateType === 'HAZARD_SURCHARGE' && r.currency === 'USD',
|
||||
(r) =>
|
||||
r.rateType === 'HAZARD_SURCHARGE' &&
|
||||
r.rateUnit !== 'PER_CONTAINER' &&
|
||||
r.currency === 'USD',
|
||||
);
|
||||
if (hazard && Number(hazard.rateValue) > 0) {
|
||||
lineItems.push({
|
||||
|
||||
@@ -256,6 +256,17 @@ export class CreateBookingUnderContractDto {
|
||||
@MaxLength(50)
|
||||
customsClearingAgentPhone?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description:
|
||||
'Instead of typing a clearing agent: a registered Ethiopian transit agent (freight ' +
|
||||
'forwarder). The booking is assigned to it and the forwarder is notified; the typed ' +
|
||||
'agent fields are ignored when this is set.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
transitAgentId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -130,6 +130,7 @@ export const customersDataset: ExportDataset = {
|
||||
{ value: 'exporter', label: 'Exporter' },
|
||||
{ value: 'freight_forwarder', label: 'Freight forwarder' },
|
||||
{ value: 'dj_freight_forwarder', label: 'DJ freight forwarder' },
|
||||
{ value: 'transit_agent', label: 'Transit agent' },
|
||||
{ value: 'transporter', label: 'Transporter' },
|
||||
] },
|
||||
// The list's Status filter folds the review queues in, and sends these two
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
RATE_UNITS,
|
||||
} from '../entities/rate.entity';
|
||||
|
||||
// DOMESTIC is accepted only for FUEL rates (an intercity fuel lane).
|
||||
// DOMESTIC is accepted only for FUEL and per-container HAZARDOUS rates (an intercity lane).
|
||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH', 'DOMESTIC'] as const;
|
||||
// ETB is accepted only for last-mile rates; the service forces USD elsewhere.
|
||||
const CURRENCIES = ['USD', 'ETB'] as const;
|
||||
@@ -26,7 +26,10 @@ export class CreateRateDto {
|
||||
@IsIn([...RATE_TRIGGERS])
|
||||
trigger!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'FK to container_types.id — set for container/intercity-container rates' })
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'FK to container_types.id — set for container/intercity-container rates, and optionally on the per-container HAZARDOUS surcharge (20ft / 40ft price differently; omitted = the lane catch-all)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
containerTypeId?: string;
|
||||
@@ -61,7 +64,7 @@ export class CreateRateDto {
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'FK to yards.id — origin of the leg this rate prices. Required for base freight (bulk/container/intercity), rejected for surcharges and first/last mile.',
|
||||
'FK to yards.id — origin of the leg this rate prices. Required for base freight (bulk/container/intercity) and the lane-sold surcharges (customs clearance, empty return, fuel, per-container HAZARDOUS); rejected for every other surcharge and first/last mile.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
@@ -69,7 +72,7 @@ export class CreateRateDto {
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'FK to yards.id — destination of the leg this rate prices. Required for base freight (bulk/container/intercity), rejected for surcharges and first/last mile.',
|
||||
'FK to yards.id — destination of the leg this rate prices. Required for base freight (bulk/container/intercity) and the lane-sold surcharges (customs clearance, empty return, fuel, per-container HAZARDOUS); rejected for every other surcharge and first/last mile.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
|
||||
@@ -88,6 +88,10 @@ export type RateAppliesTo = typeof RATE_APPLIES_TO[number];
|
||||
*/
|
||||
export const RATE_TRIGGERS = [
|
||||
'ALWAYS',
|
||||
// Hazardous cargo. Two shapes under one trigger, told apart by the unit:
|
||||
// PER_CONTAINER is the container surcharge, sold per direction + lane and
|
||||
// optionally per box size (20ft / 40ft) like the empty-return service;
|
||||
// PER_TON is the bulk surcharge, direction-agnostic and unscoped.
|
||||
'HAZARDOUS',
|
||||
'OVERWEIGHT',
|
||||
'REEFER',
|
||||
@@ -121,6 +125,16 @@ export type RateTrigger = typeof RATE_TRIGGERS[number];
|
||||
export const isCustomsClearanceTrigger = (trigger: string): boolean =>
|
||||
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'ETHIOPIAN_CUSTOMS_CLEARANCE';
|
||||
|
||||
/**
|
||||
* The container hazardous-cargo surcharge: HAZARDOUS billed per container.
|
||||
* It is sold per trade direction + origin → destination lane, optionally
|
||||
* narrowed to one container type (20ft / 40ft), and priced by the
|
||||
* route-matched block in RuleEngineService — never by the additive loop.
|
||||
* The per-ton (bulk) hazard rate keeps the old global, unscoped shape.
|
||||
*/
|
||||
export const isContainerHazardRate = (trigger: string, rateUnit: string): boolean =>
|
||||
trigger === 'HAZARDOUS' && rateUnit === 'PER_CONTAINER';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'rates' })
|
||||
@Index(['rateType'])
|
||||
@Index(['status'])
|
||||
@@ -159,8 +173,10 @@ export class Rate extends BaseEntity {
|
||||
/**
|
||||
* The leg this rate prices. Base freight (trigger = ALWAYS) is quoted per
|
||||
* route — "container import, Djibouti → Dire Dawa" — so both yards are
|
||||
* required for BULK/CONTAINER/INTERCITY and NULL for everything else. The
|
||||
* `CK_rates_yard_scope` DB constraint enforces both halves of that.
|
||||
* required for BULK/CONTAINER/EMPTY_CONTAINER/INTERCITY, for the lane-sold
|
||||
* surcharges (customs clearance, empty return, fuel, container hazard) and
|
||||
* NULL for everything else. The `CK_rates_yard_scope` DB constraint enforces
|
||||
* both halves of that.
|
||||
*/
|
||||
@Column({ name: 'origin_yard_id', type: 'uuid', nullable: true })
|
||||
originYardId?: string | null;
|
||||
|
||||
@@ -3,12 +3,14 @@ import type { BookingEvaluationInput } from './rule-engine.service';
|
||||
import type { Rate } from './entities/rate.entity';
|
||||
|
||||
describe('RuleEngineService — requested service without a configured surcharge rate', () => {
|
||||
// The bulk (per-ton) hazard rate — global, no lane. These bookings carry no
|
||||
// containers, so they are bulk-shaped and price off this one.
|
||||
const hazardRate: Rate = {
|
||||
id: 'rate-hazard',
|
||||
rateType: 'HAZARD_SURCHARGE',
|
||||
trigger: 'HAZARDOUS',
|
||||
rateValue: 50,
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
rateUnit: 'PER_TON',
|
||||
currency: 'USD',
|
||||
status: 'LIVE',
|
||||
containerTypeId: null,
|
||||
@@ -650,6 +652,7 @@ describe('RuleEngineService — shipping-line rates override the standard ones',
|
||||
shippingLineCompanyId: LINE,
|
||||
} as Rate;
|
||||
|
||||
// Container hazard is sold per direction + lane (+ optional box size).
|
||||
const standardHazard: Rate = {
|
||||
id: 'rate-hazard-standard',
|
||||
rateType: 'HAZARD_SURCHARGE',
|
||||
@@ -661,6 +664,9 @@ describe('RuleEngineService — shipping-line rates override the standard ones',
|
||||
containerTypeId: null,
|
||||
cargoTypeId: null,
|
||||
shippingLineCompanyId: null,
|
||||
tradeDirection: 'IMPORT',
|
||||
originYardId: 'yard-dj',
|
||||
destinationYardId: 'yard-adama',
|
||||
} as Rate;
|
||||
|
||||
const lineHazard: Rate = {
|
||||
@@ -764,3 +770,161 @@ describe('RuleEngineService — shipping-line rates override the standard ones',
|
||||
expect(result.hardBlocked[0]).toContain('hazardous');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RuleEngineService — container hazard surcharge per lane and box size', () => {
|
||||
const lane = {
|
||||
tradeDirection: 'IMPORT',
|
||||
originYardId: 'yard-dj',
|
||||
destinationYardId: 'yard-adama',
|
||||
};
|
||||
const catchAll: Rate = {
|
||||
id: 'rate-hazard-lane',
|
||||
rateType: 'HAZARD_SURCHARGE',
|
||||
trigger: 'HAZARDOUS',
|
||||
rateValue: 50,
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
currency: 'USD',
|
||||
status: 'LIVE',
|
||||
containerTypeId: null,
|
||||
cargoTypeId: null,
|
||||
...lane,
|
||||
} as Rate;
|
||||
const forty: Rate = {
|
||||
...catchAll,
|
||||
id: 'rate-hazard-lane-40',
|
||||
rateValue: 90,
|
||||
containerTypeId: 'ct-40',
|
||||
} as Rate;
|
||||
const bulkHazard: Rate = {
|
||||
...catchAll,
|
||||
id: 'rate-hazard-bulk',
|
||||
rateValue: 3,
|
||||
rateUnit: 'PER_TON',
|
||||
tradeDirection: null,
|
||||
originYardId: null,
|
||||
destinationYardId: null,
|
||||
} as Rate;
|
||||
|
||||
const buildService = (rates: Rate[]) =>
|
||||
new RuleEngineService(
|
||||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||
{ findActiveByContainerTypeId: jest.fn().mockResolvedValue([]) } as never,
|
||||
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
|
||||
{ findLiveRates: jest.fn().mockResolvedValue(rates) } as never,
|
||||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
const booking = (overrides: Partial<BookingEvaluationInput> = {}): BookingEvaluationInput => ({
|
||||
serviceTypeId: 'svc-1',
|
||||
paymentCurrency: 'USD',
|
||||
isHazardous: false,
|
||||
totalWagons: 2,
|
||||
...lane,
|
||||
containers: [
|
||||
{ containerTypeId: 'ct-20', quantity: 3, vgmPerUnitTons: 10, totalVgmTons: 30, hazardousQuantity: 2 },
|
||||
{ containerTypeId: 'ct-40', quantity: 1, vgmPerUnitTons: 10, totalVgmTons: 10, hazardousQuantity: 1 },
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const hazardOf = (result: { appliedModifiers: Array<{ surchargeCode: string }> }) =>
|
||||
result.appliedModifiers.filter((m) => m.surchargeCode === 'HAZARD_SURCHARGE');
|
||||
|
||||
it('bills each line off the lane rate for its own box size, catch-all otherwise', async () => {
|
||||
const result = await buildService([catchAll, forty]).evaluate(booking());
|
||||
expect(result.hardBlocked).toHaveLength(0);
|
||||
const lines = hazardOf(result);
|
||||
expect(lines).toHaveLength(2);
|
||||
// 2 hazardous 20ft on the lane catch-all, 1 hazardous 40ft on the 40ft rate.
|
||||
expect(lines).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ rateId: catchAll.id, triggerValue: 2, calculatedAmount: 100, unitPriceUsd: 50, billingUnit: 'PER_CONTAINER' }),
|
||||
expect.objectContaining({ rateId: forty.id, triggerValue: 1, calculatedAmount: 90, unitPriceUsd: 90 }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('bills the opted-in count, not the whole line', async () => {
|
||||
const result = await buildService([catchAll]).evaluate(
|
||||
booking({
|
||||
containers: [
|
||||
{ containerTypeId: 'ct-20', quantity: 10, vgmPerUnitTons: 10, totalVgmTons: 100, hazardousQuantity: 4 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(hazardOf(result)).toEqual([
|
||||
expect.objectContaining({ triggerValue: 4, calculatedAmount: 200 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to every container when only the legacy booking-level flag is set', async () => {
|
||||
const result = await buildService([catchAll]).evaluate(
|
||||
booking({
|
||||
isHazardous: true,
|
||||
containers: [
|
||||
{ containerTypeId: 'ct-20', quantity: 3, vgmPerUnitTons: 10, totalVgmTons: 30 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(hazardOf(result)).toEqual([
|
||||
expect.objectContaining({ triggerValue: 3, calculatedAmount: 150 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('never bills the per-container rate a second time through the additive loop', async () => {
|
||||
const result = await buildService([catchAll]).evaluate(
|
||||
booking({
|
||||
isHazardous: true,
|
||||
containers: [
|
||||
{ containerTypeId: 'ct-20', quantity: 2, vgmPerUnitTons: 10, totalVgmTons: 20 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(hazardOf(result)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('hard-blocks when the lane has no per-container hazard rate', async () => {
|
||||
const result = await buildService([catchAll]).evaluate(
|
||||
booking({ destinationYardId: 'yard-elsewhere' }),
|
||||
);
|
||||
expect(hazardOf(result)).toHaveLength(0);
|
||||
expect(result.hardBlocked).toHaveLength(1);
|
||||
expect(result.hardBlocked[0]).toContain('hazardous');
|
||||
expect(result.hardBlocked[0]).toContain('route');
|
||||
});
|
||||
|
||||
it('hard-blocks when the rate is for the other direction', async () => {
|
||||
const result = await buildService([catchAll]).evaluate(
|
||||
booking({ tradeDirection: 'EXPORT', originYardId: 'yard-adama', destinationYardId: 'yard-dj' }),
|
||||
);
|
||||
expect(result.hardBlocked).toHaveLength(1);
|
||||
expect(result.hardBlocked[0]).toContain('hazardous');
|
||||
});
|
||||
|
||||
it('does not let the bulk per-ton rate stand in for a container booking', async () => {
|
||||
const result = await buildService([bulkHazard]).evaluate(booking());
|
||||
expect(hazardOf(result)).toHaveLength(0);
|
||||
expect(result.hardBlocked).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('bills a bulk booking off the global per-ton rate, untouched by the lane rule', async () => {
|
||||
const result = await buildService([bulkHazard, catchAll]).evaluate(
|
||||
booking({ isHazardous: true, containers: [], bulkTons: 40, totalWagons: 1 }),
|
||||
);
|
||||
expect(result.hardBlocked).toHaveLength(0);
|
||||
expect(hazardOf(result)).toEqual([
|
||||
expect.objectContaining({ rateId: bulkHazard.id, triggerValue: 40, calculatedAmount: 120 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('hard-blocks a hazardous bulk booking when only the container rate exists', async () => {
|
||||
const result = await buildService([catchAll]).evaluate(
|
||||
booking({ isHazardous: true, containers: [], bulkTons: 40, totalWagons: 1 }),
|
||||
);
|
||||
expect(result.hardBlocked).toHaveLength(1);
|
||||
expect(result.hardBlocked[0]).toContain('hazardous');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Inject, Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.entity';
|
||||
import { Rate, RateTrigger } from './entities/rate.entity';
|
||||
import { Rate, RateTrigger, isContainerHazardRate } from './entities/rate.entity';
|
||||
import { isBulkQuantityUnit } from './entities/rate-unit.util';
|
||||
import {
|
||||
ICargoTypesRepository,
|
||||
@@ -366,9 +366,11 @@ export class RuleEngineService {
|
||||
// hard block — pricing would otherwise ship the service for free. System-
|
||||
// derived charges (consolidation, overweight, shipping line, lashing) stay
|
||||
// exempt: the customer never opted into those, so they must not block.
|
||||
const isContainerBooking = input.containers.length > 0;
|
||||
const requestedServices: Array<{
|
||||
trigger: RateTrigger;
|
||||
wanted: boolean;
|
||||
configured: boolean;
|
||||
label: string;
|
||||
}> = [
|
||||
{
|
||||
@@ -376,6 +378,15 @@ export class RuleEngineService {
|
||||
wanted:
|
||||
truthy(input.isHazardous) ||
|
||||
input.containers.some((c) => Number(c.hazardousQuantity ?? 0) > 0),
|
||||
// Container hazard is sold per lane + box size and checked by the
|
||||
// route-matched block below (which blocks per missing lane/type rate);
|
||||
// bulk hazard is the global per-ton rate this loop can vouch for.
|
||||
configured: isContainerBooking
|
||||
? true
|
||||
: surchargeRates.some(
|
||||
(r) =>
|
||||
r.trigger === 'HAZARDOUS' && !isContainerHazardRate(r.trigger, r.rateUnit),
|
||||
),
|
||||
label: 'hazardous cargo',
|
||||
},
|
||||
{
|
||||
@@ -383,11 +394,12 @@ export class RuleEngineService {
|
||||
wanted:
|
||||
hasReefer ||
|
||||
input.containers.some((c) => Number(c.reeferQuantity ?? 0) > 0),
|
||||
configured: surchargeRates.some((r) => r.trigger === 'REEFER'),
|
||||
label: 'refrigerated (reefer) cargo',
|
||||
},
|
||||
];
|
||||
for (const svc of requestedServices) {
|
||||
if (svc.wanted && !surchargeRates.some((r) => r.trigger === svc.trigger)) {
|
||||
if (svc.wanted && !svc.configured) {
|
||||
hardBlocked.push(
|
||||
`No ${svc.label} surcharge rate is configured — the booking cannot ` +
|
||||
`be priced with this service. Remove the ${svc.label} option or ` +
|
||||
@@ -411,6 +423,10 @@ export class RuleEngineService {
|
||||
// Fuel is sold per lane + cargo type — billed by the route-matched
|
||||
// block below, never by this route-agnostic loop.
|
||||
if (rate.trigger === 'FUEL') continue;
|
||||
// The container hazard surcharge is sold per lane + box size like the
|
||||
// empty-return service — billed by its own route-matched block below.
|
||||
// The per-ton bulk hazard rate stays additive here.
|
||||
if (isContainerHazardRate(rate.trigger, rate.rateUnit)) continue;
|
||||
const triggered = this.matchesTrigger(rate.trigger, {
|
||||
isHazardous: input.isHazardous,
|
||||
hasReefer,
|
||||
@@ -521,6 +537,10 @@ export class RuleEngineService {
|
||||
appliedModifiers.push(...withReturn.modifiers);
|
||||
hardBlocked.push(...withReturn.blocked);
|
||||
|
||||
const containerHazard = this.containerHazardCharges(input, liveRates);
|
||||
appliedModifiers.push(...containerHazard.modifiers);
|
||||
hardBlocked.push(...containerHazard.blocked);
|
||||
|
||||
if (hasLashing) {
|
||||
appliedModifiers.push(...this.lashingCharges(input, liveRates));
|
||||
}
|
||||
@@ -730,6 +750,80 @@ export class RuleEngineService {
|
||||
return { modifiers, blocked: [...new Set(blocked)] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Container hazardous-cargo surcharge — sold per direction + route, optionally
|
||||
* per container type, exactly like the empty-return service. Each container
|
||||
* line that opted in (hazardousQuantity, or every container when only the
|
||||
* legacy booking-level flag is set) bills the route-matched PER_CONTAINER
|
||||
* HAZARDOUS rate for its own container type, falling back to the lane's
|
||||
* catch-all (no type) rate; a line with no matching rate hard-blocks the
|
||||
* booking instead of shipping the service for free. Bulk bookings never
|
||||
* reach here — their per-ton hazard rate is billed by the additive loop.
|
||||
* ponytail: bills the LIVE route rate, not a frozen contract snapshot — one
|
||||
* HAZARD_SURCHARGE snapshot code can't hold per-size route prices.
|
||||
*/
|
||||
private containerHazardCharges(
|
||||
input: BookingEvaluationInput,
|
||||
liveRates: Rate[],
|
||||
): { modifiers: AppliedCargoModifier[]; blocked: string[] } {
|
||||
const modifiers: AppliedCargoModifier[] = [];
|
||||
const blocked: string[] = [];
|
||||
if (input.containers.length === 0) return { modifiers, blocked };
|
||||
const bookingLevel = truthy(input.isHazardous);
|
||||
const wanted =
|
||||
bookingLevel ||
|
||||
input.containers.some((c) => Number(c.hazardousQuantity ?? 0) > 0);
|
||||
if (!wanted) return { modifiers, blocked };
|
||||
|
||||
const onLeg = liveRates.filter(
|
||||
(r) =>
|
||||
isContainerHazardRate(r.trigger, r.rateUnit) &&
|
||||
r.currency === 'USD' &&
|
||||
r.tradeDirection === input.tradeDirection &&
|
||||
r.originYardId === input.originYardId &&
|
||||
r.destinationYardId === input.destinationYardId,
|
||||
);
|
||||
|
||||
for (const container of input.containers) {
|
||||
const qty =
|
||||
Number(container.hazardousQuantity ?? 0) > 0
|
||||
? Number(container.hazardousQuantity)
|
||||
: bookingLevel
|
||||
? Number(container.quantity || 0)
|
||||
: 0;
|
||||
if (!(qty > 0)) continue;
|
||||
|
||||
// The rate scoped to this box size wins over the lane's catch-all.
|
||||
const rate =
|
||||
onLeg.find((r) => r.containerTypeId === container.containerTypeId) ??
|
||||
onLeg.find((r) => !r.containerTypeId);
|
||||
if (!rate) {
|
||||
blocked.push(
|
||||
'No hazardous cargo surcharge rate is configured for this container ' +
|
||||
'type on this route — remove the hazardous option or ask EDR to ' +
|
||||
'configure its per-container rate for this origin → destination.',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const rateValue = Number(rate.rateValue);
|
||||
const amount = qty * rateValue;
|
||||
if (!(amount > 0)) continue;
|
||||
modifiers.push({
|
||||
rateId: rate.id,
|
||||
surchargeCode: this.surchargeCode(rate),
|
||||
triggerValue: qty,
|
||||
calculatedAmount: amount,
|
||||
currency: rate.currency,
|
||||
unitPriceUsd: rateValue,
|
||||
billingUnit: rate.rateUnit,
|
||||
});
|
||||
}
|
||||
|
||||
// Same block deduplicated — several lines missing the rate is one problem.
|
||||
return { modifiers, blocked: [...new Set(blocked)] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cargo securing / lashing — BULK only, sold per trade direction, optionally
|
||||
* narrowed to one leaf commodity (the commodity-scoped rate wins over the
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
import { BadRequestException, ConflictException } from '@nestjs/common';
|
||||
|
||||
import { RatesService } from './rates.service';
|
||||
import type { Rate } from '../entities/rate.entity';
|
||||
@@ -101,8 +101,9 @@ describe('RatesService — one rate per pattern', () => {
|
||||
|
||||
/**
|
||||
* Additive surcharges are billed per matching rate, each by its own unit, so
|
||||
* hazard is legitimately per-container for boxes AND per-ton for bulk. The
|
||||
* unit stays part of their identity or the second one could never be created.
|
||||
* the bulk (per-ton) hazard rate coexists with the lane-sold container one.
|
||||
* The unit stays part of their identity or the second one could never be
|
||||
* created.
|
||||
*/
|
||||
it('keeps the unit in the key for an additive surcharge', async () => {
|
||||
await service.create(
|
||||
@@ -110,17 +111,44 @@ describe('RatesService — one rate per pattern', () => {
|
||||
appliesTo: 'OTHER',
|
||||
trigger: 'HAZARDOUS',
|
||||
rateValue: 300,
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
rateUnit: 'PER_TON',
|
||||
} as never,
|
||||
'staff-1',
|
||||
);
|
||||
|
||||
expect(repository.findByPattern.mock.calls[0][0]).toMatchObject({
|
||||
rateType: 'HAZARD_SURCHARGE',
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
rateUnit: 'PER_TON',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the per-ton hazard rate global — direction and lane are dropped', async () => {
|
||||
await service.create(
|
||||
{
|
||||
appliesTo: 'OTHER',
|
||||
trigger: 'HAZARDOUS',
|
||||
tradeDirection: 'IMPORT',
|
||||
originYardId: DJ,
|
||||
destinationYardId: ET,
|
||||
containerTypeId: CT20,
|
||||
rateValue: 5,
|
||||
rateUnit: 'PER_TON',
|
||||
} as never,
|
||||
'staff-1',
|
||||
);
|
||||
|
||||
expect(repository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
rateType: 'HAZARD_SURCHARGE',
|
||||
rateUnit: 'PER_TON',
|
||||
tradeDirection: null,
|
||||
originYardId: null,
|
||||
destinationYardId: null,
|
||||
containerTypeId: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('treats lashing as singly resolved — one unit per direction', async () => {
|
||||
await service.create(
|
||||
{
|
||||
@@ -138,3 +166,166 @@ describe('RatesService — one rate per pattern', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The container hazard surcharge (HAZARDOUS per container) is sold per
|
||||
* direction + lane, optionally per box size — the same shape as the
|
||||
* empty-return service. Pricing resolves exactly one rate per lane + size, so
|
||||
* the unit leaves the identity and the lane joins it.
|
||||
*/
|
||||
describe('RatesService — per-container hazard is sold per lane', () => {
|
||||
const DJ = '11111111-1111-4000-8000-000000000001';
|
||||
const ET = '11111111-1111-4000-8000-000000000002';
|
||||
const ET2 = '11111111-1111-4000-8000-000000000004';
|
||||
const CT20 = '11111111-1111-4000-8000-000000000003';
|
||||
|
||||
let repository: { findByPattern: jest.Mock; create: jest.Mock };
|
||||
let service: RatesService;
|
||||
|
||||
beforeEach(() => {
|
||||
repository = {
|
||||
findByPattern: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn(async (r) => ({ id: 'rate-new', ...r })),
|
||||
};
|
||||
service = new RatesService(
|
||||
repository as never,
|
||||
{
|
||||
findById: jest.fn(async (id: string) => ({
|
||||
id,
|
||||
country: id === DJ ? 'Djibouti' : 'Ethiopia',
|
||||
label: id === DJ ? 'Doraleh' : id === ET ? 'Gelan' : 'Dire Dawa',
|
||||
})),
|
||||
} as never,
|
||||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||
{ findById: jest.fn() } as never,
|
||||
);
|
||||
});
|
||||
|
||||
const containerHazard = {
|
||||
appliesTo: 'OTHER',
|
||||
trigger: 'HAZARDOUS',
|
||||
rateValue: 300,
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
};
|
||||
|
||||
it('requires a trade direction', async () => {
|
||||
await expect(
|
||||
service.create(
|
||||
{ ...containerHazard, originYardId: DJ, destinationYardId: ET } as never,
|
||||
'staff-1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(repository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requires both yards of the lane', async () => {
|
||||
await expect(
|
||||
service.create(
|
||||
{ ...containerHazard, tradeDirection: 'IMPORT' } as never,
|
||||
'staff-1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(repository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a lane that contradicts the direction', async () => {
|
||||
// Export runs Ethiopia → Djibouti; this leg is the import shape.
|
||||
await expect(
|
||||
service.create(
|
||||
{
|
||||
...containerHazard,
|
||||
tradeDirection: 'EXPORT',
|
||||
originYardId: DJ,
|
||||
destinationYardId: ET,
|
||||
} as never,
|
||||
'staff-1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('files the rate per direction + lane + box size, unit out of the key', async () => {
|
||||
await service.create(
|
||||
{
|
||||
...containerHazard,
|
||||
tradeDirection: 'IMPORT',
|
||||
originYardId: DJ,
|
||||
destinationYardId: ET,
|
||||
containerTypeId: CT20,
|
||||
} as never,
|
||||
'staff-1',
|
||||
);
|
||||
|
||||
const pattern = repository.findByPattern.mock.calls[0][0];
|
||||
expect(pattern).not.toHaveProperty('rateUnit');
|
||||
expect(pattern).toMatchObject({
|
||||
rateType: 'HAZARD_SURCHARGE',
|
||||
tradeDirection: 'IMPORT',
|
||||
originYardId: DJ,
|
||||
destinationYardId: ET,
|
||||
containerTypeId: CT20,
|
||||
});
|
||||
expect(repository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
rateType: 'HAZARD_SURCHARGE',
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
tradeDirection: 'IMPORT',
|
||||
originYardId: DJ,
|
||||
destinationYardId: ET,
|
||||
containerTypeId: CT20,
|
||||
cargoTypeId: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts a lane catch-all with no box size', async () => {
|
||||
await service.create(
|
||||
{
|
||||
...containerHazard,
|
||||
tradeDirection: 'EXPORT',
|
||||
originYardId: ET,
|
||||
destinationYardId: DJ,
|
||||
} as never,
|
||||
'staff-1',
|
||||
);
|
||||
expect(repository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tradeDirection: 'EXPORT',
|
||||
originYardId: ET,
|
||||
destinationYardId: DJ,
|
||||
containerTypeId: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts a DOMESTIC (intercity) lane inside Ethiopia', async () => {
|
||||
await service.create(
|
||||
{
|
||||
...containerHazard,
|
||||
tradeDirection: 'DOMESTIC',
|
||||
originYardId: ET,
|
||||
destinationYardId: ET2,
|
||||
} as never,
|
||||
'staff-1',
|
||||
);
|
||||
expect(repository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ tradeDirection: 'DOMESTIC', originYardId: ET, destinationYardId: ET2 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a second rate for the same lane + box size', async () => {
|
||||
repository.findByPattern.mockResolvedValue({ id: 'rate-existing' } as Rate);
|
||||
await expect(
|
||||
service.create(
|
||||
{
|
||||
...containerHazard,
|
||||
tradeDirection: 'IMPORT',
|
||||
originYardId: DJ,
|
||||
destinationYardId: ET,
|
||||
containerTypeId: CT20,
|
||||
} as never,
|
||||
'staff-1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { ShippingLineCompaniesService } from '../../shipping-lines/shipping-line
|
||||
import { CreateRateDto } from '../dto/create-rate.dto';
|
||||
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { UpdateRateDto } from '../dto/update-rate.dto';
|
||||
import { Rate, isCustomsClearanceTrigger } from '../entities/rate.entity';
|
||||
import { Rate, isContainerHazardRate, isCustomsClearanceTrigger } from '../entities/rate.entity';
|
||||
import { deriveRateType } from '../entities/rate-type.util';
|
||||
import { CargoUom, allowedRateUnits, isRateUnitAllowed } from '../entities/rate-unit.util';
|
||||
import {
|
||||
@@ -39,7 +39,11 @@ const CARGO_KIND_TRIGGERS: readonly Rate['trigger'][] = [
|
||||
'ETHIOPIAN_CUSTOMS_CLEARANCE',
|
||||
'CANCELLATION',
|
||||
];
|
||||
/** Surcharges that keep a trade direction (everything else is direction-agnostic). */
|
||||
/**
|
||||
* Surcharges that keep a trade direction (everything else is direction-agnostic).
|
||||
* Container hazard (HAZARDOUS billed PER_CONTAINER) is directed too, but is
|
||||
* keyed on the unit rather than the trigger — see {@link isDirectedSurcharge}.
|
||||
*/
|
||||
const DIRECTED_SURCHARGE_TRIGGERS: readonly Rate['trigger'][] = [
|
||||
'CUSTOMS_CLEARANCE',
|
||||
'ETHIOPIAN_CUSTOMS_CLEARANCE',
|
||||
@@ -148,18 +152,29 @@ export class RatesService {
|
||||
|
||||
/**
|
||||
* Rates sold per direction + route. Base freight always; customs clearance,
|
||||
* empty-container return and fuel are the surcharges that are too — their
|
||||
* fee depends on the lane (and, for returns, the container type).
|
||||
* empty-container return, fuel and the container hazard surcharge are the
|
||||
* surcharges that are too — their fee depends on the lane (and, for returns
|
||||
* and container hazard, the container type).
|
||||
*/
|
||||
private isRouteScoped(appliesTo: Rate['appliesTo'], trigger: Rate['trigger']): boolean {
|
||||
private isRouteScoped(
|
||||
appliesTo: Rate['appliesTo'],
|
||||
trigger: Rate['trigger'],
|
||||
rateUnit: Rate['rateUnit'],
|
||||
): boolean {
|
||||
return (
|
||||
this.isBaseFreight(appliesTo, trigger) ||
|
||||
isCustomsClearanceTrigger(trigger) ||
|
||||
trigger === 'WITH_RETURN' ||
|
||||
trigger === 'FUEL'
|
||||
trigger === 'FUEL' ||
|
||||
isContainerHazardRate(trigger, rateUnit)
|
||||
);
|
||||
}
|
||||
|
||||
/** Surcharges that carry a trade direction; everything else is direction-agnostic. */
|
||||
private isDirectedSurcharge(trigger: Rate['trigger'], rateUnit: Rate['rateUnit']): boolean {
|
||||
return DIRECTED_SURCHARGE_TRIGGERS.includes(trigger) || isContainerHazardRate(trigger, rateUnit);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when pricing resolves exactly ONE rate for this shape (base freight,
|
||||
* customs clearance, lashing, empty-container return — all `find()`-based
|
||||
@@ -174,9 +189,10 @@ export class RatesService {
|
||||
private resolvesSingleRate(
|
||||
appliesTo: Rate['appliesTo'],
|
||||
trigger: Rate['trigger'],
|
||||
rateUnit: Rate['rateUnit'],
|
||||
): boolean {
|
||||
return (
|
||||
this.isRouteScoped(appliesTo, trigger) ||
|
||||
this.isRouteScoped(appliesTo, trigger, rateUnit) ||
|
||||
trigger === 'LASHING' ||
|
||||
trigger === 'CANCELLATION'
|
||||
);
|
||||
@@ -191,8 +207,8 @@ export class RatesService {
|
||||
appliesTo: Rate['appliesTo'],
|
||||
tradeDirection: string | null,
|
||||
): { origin: YardCountry; destination: YardCountry } {
|
||||
// DOMESTIC only reaches here on a FUEL rate's intercity lane — it stays
|
||||
// inside Ethiopia exactly like intercity base freight.
|
||||
// DOMESTIC only reaches here on a FUEL or container-hazard rate's intercity
|
||||
// lane — it stays inside Ethiopia exactly like intercity base freight.
|
||||
if (appliesTo === 'INTERCITY' || tradeDirection === 'DOMESTIC') {
|
||||
return { origin: YardCountry.ETHIOPIA, destination: YardCountry.ETHIOPIA };
|
||||
}
|
||||
@@ -212,12 +228,13 @@ export class RatesService {
|
||||
private async resolveYardScope(input: {
|
||||
appliesTo: Rate['appliesTo'];
|
||||
trigger: Rate['trigger'];
|
||||
rateUnit: Rate['rateUnit'];
|
||||
tradeDirection: string | null;
|
||||
originYardId?: string | null;
|
||||
destinationYardId?: string | null;
|
||||
}): Promise<YardScope> {
|
||||
const { appliesTo, trigger, tradeDirection } = input;
|
||||
if (!this.isRouteScoped(appliesTo, trigger)) {
|
||||
const { appliesTo, trigger, rateUnit, tradeDirection } = input;
|
||||
if (!this.isRouteScoped(appliesTo, trigger, rateUnit)) {
|
||||
return { originYardId: null, destinationYardId: null };
|
||||
}
|
||||
|
||||
@@ -263,14 +280,36 @@ export class RatesService {
|
||||
private assertScopeCoherent(input: {
|
||||
appliesTo: Rate['appliesTo'];
|
||||
trigger: Rate['trigger'];
|
||||
rateUnit: Rate['rateUnit'];
|
||||
tradeDirection: string | null;
|
||||
intercityKind: string | null;
|
||||
cargoKind: string | null;
|
||||
containerTypeId: string | null;
|
||||
cargoTypeId: string | null;
|
||||
}): void {
|
||||
const { appliesTo, trigger, tradeDirection, intercityKind, cargoKind } = input;
|
||||
const { appliesTo, trigger, rateUnit, tradeDirection, intercityKind, cargoKind } = input;
|
||||
const { containerTypeId, cargoTypeId } = input;
|
||||
if (isContainerHazardRate(trigger, rateUnit)) {
|
||||
// The container hazard surcharge is sold per lane like the empty-return
|
||||
// service: the direction says which countries the leg spans (DOMESTIC =
|
||||
// intercity, inside Ethiopia) and the box size may narrow it (a 20ft and
|
||||
// a 40ft hazardous box price differently; no size = the lane's catch-all).
|
||||
if (
|
||||
tradeDirection !== 'IMPORT' &&
|
||||
tradeDirection !== 'EXPORT' &&
|
||||
tradeDirection !== 'DOMESTIC'
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'A per-container hazardous surcharge must say whether it covers IMPORT, EXPORT or DOMESTIC (intercity).',
|
||||
);
|
||||
}
|
||||
if (cargoTypeId) {
|
||||
throw new BadRequestException(
|
||||
'A per-container hazardous surcharge cannot be scoped to a bulk cargo type.',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isCustomsClearanceTrigger(trigger) || trigger === 'CANCELLATION') {
|
||||
// Both fees are sold per direction + cargo kind + type: customs clearance
|
||||
// per lane, the wagon cancellation fee per direction only.
|
||||
@@ -602,18 +641,12 @@ export class RatesService {
|
||||
// Surcharges (trigger ≠ ALWAYS) carry no direction/scope — clear them so
|
||||
// the engine never accidentally narrows a surcharge by container/direction.
|
||||
// Exceptions: the directed surcharges (customs clearance, cancellation,
|
||||
// empty-container return, lashing, fuel) keep direction + cargo scope.
|
||||
// empty-container return, lashing, fuel, per-container hazard) keep
|
||||
// direction + cargo scope.
|
||||
const isSurcharge = trigger !== 'ALWAYS';
|
||||
const cargoKind = CARGO_KIND_TRIGGERS.includes(trigger)
|
||||
? ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? null)
|
||||
: null;
|
||||
const containerTypeId =
|
||||
trigger === 'WITH_RETURN' ||
|
||||
(CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'CONTAINER')
|
||||
? (dto.containerTypeId ?? null)
|
||||
: isSurcharge
|
||||
? null
|
||||
: (dto.containerTypeId ?? null);
|
||||
const cargoTypeId =
|
||||
(CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'BULK') ||
|
||||
trigger === 'LASHING' ||
|
||||
@@ -622,12 +655,30 @@ export class RatesService {
|
||||
: isSurcharge
|
||||
? null
|
||||
: (dto.cargoTypeId ?? null);
|
||||
// The unit is resolved before the scope because for hazard it IS the shape:
|
||||
// per container is the lane-sold container surcharge (direction + yards +
|
||||
// optional box size), per ton the global bulk one.
|
||||
const rateUnit = await this.resolveRateUnit(
|
||||
appliesTo,
|
||||
trigger,
|
||||
dto.rateUnit as Rate['rateUnit'] | undefined,
|
||||
cargoKind,
|
||||
cargoTypeId,
|
||||
);
|
||||
const containerTypeId =
|
||||
trigger === 'WITH_RETURN' ||
|
||||
isContainerHazardRate(trigger, rateUnit) ||
|
||||
(CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'CONTAINER')
|
||||
? (dto.containerTypeId ?? null)
|
||||
: isSurcharge
|
||||
? null
|
||||
: (dto.containerTypeId ?? null);
|
||||
// Intercity never leaves Ethiopia, so it has no trade direction to store —
|
||||
// its yard pair already says where it runs. (Fuel is the exception: its
|
||||
// intercity lane is stored as DOMESTIC, since appliesTo = OTHER says
|
||||
// nothing about the direction.)
|
||||
// its yard pair already says where it runs. (Fuel and container hazard are
|
||||
// the exception: their intercity lane is stored as DOMESTIC, since
|
||||
// appliesTo = OTHER says nothing about the direction.)
|
||||
const tradeDirection =
|
||||
DIRECTED_SURCHARGE_TRIGGERS.includes(trigger)
|
||||
this.isDirectedSurcharge(trigger, rateUnit)
|
||||
? (dto.tradeDirection ?? null)
|
||||
: isSurcharge || appliesTo === 'INTERCITY'
|
||||
? null
|
||||
@@ -637,6 +688,7 @@ export class RatesService {
|
||||
this.assertScopeCoherent({
|
||||
appliesTo,
|
||||
trigger,
|
||||
rateUnit,
|
||||
tradeDirection,
|
||||
intercityKind,
|
||||
cargoKind,
|
||||
@@ -646,6 +698,7 @@ export class RatesService {
|
||||
const { originYardId, destinationYardId } = await this.resolveYardScope({
|
||||
appliesTo,
|
||||
trigger,
|
||||
rateUnit,
|
||||
tradeDirection,
|
||||
originYardId: dto.originYardId,
|
||||
destinationYardId: dto.destinationYardId,
|
||||
@@ -662,13 +715,6 @@ export class RatesService {
|
||||
tradeDirection,
|
||||
isBulk: this.resolvesToBulk(appliesTo, intercityKind),
|
||||
});
|
||||
const rateUnit = await this.resolveRateUnit(
|
||||
appliesTo,
|
||||
trigger,
|
||||
dto.rateUnit as Rate['rateUnit'] | undefined,
|
||||
cargoKind,
|
||||
cargoTypeId,
|
||||
);
|
||||
|
||||
const { minKm, maxKm } = this.resolveLastMileBand({
|
||||
appliesTo,
|
||||
@@ -689,7 +735,7 @@ export class RatesService {
|
||||
|
||||
await this.assertNoDuplicatePattern({
|
||||
rateType,
|
||||
...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }),
|
||||
...(this.resolvesSingleRate(appliesTo, trigger, rateUnit) ? {} : { rateUnit }),
|
||||
shippingLineCompanyId,
|
||||
containerTypeId,
|
||||
cargoTypeId,
|
||||
@@ -826,15 +872,6 @@ export class RatesService {
|
||||
: ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ??
|
||||
(existing.containerTypeId ? 'CONTAINER' : 'BULK'));
|
||||
|
||||
const keepsContainerType =
|
||||
!isSurcharge ||
|
||||
trigger === 'WITH_RETURN' ||
|
||||
(CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'CONTAINER');
|
||||
const containerTypeId = !keepsContainerType
|
||||
? null
|
||||
: dto.containerTypeId !== undefined
|
||||
? dto.containerTypeId
|
||||
: existing.containerTypeId;
|
||||
const keepsCargoType =
|
||||
!isSurcharge ||
|
||||
(CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'BULK') ||
|
||||
@@ -845,8 +882,32 @@ export class RatesService {
|
||||
: dto.cargoTypeId !== undefined
|
||||
? dto.cargoTypeId
|
||||
: existing.cargoTypeId;
|
||||
// Re-validate the unit against the (possibly changed) shape before the
|
||||
// scope is settled: for hazard the unit decides whether the rate is the
|
||||
// lane-sold container surcharge or the global bulk one. Overweight is
|
||||
// forced to PER_TON.
|
||||
const requestedUnit = (dto.rateUnit as Rate['rateUnit']) ?? existing.rateUnit;
|
||||
const rateUnit = await this.resolveRateUnit(
|
||||
appliesTo,
|
||||
trigger,
|
||||
requestedUnit,
|
||||
cargoKind,
|
||||
cargoTypeId ?? null,
|
||||
);
|
||||
updates.rateUnit = rateUnit;
|
||||
|
||||
const keepsContainerType =
|
||||
!isSurcharge ||
|
||||
trigger === 'WITH_RETURN' ||
|
||||
isContainerHazardRate(trigger, rateUnit) ||
|
||||
(CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'CONTAINER');
|
||||
const containerTypeId = !keepsContainerType
|
||||
? null
|
||||
: dto.containerTypeId !== undefined
|
||||
? dto.containerTypeId
|
||||
: existing.containerTypeId;
|
||||
const tradeDirection =
|
||||
DIRECTED_SURCHARGE_TRIGGERS.includes(trigger)
|
||||
this.isDirectedSurcharge(trigger, rateUnit)
|
||||
? dto.tradeDirection !== undefined
|
||||
? dto.tradeDirection
|
||||
: existing.tradeDirection
|
||||
@@ -868,6 +929,7 @@ export class RatesService {
|
||||
this.assertScopeCoherent({
|
||||
appliesTo,
|
||||
trigger,
|
||||
rateUnit,
|
||||
tradeDirection: updates.tradeDirection,
|
||||
intercityKind,
|
||||
cargoKind,
|
||||
@@ -879,6 +941,7 @@ export class RatesService {
|
||||
const yardScope = await this.resolveYardScope({
|
||||
appliesTo,
|
||||
trigger,
|
||||
rateUnit,
|
||||
tradeDirection: updates.tradeDirection,
|
||||
originYardId:
|
||||
dto.originYardId !== undefined ? dto.originYardId : existing.originYardId,
|
||||
@@ -910,18 +973,6 @@ export class RatesService {
|
||||
});
|
||||
updates.rateType = rateType;
|
||||
|
||||
// Re-validate the unit against the (possibly changed) shape; overweight is
|
||||
// forced to PER_TON.
|
||||
const requestedUnit = (dto.rateUnit as Rate['rateUnit']) ?? existing.rateUnit;
|
||||
const rateUnit = await this.resolveRateUnit(
|
||||
appliesTo,
|
||||
trigger,
|
||||
requestedUnit,
|
||||
cargoKind,
|
||||
updates.cargoTypeId,
|
||||
);
|
||||
updates.rateUnit = rateUnit;
|
||||
|
||||
const { minKm, maxKm } = this.resolveLastMileBand({
|
||||
appliesTo,
|
||||
rateUnit,
|
||||
@@ -948,7 +999,7 @@ export class RatesService {
|
||||
// Guard the pattern uniqueness for the new identity, ignoring this row.
|
||||
await this.assertNoDuplicatePattern({
|
||||
rateType,
|
||||
...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }),
|
||||
...(this.resolvesSingleRate(appliesTo, trigger, rateUnit) ? {} : { rateUnit }),
|
||||
shippingLineCompanyId,
|
||||
containerTypeId: updates.containerTypeId,
|
||||
cargoTypeId: updates.cargoTypeId,
|
||||
|
||||
@@ -2,14 +2,15 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Transform } from "class-transformer";
|
||||
import {
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsEmail,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
} from "class-validator";
|
||||
|
||||
import { IsValidPhone } from "../../../common/validators/is-phone-number.validator";
|
||||
import { TransitAgentCountry } from "../entities/transit-agent.entity";
|
||||
|
||||
const toBoolean = ({ value }: { value: unknown }) => {
|
||||
if (typeof value === "boolean") return value;
|
||||
@@ -24,13 +25,18 @@ export class CreateTransitAgentDto {
|
||||
@MaxLength(150)
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ example: "2026-01-01" })
|
||||
@IsDateString()
|
||||
validFrom!: string;
|
||||
|
||||
@ApiProperty({ example: "2026-12-31" })
|
||||
@IsDateString()
|
||||
validTo!: string;
|
||||
/**
|
||||
* Defaults to Djibouti, which is what the whole roster was before Ethiopian
|
||||
* agents were added. Only `ET` agents are offered to a freight forwarder
|
||||
* picking itself during onboarding.
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
enum: TransitAgentCountry,
|
||||
default: TransitAgentCountry.Djibouti,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(TransitAgentCountry)
|
||||
country?: TransitAgentCountry;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
|
||||
@@ -2,22 +2,38 @@ import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity, Index } from "typeorm";
|
||||
|
||||
/**
|
||||
* Djibouti transit officer GL Djibouti may assign against a shipment's
|
||||
* transit-assignee handshake. Admin-managed so the roster and each officer's
|
||||
* validity window arrive without a code change; `isActive` is the manual
|
||||
* suspend/reactivate switch, independent of the validity window.
|
||||
* Where the agent is licensed. The roster started Djibouti-only (the officers
|
||||
* GL Djibouti assigns), so that is the column default. An Ethiopian transit
|
||||
* agent is the same business as a freight forwarder — a forwarder onboarding on
|
||||
* the portal picks itself from the `ET` entries (`Company.transitAgentId`).
|
||||
*/
|
||||
export enum TransitAgentCountry {
|
||||
Ethiopia = "ET",
|
||||
Djibouti = "DJ",
|
||||
}
|
||||
|
||||
/**
|
||||
* Transit officer GL Djibouti may assign against a shipment's transit-assignee
|
||||
* handshake, and — for the Ethiopian entries — the roster a freight forwarder
|
||||
* registers itself against. Admin-managed so the roster arrives without a code
|
||||
* change; `isActive` is the manual suspend/reactivate switch and the only
|
||||
* thing that decides whether an agent may be assigned or picked.
|
||||
*/
|
||||
@Entity({ schema: "freight", name: "transit_agents" })
|
||||
@Index(["isActive"])
|
||||
@Index(["country"])
|
||||
export class TransitAgent extends BaseEntity {
|
||||
@Column({ name: "name", type: "varchar", length: 150 })
|
||||
name!: string;
|
||||
|
||||
@Column({ name: "valid_from", type: "date" })
|
||||
validFrom!: string;
|
||||
|
||||
@Column({ name: "valid_to", type: "date" })
|
||||
validTo!: string;
|
||||
@Column({
|
||||
name: "country",
|
||||
type: "varchar",
|
||||
length: 2,
|
||||
enum: TransitAgentCountry,
|
||||
default: TransitAgentCountry.Djibouti,
|
||||
})
|
||||
country!: TransitAgentCountry;
|
||||
|
||||
@Column({ name: "is_active", type: "boolean", default: true })
|
||||
isActive!: boolean;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { PortalCustomer } from "../../common/booking-guards";
|
||||
import {
|
||||
RuleEngineCreate,
|
||||
RuleEngineDelete,
|
||||
@@ -50,16 +51,29 @@ export class TransitAgentsController {
|
||||
});
|
||||
}
|
||||
|
||||
/** Active + currently valid officers — the transit-assignee assignment dropdown. */
|
||||
/** Active officers — the transit-assignee assignment dropdown. */
|
||||
@Get("assignable")
|
||||
@RuleEngineView("transit-agents")
|
||||
@ApiOperation({
|
||||
summary: "List transit agents assignable right now (active and in-window)",
|
||||
})
|
||||
@ApiOperation({ summary: "List active transit agents (assignable)" })
|
||||
findAssignable() {
|
||||
return this.transitAgentsService.findAssignable();
|
||||
}
|
||||
|
||||
/**
|
||||
* The Ethiopian roster, id + name only, for a customer registering as a
|
||||
* freight forwarder to pick itself from. Declared before `:id` so the
|
||||
* literal path is not swallowed by the UUID route.
|
||||
*/
|
||||
@Get("forwarder-options")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"List active Ethiopian transit agents (id + name) a freight forwarder can register as",
|
||||
})
|
||||
findForwarderOptions() {
|
||||
return this.transitAgentsService.findForwarderOptions();
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@RuleEngineView("transit-agents")
|
||||
@ApiOperation({ summary: "Get a transit agent by ID" })
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import {
|
||||
EntityManager,
|
||||
LessThanOrEqual,
|
||||
MoreThanOrEqual,
|
||||
Repository,
|
||||
} from "typeorm";
|
||||
import { EntityManager, Repository } from "typeorm";
|
||||
|
||||
import { TransitAgent } from "./entities/transit-agent.entity";
|
||||
import {
|
||||
TransitAgent,
|
||||
TransitAgentCountry,
|
||||
} from "./entities/transit-agent.entity";
|
||||
|
||||
/** What a forwarder picking itself from the roster needs: the id and a label. */
|
||||
export type ForwarderTransitAgentOption = Pick<TransitAgent, "id" | "name">;
|
||||
|
||||
@Injectable()
|
||||
export class TransitAgentsRepository extends BaseRepository<TransitAgent> {
|
||||
@@ -19,14 +20,23 @@ export class TransitAgentsRepository extends BaseRepository<TransitAgent> {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/** Active AND currently inside its validity window (today's date, server-side). */
|
||||
findAssignable(today: string): Promise<TransitAgent[]> {
|
||||
/** Every active agent — the GL assignment dropdown. */
|
||||
findAssignable(): Promise<TransitAgent[]> {
|
||||
return this.repository.find({
|
||||
where: {
|
||||
isActive: true,
|
||||
validFrom: LessThanOrEqual(today),
|
||||
validTo: MoreThanOrEqual(today),
|
||||
},
|
||||
where: { isActive: true },
|
||||
order: { name: "ASC" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The Ethiopian roster a freight forwarder registers itself against, as
|
||||
* `{ id, name }` only — this is served to customers, who have no business
|
||||
* seeing another agent's email or phone. Suspended agents are left out.
|
||||
*/
|
||||
findForwarderOptions(): Promise<ForwarderTransitAgentOption[]> {
|
||||
return this.repository.find({
|
||||
select: { id: true, name: true },
|
||||
where: { isActive: true, country: TransitAgentCountry.Ethiopia },
|
||||
order: { name: "ASC" },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,11 +5,12 @@ import {
|
||||
} from "@tria-plc/api-common/utils/enums/user.enum";
|
||||
|
||||
import { ResetChannel } from "../auth/dto/forgot-password.dto";
|
||||
import { TransitAgentCountry } from "./entities/transit-agent.entity";
|
||||
import { TransitAgentsService } from "./transit-agents.service";
|
||||
|
||||
/**
|
||||
* The account half of a transit agent. The roster half (validity window,
|
||||
* assignability) predates this and is untouched — what these lock is that
|
||||
* The account half of a transit agent. The roster half (assignability)
|
||||
* predates this and is untouched — what these lock is that
|
||||
* adding a login did not make an account MANDATORY, since production is full of
|
||||
* roster-only agents that must keep working.
|
||||
*/
|
||||
@@ -36,8 +37,6 @@ describe("TransitAgentsService accounts", () => {
|
||||
|
||||
const base = {
|
||||
name: "Ahmed Bourhan",
|
||||
validFrom: "2026-01-01",
|
||||
validTo: "2026-12-31",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -343,4 +342,102 @@ describe("TransitAgentsService accounts", () => {
|
||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* An Ethiopian transit agent IS a freight forwarder, which signs up on the
|
||||
* portal with its own email and phone. Nothing minted from this side may
|
||||
* claim those first.
|
||||
*/
|
||||
describe("Ethiopian agents carry no contact details or account", () => {
|
||||
const ethiopian = { ...base, country: TransitAgentCountry.Ethiopia };
|
||||
|
||||
it("refuses an email on create", async () => {
|
||||
await expect(
|
||||
service.createWithInvite({ ...ethiopian, email: "ff@example.et" }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
expect(repo.create).not.toHaveBeenCalled();
|
||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a phone number on create", async () => {
|
||||
await expect(
|
||||
service.createWithInvite({ ...ethiopian, phoneNumber: "+251911223344" }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
expect(repo.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates the roster entry with neither", async () => {
|
||||
const { agent } = await service.createWithInvite(ethiopian);
|
||||
|
||||
expect(agent.country).toBe(TransitAgentCountry.Ethiopia);
|
||||
expect(agent.hasAccount).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses to invite one", async () => {
|
||||
repo.findById.mockResolvedValue({
|
||||
id: "ta-1",
|
||||
...ethiopian,
|
||||
isActive: true,
|
||||
userId: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.invite("ta-1", { email: "ff@example.et" }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses an email on update", async () => {
|
||||
repo.findById.mockResolvedValue({
|
||||
id: "ta-1",
|
||||
...ethiopian,
|
||||
isActive: true,
|
||||
userId: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.update("ta-1", { email: "ff@example.et" }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
expect(repo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears the contact details when a Djiboutian row is switched", async () => {
|
||||
repo.findById.mockResolvedValue({
|
||||
id: "ta-1",
|
||||
...base,
|
||||
country: TransitAgentCountry.Djibouti,
|
||||
isActive: true,
|
||||
userId: null,
|
||||
email: "a@transit.dj",
|
||||
phoneNumber: "+25377834567",
|
||||
});
|
||||
|
||||
await service.update("ta-1", { country: TransitAgentCountry.Ethiopia });
|
||||
|
||||
expect(repo.update).toHaveBeenCalledWith(
|
||||
"ta-1",
|
||||
expect.objectContaining({
|
||||
country: TransitAgentCountry.Ethiopia,
|
||||
email: null,
|
||||
phoneNumber: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses the switch when the row already has a portal account", async () => {
|
||||
repo.findById.mockResolvedValue({
|
||||
id: "ta-1",
|
||||
...base,
|
||||
country: TransitAgentCountry.Djibouti,
|
||||
isActive: true,
|
||||
userId: "user-1",
|
||||
email: "a@transit.dj",
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.update("ta-1", { country: TransitAgentCountry.Ethiopia }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
expect(repo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,13 +26,16 @@ import { isDomesticPhone } from "../otp/otp.service";
|
||||
import { CreateTransitAgentDto } from "./dto/create-transit-agent.dto";
|
||||
import { InviteTransitAgentDto } from "./dto/invite-transit-agent.dto";
|
||||
import { UpdateTransitAgentDto } from "./dto/update-transit-agent.dto";
|
||||
import { TransitAgent } from "./entities/transit-agent.entity";
|
||||
import { TransitAgentsRepository } from "./transit-agents.repository";
|
||||
|
||||
export type TransitAgentValidityStatus = "VALID" | "NOT_STARTED" | "EXPIRED";
|
||||
import {
|
||||
TransitAgent,
|
||||
TransitAgentCountry,
|
||||
} from "./entities/transit-agent.entity";
|
||||
import {
|
||||
ForwarderTransitAgentOption,
|
||||
TransitAgentsRepository,
|
||||
} from "./transit-agents.repository";
|
||||
|
||||
export type TransitAgentView = TransitAgent & {
|
||||
validityStatus: TransitAgentValidityStatus;
|
||||
/** True once an IAM account backs this agent — i.e. it can sign in. */
|
||||
hasAccount: boolean;
|
||||
};
|
||||
@@ -52,24 +55,9 @@ type TransitAgentListFilter = {
|
||||
sortOrder?: string;
|
||||
};
|
||||
|
||||
/** Today as `yyyy-MM-dd`, matching the `date`-typed validity columns. */
|
||||
function todayISODate(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function validityStatus(
|
||||
agent: Pick<TransitAgent, "validFrom" | "validTo">,
|
||||
): TransitAgentValidityStatus {
|
||||
const today = todayISODate();
|
||||
if (today < agent.validFrom) return "NOT_STARTED";
|
||||
if (today > agent.validTo) return "EXPIRED";
|
||||
return "VALID";
|
||||
}
|
||||
|
||||
function withValidityStatus(agent: TransitAgent): TransitAgentView {
|
||||
function toView(agent: TransitAgent): TransitAgentView {
|
||||
return {
|
||||
...agent,
|
||||
validityStatus: validityStatus(agent),
|
||||
hasAccount: Boolean(agent.userId),
|
||||
};
|
||||
}
|
||||
@@ -92,9 +80,7 @@ export class TransitAgentsService {
|
||||
}> {
|
||||
const page = filter.page ?? 1;
|
||||
const pageSize = filter.pageSize ?? 500;
|
||||
const sortBy = ["name", "validFrom", "validTo", "isActive"].includes(
|
||||
filter.sortBy ?? "",
|
||||
)
|
||||
const sortBy = ["name", "country", "isActive"].includes(filter.sortBy ?? "")
|
||||
? (filter.sortBy as keyof TransitAgent)
|
||||
: "name";
|
||||
const sortOrder =
|
||||
@@ -108,7 +94,7 @@ export class TransitAgentsService {
|
||||
});
|
||||
|
||||
return {
|
||||
data: data.map(withValidityStatus),
|
||||
data: data.map(toView),
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
@@ -118,9 +104,14 @@ export class TransitAgentsService {
|
||||
};
|
||||
}
|
||||
|
||||
/** Active and currently inside its validity window — the DJ assignment dropdown. */
|
||||
/** Every active agent — the DJ assignment dropdown. */
|
||||
async findAssignable(): Promise<TransitAgent[]> {
|
||||
return this.transitAgentsRepository.findAssignable(todayISODate());
|
||||
return this.transitAgentsRepository.findAssignable();
|
||||
}
|
||||
|
||||
/** The Ethiopian roster a freight forwarder picks itself from at onboarding. */
|
||||
findForwarderOptions(): Promise<ForwarderTransitAgentOption[]> {
|
||||
return this.transitAgentsRepository.findForwarderOptions();
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<TransitAgentView> {
|
||||
@@ -128,10 +119,10 @@ export class TransitAgentsService {
|
||||
if (!agent) {
|
||||
throw new NotFoundException(`Transit agent ${id} not found`);
|
||||
}
|
||||
return withValidityStatus(agent);
|
||||
return toView(agent);
|
||||
}
|
||||
|
||||
/** Used by the assignment flow — rejects a suspended or out-of-window officer. */
|
||||
/** Used by the assignment flow — rejects a suspended officer. */
|
||||
async getAssignable(id: string): Promise<TransitAgent> {
|
||||
const agent = await this.transitAgentsRepository.findById(id);
|
||||
if (!agent) {
|
||||
@@ -142,11 +133,6 @@ export class TransitAgentsService {
|
||||
`${agent.name} is suspended — pick another transit officer.`,
|
||||
);
|
||||
}
|
||||
if (validityStatus(agent) !== "VALID") {
|
||||
throw new BadRequestException(
|
||||
`${agent.name}'s validity window has expired — pick another transit officer or extend their dates.`,
|
||||
);
|
||||
}
|
||||
return agent;
|
||||
}
|
||||
|
||||
@@ -221,6 +207,32 @@ export class TransitAgentsService {
|
||||
return { email, username, phoneNumber };
|
||||
}
|
||||
|
||||
/**
|
||||
* An Ethiopian transit agent never gets a portal account of its own.
|
||||
*
|
||||
* It IS a freight forwarder, and the forwarder signs up on the portal as a
|
||||
* customer with its own email and phone — the same ones staff would type
|
||||
* here. An IAM account minted from this side would then claim that email
|
||||
* first, and the forwarder's own registration would fail with "already
|
||||
* registered". So for `ET` the contact fields are refused outright, and the
|
||||
* invite path is closed.
|
||||
*/
|
||||
private assertNoAccountForEthiopian(
|
||||
country: TransitAgentCountry,
|
||||
dto: {
|
||||
email?: string | null;
|
||||
phoneNumber?: string | null;
|
||||
username?: string;
|
||||
},
|
||||
): void {
|
||||
if (country !== TransitAgentCountry.Ethiopia) return;
|
||||
if (dto.email || dto.phoneNumber || dto.username) {
|
||||
throw new BadRequestException(
|
||||
"An Ethiopian transit agent has no email, phone or portal account here — it registers itself on the portal as a freight forwarder with its own contact details.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a transit agent.
|
||||
*
|
||||
@@ -237,24 +249,18 @@ export class TransitAgentsService {
|
||||
async createWithInvite(
|
||||
dto: CreateTransitAgentDto,
|
||||
): Promise<InvitedTransitAgent> {
|
||||
if (dto.validTo < dto.validFrom) {
|
||||
throw new BadRequestException(
|
||||
"Valid-to date must be on or after valid-from date.",
|
||||
);
|
||||
}
|
||||
|
||||
const base = {
|
||||
name: dto.name.trim(),
|
||||
validFrom: dto.validFrom,
|
||||
validTo: dto.validTo,
|
||||
country: dto.country ?? TransitAgentCountry.Djibouti,
|
||||
isActive: dto.isActive ?? true,
|
||||
};
|
||||
this.assertNoAccountForEthiopian(base.country, dto);
|
||||
|
||||
if (!dto.email) {
|
||||
// Roster-only agent — no account, nothing to send.
|
||||
const agent = await this.transitAgentsRepository.create(base);
|
||||
return {
|
||||
agent: withValidityStatus(agent),
|
||||
agent: toView(agent),
|
||||
activationSentTo: null,
|
||||
activationChannel: null,
|
||||
};
|
||||
@@ -286,7 +292,7 @@ export class TransitAgentsService {
|
||||
// valid without it.
|
||||
const activation = await this.sendActivationLink(agent);
|
||||
return {
|
||||
agent: withValidityStatus(agent),
|
||||
agent: toView(agent),
|
||||
activationSentTo: activation?.maskedTarget ?? null,
|
||||
activationChannel: activation?.channel ?? null,
|
||||
};
|
||||
@@ -312,6 +318,7 @@ export class TransitAgentsService {
|
||||
"This transit agent already has a portal account — resend the activation link instead.",
|
||||
);
|
||||
}
|
||||
this.assertNoAccountForEthiopian(current.country, dto);
|
||||
|
||||
const { email, username, phoneNumber } = await this.prepareAccountFields(
|
||||
dto,
|
||||
@@ -338,7 +345,7 @@ export class TransitAgentsService {
|
||||
|
||||
const activation = await this.sendActivationLink(agent);
|
||||
return {
|
||||
agent: withValidityStatus(agent),
|
||||
agent: toView(agent),
|
||||
activationSentTo: activation?.maskedTarget ?? null,
|
||||
activationChannel: activation?.channel ?? null,
|
||||
};
|
||||
@@ -436,13 +443,6 @@ export class TransitAgentsService {
|
||||
dto: UpdateTransitAgentDto,
|
||||
): Promise<TransitAgentView> {
|
||||
const current = await this.findById(id);
|
||||
const nextValidFrom = dto.validFrom ?? current.validFrom;
|
||||
const nextValidTo = dto.validTo ?? current.validTo;
|
||||
if (nextValidTo < nextValidFrom) {
|
||||
throw new BadRequestException(
|
||||
"Valid-to date must be on or after valid-from date.",
|
||||
);
|
||||
}
|
||||
|
||||
// `username` only ever names an IAM account, and it is chosen once at
|
||||
// account creation. Accepting it here (PartialType inherits it from the
|
||||
@@ -450,6 +450,21 @@ export class TransitAgentsService {
|
||||
const { username: _ignoredUsername, email, phoneNumber, ...rest } = dto;
|
||||
|
||||
const contact: Partial<TransitAgent> = {};
|
||||
const nextCountry = dto.country ?? current.country;
|
||||
if (nextCountry === TransitAgentCountry.Ethiopia) {
|
||||
this.assertNoAccountForEthiopian(nextCountry, { email, phoneNumber });
|
||||
if (current.userId) {
|
||||
// The account already holds the email the forwarder would sign up
|
||||
// with; there is no way to hand it back, so the row stays Djiboutian.
|
||||
throw new BadRequestException(
|
||||
`${current.name} already has a portal account, so it cannot become an Ethiopian transit agent — create a new Ethiopian entry instead.`,
|
||||
);
|
||||
}
|
||||
// Whatever contact details a Djiboutian row carried go with the switch,
|
||||
// so the forwarder's own registration cannot collide with them.
|
||||
contact.email = null;
|
||||
contact.phoneNumber = null;
|
||||
}
|
||||
if (email !== undefined) {
|
||||
const normalized = email.trim().toLowerCase();
|
||||
if (await this.transitAgentsRepository.existsByEmail(normalized, id)) {
|
||||
@@ -483,7 +498,7 @@ export class TransitAgentsService {
|
||||
await this.syncIamContact(updated);
|
||||
}
|
||||
|
||||
return withValidityStatus(updated);
|
||||
return toView(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { ExternalProfile } from "../companies/entities/external-profile.entity";
|
||||
import { ClearanceMilestone } from "../contracts/entities/clearance-milestone.entity";
|
||||
import { FilesModule } from "../files/files.module";
|
||||
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
|
||||
@@ -19,11 +20,14 @@ import { TransitAssignmentsService } from "./transit-assignments.service";
|
||||
// Milestones and train schedules are read for the agent's dashboard
|
||||
// timings (declaration stamps, departure/arrival fallbacks) — entities
|
||||
// only, for the same reason as Booking.
|
||||
// ExternalProfile: `/my` resolves a freight forwarder's portal user to the
|
||||
// transit agent its company registered as — entity only, same reason.
|
||||
TypeOrmModule.forFeature([
|
||||
TransitAssignment,
|
||||
Booking,
|
||||
ClearanceMilestone,
|
||||
TrainSchedule,
|
||||
ExternalProfile,
|
||||
]),
|
||||
FilesModule,
|
||||
TransitAgentsModule,
|
||||
|
||||
@@ -93,6 +93,7 @@ describe("TransitAssignmentsService", () => {
|
||||
files as never,
|
||||
milestones as never,
|
||||
trainSchedules as never,
|
||||
{ findOne: jest.fn().mockResolvedValue(null) } as never, // externalProfiles
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -17,6 +17,11 @@ import {
|
||||
} from "@edr/types";
|
||||
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { ExternalProfile } from "../companies/entities/external-profile.entity";
|
||||
import {
|
||||
ProfileStatus,
|
||||
ProfileType,
|
||||
} from "../companies/entities/company-profile.entity";
|
||||
import { ClearanceMilestone } from "../contracts/entities/clearance-milestone.entity";
|
||||
import { FilesService } from "../files/files.service";
|
||||
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
|
||||
@@ -170,6 +175,11 @@ export class TransitAssignmentsService {
|
||||
private readonly milestonesRepository: Repository<ClearanceMilestone>,
|
||||
@InjectRepository(TrainSchedule)
|
||||
private readonly trainSchedulesRepository: Repository<TrainSchedule>,
|
||||
// The ExternalProfile ENTITY (not CompaniesModule) for the same reason as
|
||||
// Booking above: `/my` only has to walk portal user → company → the
|
||||
// transit agent that company registered itself as.
|
||||
@InjectRepository(ExternalProfile)
|
||||
private readonly externalProfilesRepository: Repository<ExternalProfile>,
|
||||
) {}
|
||||
|
||||
private static minutesBetween(
|
||||
@@ -252,9 +262,52 @@ export class TransitAssignmentsService {
|
||||
// client-supplied id: an agent must not be able to read or edit another
|
||||
// agent's assignments by guessing one.
|
||||
|
||||
/** The transit agent this portal user signs in as. */
|
||||
private async requireAgentForUser(userId: string) {
|
||||
const agent = await this.transitAgentsRepository.findByUserId(userId);
|
||||
/**
|
||||
* The transit agent this portal user acts as.
|
||||
*
|
||||
* Two kinds of account reach `/my`: a Djibouti transit officer, who signs in
|
||||
* AS the agent (`transit_agents.user_id`), and a customer company that
|
||||
* registered itself as an Ethiopian transit agent (`companies.
|
||||
* transit_agent_id`) — under the transit agent role, the forwarder role, or
|
||||
* both. It may look at its assigned bookings from the moment the role is
|
||||
* requested — that is how it learns work is waiting — but may only act on
|
||||
* them (`forWrite`) once a roster role has been approved.
|
||||
*/
|
||||
private async requireAgentForUser(
|
||||
userId: string,
|
||||
opts: { forWrite?: boolean } = {},
|
||||
) {
|
||||
const own = await this.transitAgentsRepository.findByUserId(userId);
|
||||
if (own) return own;
|
||||
|
||||
const profile = await this.externalProfilesRepository.findOne({
|
||||
where: { userId },
|
||||
relations: { company: { companyProfiles: true } },
|
||||
});
|
||||
const company = profile?.company;
|
||||
if (!company?.transitAgentId) {
|
||||
throw new ForbiddenException("This account is not a transit agent");
|
||||
}
|
||||
// Either roster role will do — a plain transit agent or a forwarder.
|
||||
const agentRoles = (company.companyProfiles ?? []).filter(
|
||||
(p) =>
|
||||
p.type === ProfileType.transitAgent ||
|
||||
p.type === ProfileType.freightForwarder,
|
||||
);
|
||||
if (agentRoles.length === 0) {
|
||||
throw new ForbiddenException("This account is not a transit agent");
|
||||
}
|
||||
if (
|
||||
opts.forWrite &&
|
||||
!agentRoles.some((p) => p.status === ProfileStatus.Active)
|
||||
) {
|
||||
throw new ForbiddenException(
|
||||
"Your transit agent role is not approved yet — you can view assigned bookings but not act on them until it is.",
|
||||
);
|
||||
}
|
||||
const agent = await this.transitAgentsRepository.findById(
|
||||
company.transitAgentId,
|
||||
);
|
||||
if (!agent) {
|
||||
throw new ForbiddenException("This account is not a transit agent");
|
||||
}
|
||||
@@ -640,8 +693,12 @@ export class TransitAssignmentsService {
|
||||
return { ...this.toView(assignment), files: await this.listFiles(id) };
|
||||
}
|
||||
|
||||
/** Assert the assignment is this user's before any write reaches it. */
|
||||
/**
|
||||
* Assert the assignment is this user's before any write reaches it — and
|
||||
* that the user may write at all (an unapproved forwarder may only look).
|
||||
*/
|
||||
private async assertMine(userId: string, id: string): Promise<void> {
|
||||
await this.requireAgentForUser(userId, { forWrite: true });
|
||||
await this.findMineById(userId, id);
|
||||
}
|
||||
|
||||
@@ -677,6 +734,7 @@ export class TransitAssignmentsService {
|
||||
id: string,
|
||||
input: { finish: boolean; note?: string },
|
||||
): Promise<TransitAssignmentView> {
|
||||
await this.requireAgentForUser(userId, { forWrite: true });
|
||||
const current = await this.findMineById(userId, id);
|
||||
if (current.status === TransitAssignmentStatus.Finished) {
|
||||
throw new ForbiddenException("This assignment is already finished.");
|
||||
|
||||
@@ -166,14 +166,14 @@ export function TransitAssigneePanel({
|
||||
) : null}
|
||||
<Select
|
||||
label="Transit officer"
|
||||
description="Active, currently-valid transit agents only — configure the roster in Transit Agents settings"
|
||||
description="Active transit agents only — configure the roster in Transit Agents settings"
|
||||
placeholder={loadingAgents ? "Loading…" : "Select transit officer"}
|
||||
data={agentOptions}
|
||||
value={transitAgentId}
|
||||
onChange={setTransitAgentId}
|
||||
searchable
|
||||
disabled={readOnly || loadingAgents}
|
||||
nothingFoundMessage="No active, valid transit agents — add one in Transit Agents settings"
|
||||
nothingFoundMessage="No active transit agents — add one in Transit Agents settings"
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
{changing ? (
|
||||
|
||||
@@ -56,6 +56,7 @@ const PROFILE_TYPE_COLOR: Record<ProfileType, string> = {
|
||||
freight_forwarder: "blue",
|
||||
dj_freight_forwarder: "indigo",
|
||||
transporter: "grape",
|
||||
transit_agent: "lime",
|
||||
};
|
||||
|
||||
export function CompanyStatusBadge({ status }: { status: CompanyStatus }) {
|
||||
|
||||
@@ -80,24 +80,12 @@ export const formatCell = (
|
||||
);
|
||||
}
|
||||
|
||||
if (format === "validityBadge") {
|
||||
const status = String(value);
|
||||
const label =
|
||||
status === "VALID"
|
||||
? "Valid"
|
||||
: status === "EXPIRED"
|
||||
? "Expired"
|
||||
: "Not started";
|
||||
const color =
|
||||
status === "VALID"
|
||||
? "edr-green"
|
||||
: status === "EXPIRED"
|
||||
? "red"
|
||||
: "yellow";
|
||||
if (format === "country") {
|
||||
const code = String(value ?? "");
|
||||
return (
|
||||
<Badge color={color} variant="filled" size="sm" radius="md">
|
||||
{label}
|
||||
</Badge>
|
||||
<span>
|
||||
{code === "ET" ? "Ethiopia" : code === "DJ" ? "Djibouti" : code}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -954,6 +954,22 @@ export default function CustomerDetailPage() {
|
||||
<InfoField label="Email" value={company.email} />
|
||||
<InfoField label="Phone" value={company.phone} />
|
||||
<InfoField label="Website" value={company.website} />
|
||||
{/* Which roster entry the company is — shown only for
|
||||
a transit agent or forwarder, since nobody else
|
||||
has one. Unlinked = onboarded before the link. */}
|
||||
{company.companyProfiles?.some(
|
||||
(p) =>
|
||||
p.type === "freight_forwarder" ||
|
||||
p.type === "transit_agent",
|
||||
) && (
|
||||
<InfoField
|
||||
label="Transit agent"
|
||||
value={
|
||||
company.transitAgent?.name ??
|
||||
"Not linked to a transit agent"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<InfoField
|
||||
label="Submitted on"
|
||||
value={formatDate(company.createdAt)}
|
||||
|
||||
@@ -123,6 +123,7 @@ const CUSTOMER_FILTER_DEFS: FilterDef[] = [
|
||||
"freight_forwarder",
|
||||
"dj_freight_forwarder",
|
||||
"transporter",
|
||||
"transit_agent",
|
||||
] as const
|
||||
).map((value) => ({ value, label: humanize(value) })),
|
||||
},
|
||||
|
||||
@@ -61,10 +61,10 @@ import {
|
||||
import {
|
||||
DEFAULT_CONFIGURATION_SLUG,
|
||||
DEFAULT_RULES_SLUG,
|
||||
ROUTE_SCOPED_TRIGGERS,
|
||||
RULE_ENGINE_CATEGORY_BASE_PATH,
|
||||
RULE_ENGINE_SELECT_NONE,
|
||||
getRuleEngineResource,
|
||||
isRouteScopedSurcharge,
|
||||
rateUnitOptions,
|
||||
type RuleEngineNavCategory,
|
||||
} from "@/pages/ruleEngine/config/resources";
|
||||
@@ -119,17 +119,16 @@ const yardOptionsForLegEnd = (
|
||||
} else if (
|
||||
appliesTo === "CONTAINER" ||
|
||||
appliesTo === "BULK" ||
|
||||
// Customs clearance, empty-container return and fuel are sold per
|
||||
// direction + route, so their yard dropdowns narrow exactly like base
|
||||
// freight.
|
||||
(appliesTo === "OTHER" &&
|
||||
ROUTE_SCOPED_TRIGGERS.includes(String(values.trigger ?? "")))
|
||||
// Customs clearance, empty-container return, fuel and the per-container
|
||||
// hazard surcharge are sold per direction + route, so their yard
|
||||
// dropdowns narrow exactly like base freight.
|
||||
(appliesTo === "OTHER" && isRouteScopedSurcharge(values))
|
||||
) {
|
||||
const direction = String(values.tradeDirection ?? "");
|
||||
// Direction is what decides the countries, so offer nothing until it is set
|
||||
// rather than defaulting to one and letting it read as a real choice.
|
||||
if (direction === "DOMESTIC") {
|
||||
// A fuel rate's intercity lane — stays inside Ethiopia.
|
||||
// A fuel or container-hazard rate's intercity lane — stays inside Ethiopia.
|
||||
country = "Ethiopia";
|
||||
} else {
|
||||
if (direction !== "IMPORT" && direction !== "EXPORT") return [];
|
||||
@@ -613,7 +612,10 @@ const RuleEngineResourcePage = () => {
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{config.slug === "transit-agents" ? (
|
||||
{/* No invite for an Ethiopian agent: it is a freight forwarder
|
||||
and registers itself on the portal — the API refuses it too. */}
|
||||
{config.slug === "transit-agents" &&
|
||||
(row.original as unknown as TransitAgent).country !== "ET" ? (
|
||||
<TransitAgentAccountAction
|
||||
agent={row.original as unknown as TransitAgent}
|
||||
disabled={!canUpdateControls}
|
||||
|
||||
@@ -10,8 +10,8 @@ export type ColumnFormat =
|
||||
| "boolean"
|
||||
| "activeBadge"
|
||||
| "rateStatus"
|
||||
| "validityBadge"
|
||||
| "accountBadge"
|
||||
| "country"
|
||||
| "date"
|
||||
| "number"
|
||||
| "currency"
|
||||
@@ -307,19 +307,34 @@ export const ROUTE_SCOPED_TRIGGERS = [
|
||||
];
|
||||
|
||||
/**
|
||||
* Rates priced per leg: base rail freight, plus the customs clearance fees and
|
||||
* the empty-container return surcharge (sold per route + container type).
|
||||
* The container hazardous-cargo surcharge: HAZARDOUS billed per container. It
|
||||
* is sold per direction + lane, optionally per container type (20ft / 40ft),
|
||||
* exactly like the empty-return service. The per-ton (bulk) hazard rate is
|
||||
* global and unscoped — the unit is what tells the two shapes apart (mirrors
|
||||
* the API's `isContainerHazardRate`).
|
||||
*/
|
||||
export const isContainerHazardRate = (values: Record<string, unknown>) =>
|
||||
String(values.trigger ?? "") === "HAZARDOUS" &&
|
||||
String(values.rateUnit ?? "") === "PER_CONTAINER";
|
||||
|
||||
/** Surcharge triggers/shapes sold per origin → destination leg. */
|
||||
export const isRouteScopedSurcharge = (values: Record<string, unknown>) =>
|
||||
ROUTE_SCOPED_TRIGGERS.includes(String(values.trigger ?? "")) ||
|
||||
isContainerHazardRate(values);
|
||||
|
||||
/**
|
||||
* Rates priced per leg: base rail freight, plus the customs clearance fees,
|
||||
* the empty-container return surcharge and the per-container hazard surcharge
|
||||
* (both sold per route + container type).
|
||||
*/
|
||||
const isRouteScopedRate = (values: Record<string, unknown>) =>
|
||||
// A shipping line's base freight is priced per leg exactly like a customer's;
|
||||
// its surcharges are route-scoped on the same triggers.
|
||||
(isShippingLineRate(values)
|
||||
? hasShippingLine(values) &&
|
||||
(values.shippingLineRateKind === "BASE" ||
|
||||
ROUTE_SCOPED_TRIGGERS.includes(String(values.trigger ?? "")))
|
||||
(values.shippingLineRateKind === "BASE" || isRouteScopedSurcharge(values))
|
||||
: isBaseFreightRate(values)) ||
|
||||
(String(values.appliesTo ?? "") === "OTHER" &&
|
||||
ROUTE_SCOPED_TRIGGERS.includes(String(values.trigger ?? "")));
|
||||
(String(values.appliesTo ?? "") === "OTHER" && isRouteScopedSurcharge(values));
|
||||
|
||||
/**
|
||||
* Surcharges sold per cargo kind: the admin says container or bulk, then names
|
||||
@@ -362,9 +377,12 @@ const unitsForShape = (
|
||||
case "OVERWEIGHT":
|
||||
return ["PER_TON"];
|
||||
case "REEFER":
|
||||
case "HAZARDOUS":
|
||||
case "DEMURRAGE":
|
||||
return ["PER_CONTAINER", "PER_TON"];
|
||||
case "HAZARDOUS":
|
||||
// Per container = the lane-sold container surcharge (direction + route,
|
||||
// optional box size); per ton = the global bulk surcharge.
|
||||
return ["PER_CONTAINER", "PER_TON"];
|
||||
case "WITH_RETURN":
|
||||
// Container-only service — per returned container, per wagon, or flat.
|
||||
return ["PER_CONTAINER", "PER_WAGON", "FLAT"];
|
||||
@@ -667,21 +685,14 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
label: "Transit Agents",
|
||||
category: "configuration",
|
||||
subtitle:
|
||||
"Djibouti transit officers GL Djibouti may assign to a shipment — each carries a validity window",
|
||||
"Transit officers GL Djibouti may assign to a shipment, and the Ethiopian roster a freight forwarder registers itself against",
|
||||
searchPlaceholder: "Search transit agents by name...",
|
||||
cardTitleKey: "name",
|
||||
columns: [
|
||||
{ id: "name", header: "Name", accessorKey: "name" },
|
||||
{ id: "country", header: "Country", accessorKey: "country", format: "country" },
|
||||
{ id: "email", header: "Email", accessorKey: "email" },
|
||||
{ id: "phoneNumber", header: "Phone", accessorKey: "phoneNumber" },
|
||||
{ id: "validFrom", header: "Valid from", accessorKey: "validFrom", format: "date" },
|
||||
{ id: "validTo", header: "Valid to", accessorKey: "validTo", format: "date" },
|
||||
{
|
||||
id: "validityStatus",
|
||||
header: "Validity",
|
||||
accessorKey: "validityStatus",
|
||||
format: "validityBadge",
|
||||
},
|
||||
{
|
||||
id: "hasAccount",
|
||||
header: "Portal account",
|
||||
@@ -692,19 +703,27 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
],
|
||||
formFields: [
|
||||
{ name: "name", label: "Name", type: "text", required: true },
|
||||
{ name: "validFrom", label: "Valid from", type: "date", required: true },
|
||||
{
|
||||
name: "validTo",
|
||||
label: "Valid to",
|
||||
type: "date",
|
||||
name: "country",
|
||||
label: "Country",
|
||||
type: "select",
|
||||
required: true,
|
||||
description: "Expired or not-yet-started agents can't be assigned — extend the dates or add a new one",
|
||||
options: [
|
||||
{ label: "Djibouti", value: "DJ" },
|
||||
{ label: "Ethiopia", value: "ET" },
|
||||
],
|
||||
description:
|
||||
"Ethiopian agents are also the list a customer picks itself from when registering as a freight forwarder — the two are the same business. They carry no email, phone or portal account here: the forwarder registers with its own on the portal.",
|
||||
},
|
||||
// Hidden for an Ethiopian agent: it is a freight forwarder and signs up on
|
||||
// the portal with its own email and phone. An account minted here would
|
||||
// claim that email first and its own registration would then be refused.
|
||||
{
|
||||
name: "email",
|
||||
label: "Email",
|
||||
type: "email",
|
||||
optional: true,
|
||||
hideWhen: { field: "country", equals: ["ET"] },
|
||||
description:
|
||||
"Filling this on create makes the portal account and emails the activation link. On an existing agent, use the Invite button instead — editing here only corrects the address.",
|
||||
},
|
||||
@@ -713,9 +732,10 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
label: "Phone number",
|
||||
type: "phone",
|
||||
optional: true,
|
||||
description: "Ethiopian and Djiboutian mobiles also receive the link by SMS",
|
||||
hideWhen: { field: "country", equals: ["ET"] },
|
||||
description: "Djiboutian mobiles also receive the link by SMS",
|
||||
},
|
||||
{ name: "isActive", label: "Active", type: "boolean", description: "Off suspends the officer regardless of the validity window" },
|
||||
{ name: "isActive", label: "Active", type: "boolean", description: "Off suspends the officer — it disappears from the assignment and forwarder lists" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -1306,9 +1326,9 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
hasShippingLine(v) && v.shippingLineRateKind === "SURCHARGE",
|
||||
},
|
||||
// ── Trade direction — Bulk & Container base freight, plus the directed
|
||||
// surcharges (customs clearance, cancellation, lashing, fuel; empty-
|
||||
// container return, which is import-only for now so export is not
|
||||
// offered) ─────────────────────────────────────────────────────────────
|
||||
// surcharges (customs clearance, cancellation, lashing, fuel, the
|
||||
// per-container hazard surcharge; empty-container return, which is
|
||||
// import-only for now so export is not offered) ───────────────────────
|
||||
{
|
||||
name: "tradeDirection",
|
||||
label: "Trade direction",
|
||||
@@ -1321,7 +1341,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
String(v.trigger ?? "") === "WITH_RETURN")
|
||||
? TRADE_DIRECTIONS.filter((d) => d.value === "IMPORT")
|
||||
: String(v.appliesTo ?? "") === "OTHER" &&
|
||||
String(v.trigger ?? "") === "FUEL"
|
||||
(String(v.trigger ?? "") === "FUEL" || isContainerHazardRate(v))
|
||||
? FUEL_TRADE_DIRECTIONS
|
||||
: TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"),
|
||||
showIf: (v) =>
|
||||
@@ -1330,14 +1350,15 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
String(v.appliesTo ?? ""),
|
||||
) ||
|
||||
(String(v.appliesTo ?? "") === "OTHER" &&
|
||||
[
|
||||
([
|
||||
"CUSTOMS_CLEARANCE",
|
||||
"ETHIOPIAN_CUSTOMS_CLEARANCE",
|
||||
"CANCELLATION",
|
||||
"WITH_RETURN",
|
||||
"LASHING",
|
||||
"FUEL",
|
||||
].includes(String(v.trigger ?? "")))),
|
||||
].includes(String(v.trigger ?? "")) ||
|
||||
isContainerHazardRate(v)))),
|
||||
},
|
||||
// Shipping lines only ever ship import — the export leg is sold through
|
||||
// the customer's contract — so the direction is stated, not asked. Shown
|
||||
@@ -1517,8 +1538,9 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
v.appliesTo === "LAST_MILE" &&
|
||||
(v.lastMileMode === "CONTAINER" || v.lastMileMode === "BULK"),
|
||||
},
|
||||
// ── Container type — Container freight, container-kind intercity, and
|
||||
// the empty-container return surcharge (20ft vs 40ft price differently) ─
|
||||
// ── Container type — Container freight, container-kind intercity, the
|
||||
// empty-container return surcharge and the per-container hazard
|
||||
// surcharge (20ft vs 40ft price differently; empty = the lane catch-all) ─
|
||||
{
|
||||
name: "containerTypeId",
|
||||
label: "Container type",
|
||||
@@ -1529,7 +1551,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
!isShippingLineRate(v) &&
|
||||
(v.appliesTo === "CONTAINER" ||
|
||||
(v.appliesTo === "INTERCITY" && v.intercityKind === "CONTAINER") ||
|
||||
(v.appliesTo === "OTHER" && v.trigger === "WITH_RETURN")),
|
||||
(v.appliesTo === "OTHER" &&
|
||||
(v.trigger === "WITH_RETURN" || isContainerHazardRate(v)))),
|
||||
},
|
||||
// Empty freight has no cargo to narrow by, so the box size IS the scope —
|
||||
// required here, unlike the laden catch-all above. The API rejects an
|
||||
@@ -1634,7 +1657,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
required: true,
|
||||
optionsFromValues: rateUnitOptions,
|
||||
description:
|
||||
"Weighting basis — options depend on what the rate applies to, and for bulk on how the picked commodity is counted (per ton or per item).",
|
||||
"Weighting basis — options depend on what the rate applies to, and for bulk on how the picked commodity is counted (per ton or per item). Hazardous: per container is sold per direction + route (optionally per 20ft / 40ft); per ton is the global bulk surcharge.",
|
||||
showIf: (v) =>
|
||||
String(v.trigger ?? "") !== "OVERWEIGHT" &&
|
||||
String(v.appliesTo ?? "") !== "LAST_MILE",
|
||||
|
||||
@@ -5,8 +5,7 @@ import type { ResetChannel } from "../types/shippingLineCompany";
|
||||
export interface TransitAgent {
|
||||
id: string;
|
||||
name: string;
|
||||
validFrom: string;
|
||||
validTo: string;
|
||||
country: "ET" | "DJ";
|
||||
isActive: boolean;
|
||||
/** Null on every agent that exists only as a GL-assignable roster entry. */
|
||||
email?: string | null;
|
||||
@@ -29,7 +28,7 @@ export interface ActivationSendResult {
|
||||
}
|
||||
|
||||
export const transitAgentsService = {
|
||||
/** Active + currently inside its validity window — the assignment dropdown. */
|
||||
/** Every active agent — the assignment dropdown. */
|
||||
async listAssignable() {
|
||||
const response = await apiClient.get<TransitAgent[]>(
|
||||
URL_CONSTANTS.RULE_ENGINE.TRANSIT_AGENTS_ASSIGNABLE,
|
||||
|
||||
@@ -32,7 +32,8 @@ export type ProfileType =
|
||||
| "exporter"
|
||||
| "freight_forwarder"
|
||||
| "dj_freight_forwarder"
|
||||
| "transporter";
|
||||
| "transporter"
|
||||
| "transit_agent";
|
||||
|
||||
/** Mirrors backend `ProfileStatus`. */
|
||||
export type ProfileStatus =
|
||||
@@ -280,6 +281,13 @@ export interface Company {
|
||||
* them against the investment licence on the Documents tab.
|
||||
*/
|
||||
investorLicence?: boolean;
|
||||
/**
|
||||
* The transit-agent roster entry a freight forwarder registered itself as —
|
||||
* the two are one business. Null for importers/exporters and for forwarders
|
||||
* linked before the field existed.
|
||||
*/
|
||||
transitAgentId?: string | null;
|
||||
transitAgent?: { id: string; name: string } | null;
|
||||
address?: string | null;
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AppLayout, type SidebarItem } from "@/components/AppLayout";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import {
|
||||
ClipboardList,
|
||||
Home,
|
||||
Layers,
|
||||
LayoutDashboard,
|
||||
@@ -74,6 +75,7 @@ import {
|
||||
TransitAgentBookingDetailPage,
|
||||
TransitAgentOverviewPage,
|
||||
} from "./pages/transit-agent";
|
||||
import { AssignedBookingsPage } from "./pages/forwarder";
|
||||
import FaqPage from "./pages/support/FaqPage";
|
||||
import HelpPage from "./pages/support/HelpPage";
|
||||
import PrivacyPolicyPage from "./pages/support/PrivacyPolicyPage";
|
||||
@@ -150,8 +152,13 @@ function isOnboardingAllowedPath(pathname: string): boolean {
|
||||
* captured, so there is nothing for them to onboard — they go straight to home.
|
||||
*/
|
||||
function OnboardingGate() {
|
||||
const { company, onboardingCompleted, isShippingLine, isTransitAgent } =
|
||||
useAuth();
|
||||
const {
|
||||
company,
|
||||
onboardingCompleted,
|
||||
isShippingLine,
|
||||
isTransitAgent,
|
||||
isTransitAgentOnly,
|
||||
} = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
// Keyed off a positive shipping-line / transit-agent identification, never
|
||||
@@ -186,6 +193,14 @@ function OnboardingGate() {
|
||||
if (needsOnboarding && !allowedHere) {
|
||||
return <Navigate to="/portal" replace />;
|
||||
}
|
||||
// Onboarded and nothing but a transit agent: the trade pages are not theirs.
|
||||
if (
|
||||
!needsOnboarding &&
|
||||
isTransitAgentOnly &&
|
||||
isTradeOnlyPath(location.pathname)
|
||||
) {
|
||||
return <Navigate to={ASSIGNED_BOOKINGS_PATH} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -242,7 +257,8 @@ function RequireTransitAgent() {
|
||||
* still in flight, which would land a shipping line on the customer home first.
|
||||
*/
|
||||
function useHomeRoute(): { ready: boolean; href: string } {
|
||||
const { isShippingLine, isTransitAgent, customerQuery } = useAuth();
|
||||
const { isShippingLine, isTransitAgent, isTransitAgentOnly, customerQuery } =
|
||||
useAuth();
|
||||
|
||||
return {
|
||||
ready: !customerQuery.isPending,
|
||||
@@ -250,7 +266,9 @@ function useHomeRoute(): { ready: boolean; href: string } {
|
||||
? "/shipping-line"
|
||||
: isTransitAgent
|
||||
? "/transit-agent"
|
||||
: "/portal",
|
||||
: isTransitAgentOnly
|
||||
? ASSIGNED_BOOKINGS_PATH
|
||||
: "/portal",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -280,6 +298,30 @@ function LandingRoute() {
|
||||
return <EDRFreightLandingPage />;
|
||||
}
|
||||
|
||||
/** Where a transit agent / forwarder sees the bookings customers assign to it. */
|
||||
const ASSIGNED_BOOKINGS_PATH = "/forwarder/assigned-bookings";
|
||||
const ASSIGNED_BOOKINGS_ITEM: SidebarItem = {
|
||||
label: "Assigned Bookings",
|
||||
href: ASSIGNED_BOOKINGS_PATH,
|
||||
icon: <ClipboardList size={18} />,
|
||||
};
|
||||
|
||||
/**
|
||||
* Pages a transit-agent-only company has no business on: they all show the
|
||||
* company's OWN trade, and it has none. Visiting one lands on the list.
|
||||
*/
|
||||
const TRADE_ONLY_PATHS = [
|
||||
"/portal",
|
||||
"/contracts",
|
||||
"/bookings",
|
||||
"/billing",
|
||||
"/tracking",
|
||||
];
|
||||
function isTradeOnlyPath(pathname: string): boolean {
|
||||
const path = pathname.toLowerCase();
|
||||
return TRADE_ONLY_PATHS.some((p) => path === p || path.startsWith(p + "/"));
|
||||
}
|
||||
|
||||
const sidebarItems: SidebarItem[] = [
|
||||
{ label: "Home", href: "/portal", icon: <Home size={18} /> },
|
||||
{
|
||||
@@ -377,6 +419,8 @@ const App = () => {
|
||||
isAuthenticated,
|
||||
isShippingLine,
|
||||
isTransitAgent,
|
||||
canSeeAssignedBookings,
|
||||
isTransitAgentOnly,
|
||||
} = useAuth();
|
||||
|
||||
// Attribute replays and exceptions to the signed-in user (id/org only).
|
||||
@@ -397,6 +441,20 @@ const App = () => {
|
||||
const displayName = user?.name?.en || user?.username || user?.email || "User";
|
||||
const userEmail = user?.email;
|
||||
const companyProfiles = company?.company?.companyProfiles ?? [];
|
||||
// Only a roster company (transit agent / forwarder) has bookings assigned
|
||||
// to it, so only it gets the tab — slotted right after Bookings, where it
|
||||
// reads as more of the same. A transit-agent-only company has no contracts,
|
||||
// bookings or invoices of its own, so those tabs go and the list leads.
|
||||
const customerSidebarItems: SidebarItem[] = isTransitAgentOnly
|
||||
? [
|
||||
ASSIGNED_BOOKINGS_ITEM,
|
||||
...sidebarItems.filter((i) => i.section === "Account"),
|
||||
]
|
||||
: canSeeAssignedBookings
|
||||
? sidebarItems.flatMap((item) =>
|
||||
item.href === "/bookings" ? [item, ASSIGNED_BOOKINGS_ITEM] : [item],
|
||||
)
|
||||
: sidebarItems;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -562,14 +620,24 @@ const App = () => {
|
||||
element={
|
||||
<AppLayout
|
||||
title="EDR Freight"
|
||||
sidebarItems={sidebarItems}
|
||||
sidebarItems={customerSidebarItems}
|
||||
activeHref={location.pathname}
|
||||
onNavigate={navigate}
|
||||
userName={displayName}
|
||||
userEmail={userEmail}
|
||||
companyProfiles={companyProfiles}
|
||||
companyType={companyType}
|
||||
onCreateProfile={createProfile}
|
||||
companyTransitAgentId={
|
||||
company?.company?.transitAgentId ?? null
|
||||
}
|
||||
onCreateProfile={(type, files, options) =>
|
||||
createProfile(
|
||||
type,
|
||||
files,
|
||||
undefined,
|
||||
options?.transitAgentId,
|
||||
)
|
||||
}
|
||||
onReapplyProfile={reapplyProfile}
|
||||
>
|
||||
<OnboardingGate />
|
||||
@@ -601,6 +669,12 @@ const App = () => {
|
||||
path="/bookings/:id/contract"
|
||||
element={<BookingContractPage />}
|
||||
/>
|
||||
{/* Freight forwarders only: bookings customers assigned to
|
||||
the transit agent this company registered as. */}
|
||||
<Route
|
||||
path={ASSIGNED_BOOKINGS_PATH}
|
||||
element={<AssignedBookingsPage />}
|
||||
/>
|
||||
<Route path="/contracts" element={<ContractsList />} />
|
||||
<Route path="/contracts/new" element={<NewContractPage />} />
|
||||
<Route
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Fragment, type ReactNode, useState } from "react";
|
||||
import TransitAgentSelect from "@/components/onboarding/TransitAgentSelect";
|
||||
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
|
||||
import NotificationBellContainer from "@/features/notifications/NotificationBellContainer";
|
||||
import SupportWidget from "@/features/support/SupportWidget";
|
||||
@@ -67,6 +68,12 @@ export interface AppLayoutProps {
|
||||
}[];
|
||||
/** Company type (e.g. "customer", "forwarder") — gates the "Add service" control. */
|
||||
companyType?: string | null;
|
||||
/**
|
||||
* The transit-agent roster entry the company already registered as, if any.
|
||||
* Adding a roster role (transit agent / forwarder) asks for it only when
|
||||
* this is null — the link is per company, not per role.
|
||||
*/
|
||||
companyTransitAgentId?: string | null;
|
||||
/**
|
||||
* Render the floating support-chat launcher. Defaults to true so the customer
|
||||
* portal is unaffected; shipping lines pass false — support chat is scoped to
|
||||
@@ -77,6 +84,7 @@ export interface AppLayoutProps {
|
||||
onCreateProfile?: (
|
||||
type: ServiceType,
|
||||
licenseFiles: File[],
|
||||
options?: { transitAgentId?: string },
|
||||
) => Promise<SwitchResult> | void;
|
||||
/** Resubmit a rejected service for approval, optionally replacing its license. */
|
||||
onReapplyProfile?: (
|
||||
@@ -87,14 +95,22 @@ export interface AppLayoutProps {
|
||||
}
|
||||
|
||||
/** Service profiles a customer company can operate under and switch between. */
|
||||
type ServiceType = "importer" | "exporter" | "freight_forwarder";
|
||||
type ServiceType =
|
||||
| "importer"
|
||||
| "exporter"
|
||||
| "freight_forwarder"
|
||||
| "transit_agent";
|
||||
|
||||
/** Services a customer company can select in the header. */
|
||||
const CUSTOMER_SERVICES: ServiceType[] = [
|
||||
"importer",
|
||||
"exporter",
|
||||
"freight_forwarder",
|
||||
"transit_agent",
|
||||
];
|
||||
|
||||
/** The roster roles — identified by a transit-agent entry, not a licence. */
|
||||
const AGENT_SERVICES: ServiceType[] = ["freight_forwarder", "transit_agent"];
|
||||
type SwitchResult =
|
||||
| { success: true; data?: unknown }
|
||||
| { success: false; error?: { message?: string } };
|
||||
@@ -156,6 +172,7 @@ export function AppLayout({
|
||||
userEmail,
|
||||
companyProfiles = [],
|
||||
companyType,
|
||||
companyTransitAgentId = null,
|
||||
onCreateProfile,
|
||||
onReapplyProfile,
|
||||
showSupportWidget = true,
|
||||
@@ -222,6 +239,9 @@ export function AppLayout({
|
||||
// Reason the profile was suspended/rejected, surfaced in the modal.
|
||||
const [reapplyNote, setReapplyNote] = useState<string | null>(null);
|
||||
const [licenseFiles, setLicenseFiles] = useState<File[]>([]);
|
||||
// The roster entry picked for a transit agent / forwarder service, when the
|
||||
// company has not named one yet.
|
||||
const [transitAgentId, setTransitAgentId] = useState<string | null>(null);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
const openServiceModal = (
|
||||
@@ -233,25 +253,40 @@ export function AppLayout({
|
||||
setReapplyStatus(profile?.status ?? null);
|
||||
setReapplyNote(profile?.reviewNote ?? null);
|
||||
setLicenseFiles([]);
|
||||
setTransitAgentId(null);
|
||||
setCreateError(null);
|
||||
setCreateOpen(true);
|
||||
};
|
||||
|
||||
const handleAddService = (type: ServiceType) => openServiceModal(type);
|
||||
|
||||
// A transit agent is identified by its roster entry, not a licence.
|
||||
const licenceApplies = createTarget !== "transit_agent";
|
||||
// A roster role needs the entry named once per company.
|
||||
const agentNeeded =
|
||||
AGENT_SERVICES.includes(createTarget) && !companyTransitAgentId;
|
||||
|
||||
const handleCreateConfirm = async () => {
|
||||
const isReapply = reapplyId !== null;
|
||||
// A new profile needs its license up front; a resubmit may reuse the old one.
|
||||
if (!isReapply && licenseFiles.length === 0) {
|
||||
if (!isReapply && licenceApplies && licenseFiles.length === 0) {
|
||||
setCreateError("Please upload at least one business license file.");
|
||||
return;
|
||||
}
|
||||
if (!isReapply && agentNeeded && !transitAgentId) {
|
||||
setCreateError("Pick your company from the transit agent list.");
|
||||
return;
|
||||
}
|
||||
setSwitching(true);
|
||||
setCreateError(null);
|
||||
try {
|
||||
const res = isReapply
|
||||
? await onReapplyProfile?.(reapplyId, licenseFiles)
|
||||
: await onCreateProfile?.(createTarget, licenseFiles);
|
||||
: await onCreateProfile?.(
|
||||
createTarget,
|
||||
licenseFiles,
|
||||
transitAgentId ? { transitAgentId } : undefined,
|
||||
);
|
||||
if (res && !res.success) {
|
||||
setCreateError(res.error?.message ?? "Failed to submit service");
|
||||
return;
|
||||
@@ -896,9 +931,11 @@ export function AppLayout({
|
||||
? `Your ${serviceLabel(
|
||||
createTarget,
|
||||
).toLowerCase()} service was rejected. Replace the business license if needed, then resubmit for approval.`
|
||||
: `You don't have a ${serviceLabel(
|
||||
createTarget,
|
||||
).toLowerCase()} profile yet. Add your business license to create one — it goes to EDR for approval before you can operate under it.`}
|
||||
: createTarget === "transit_agent"
|
||||
? "You don't have a transit agent profile yet. Pick your company from EDR's transit agent list to create one — it goes to EDR for approval before bookings assigned to you can be worked on."
|
||||
: `You don't have a ${serviceLabel(
|
||||
createTarget,
|
||||
).toLowerCase()} profile yet. Add your business license to create one — it goes to EDR for approval before you can operate under it.`}
|
||||
</Text>
|
||||
{isSuspendedAppeal && reapplyNote && (
|
||||
<Alert
|
||||
@@ -910,19 +947,33 @@ export function AppLayout({
|
||||
{reapplyNote}
|
||||
</Alert>
|
||||
)}
|
||||
<FileInput
|
||||
label={
|
||||
reapplyId ? "Business license (optional)" : "Business license"
|
||||
}
|
||||
multiple
|
||||
clearable
|
||||
accept="application/pdf,image/png,image/jpeg"
|
||||
leftSection={<Upload size={16} />}
|
||||
placeholder="Select license file(s)"
|
||||
value={licenseFiles}
|
||||
onChange={(files) => setLicenseFiles(files ?? [])}
|
||||
error={createError ?? undefined}
|
||||
/>
|
||||
{!reapplyId && agentNeeded ? (
|
||||
<TransitAgentSelect
|
||||
value={transitAgentId}
|
||||
onChange={setTransitAgentId}
|
||||
disabled={switching}
|
||||
error={createError && !transitAgentId ? createError : undefined}
|
||||
/>
|
||||
) : null}
|
||||
{licenceApplies ? (
|
||||
<FileInput
|
||||
label={
|
||||
reapplyId ? "Business license (optional)" : "Business license"
|
||||
}
|
||||
multiple
|
||||
clearable
|
||||
accept="application/pdf,image/png,image/jpeg"
|
||||
leftSection={<Upload size={16} />}
|
||||
placeholder="Select license file(s)"
|
||||
value={licenseFiles}
|
||||
onChange={(files) => setLicenseFiles(files ?? [])}
|
||||
error={createError ?? undefined}
|
||||
/>
|
||||
) : createError && transitAgentId ? (
|
||||
<Text size="sm" c="red">
|
||||
{createError}
|
||||
</Text>
|
||||
) : null}
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Globe2,
|
||||
PartyPopper,
|
||||
ShieldCheck,
|
||||
Truck,
|
||||
UploadCloud,
|
||||
User,
|
||||
UserCheck,
|
||||
@@ -27,6 +28,7 @@ import type { ReactNode } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import type { RoleLicenseProfile } from "@/components/onboarding/RoleLicenseStep";
|
||||
import TransitAgentSelect from "@/components/onboarding/TransitAgentSelect";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import CompanyProfileForm from "@/pages/accounts/CompanyProfileForm";
|
||||
import NationalitySelect from "@/pages/settings/NationalitySelect";
|
||||
@@ -57,9 +59,37 @@ const FORM_STEPS: FormStep[] = [
|
||||
"documents",
|
||||
];
|
||||
|
||||
/** The full onboarding journey: the two pre-form phases + the form steps. */
|
||||
type WizardStep = "nationality-role" | FormStep;
|
||||
const WIZARD_STEPS: WizardStep[] = ["nationality-role", ...FORM_STEPS];
|
||||
/**
|
||||
* The full onboarding journey: the pre-form phases + the form steps. The
|
||||
* transit-agent phase exists only for a company taking a roster role, and a
|
||||
* company that is ONLY a transit agent stops right after it — see
|
||||
* {@link wizardStepsFor}.
|
||||
*/
|
||||
type WizardStep = "nationality-role" | "transit-agent" | FormStep;
|
||||
|
||||
/**
|
||||
* A transit agent or forwarder answers one more question before the form;
|
||||
* nobody else sees it. A transit-agent-only company has no form at all: its
|
||||
* registration IS the roster entry it picks.
|
||||
*/
|
||||
function wizardStepsFor(
|
||||
needsAgent: boolean,
|
||||
transitAgentOnly: boolean,
|
||||
): WizardStep[] {
|
||||
if (transitAgentOnly) return ["nationality-role", "transit-agent"];
|
||||
return needsAgent
|
||||
? ["nationality-role", "transit-agent", ...FORM_STEPS]
|
||||
: ["nationality-role", ...FORM_STEPS];
|
||||
}
|
||||
|
||||
const FORWARDER_ROLE = "freight_forwarder";
|
||||
const TRANSIT_AGENT_ROLE = "transit_agent";
|
||||
/** The roles that are an Ethiopian transit-agent roster entry. */
|
||||
const AGENT_ROLES = [FORWARDER_ROLE, TRANSIT_AGENT_ROLE];
|
||||
const needsAgentFor = (roles: string[]) =>
|
||||
roles.some((r) => AGENT_ROLES.includes(r));
|
||||
const isTransitAgentOnlyRoles = (roles: string[]) =>
|
||||
roles.length > 0 && roles.every((r) => r === TRANSIT_AGENT_ROLE);
|
||||
|
||||
/** Icon + title + description shown in the global dialog header per step. */
|
||||
const STEP_META: Record<
|
||||
@@ -71,6 +101,12 @@ const STEP_META: Record<
|
||||
title: "Tell us about your company",
|
||||
description: "This determines the documents we'll ask you to provide.",
|
||||
},
|
||||
"transit-agent": {
|
||||
icon: <Truck size={20} />,
|
||||
title: "Your transit agent registration",
|
||||
description:
|
||||
"Transit agents and freight forwarders are registered with EDR as Ethiopian transit agents — pick your company from the list.",
|
||||
},
|
||||
company: {
|
||||
icon: <Building2 size={20} />,
|
||||
title: "Company Information",
|
||||
@@ -140,6 +176,7 @@ export default function OnboardingWizardDialog({
|
||||
const hasOperationalProfiles = existingProfiles.length > 0;
|
||||
const savedNationality =
|
||||
(company?.company?.nationality as CompanyNationality | null) ?? null;
|
||||
const savedTransitAgentId = company?.company?.transitAgentId ?? null;
|
||||
|
||||
// Resume position from the backend-persisted step.
|
||||
const resumeFormStep: FormStep = FORM_STEPS.includes(
|
||||
@@ -148,17 +185,23 @@ export default function OnboardingWizardDialog({
|
||||
? (onboardingStep as FormStep)
|
||||
: "company";
|
||||
|
||||
// Phases: nationality → role → form. If a draft already exists, resume
|
||||
// straight into the form with nationality + roles pre-selected.
|
||||
const [phase, setPhase] = useState<"nationality-role" | "form">(
|
||||
companyAlreadyStarted ? "form" : "nationality-role",
|
||||
);
|
||||
// Phases: nationality → role → (transit agent, forwarders only) → form. If a
|
||||
// draft already exists, resume straight into the form with nationality +
|
||||
// roles pre-selected.
|
||||
const [phase, setPhase] = useState<
|
||||
"nationality-role" | "transit-agent" | "form"
|
||||
>(companyAlreadyStarted ? "form" : "nationality-role");
|
||||
const [nationality, setNationality] = useState<CompanyNationality | null>(
|
||||
savedNationality,
|
||||
);
|
||||
const [roles, setRoles] = useState<string[]>(
|
||||
existingProfiles.map((p) => p.type),
|
||||
);
|
||||
// Which Ethiopian transit agent the company is — asked only of a forwarder,
|
||||
// and required before its draft can be created (the API refuses otherwise).
|
||||
const [transitAgentId, setTransitAgentId] = useState<string | null>(
|
||||
savedTransitAgentId,
|
||||
);
|
||||
const [cooperative, setCooperative] = useState<boolean>(
|
||||
company?.company?.attributes?.cooperative === true,
|
||||
);
|
||||
@@ -175,7 +218,7 @@ export default function OnboardingWizardDialog({
|
||||
const handleCooperativeChange = useCallback((checked: boolean) => {
|
||||
setCooperative(checked);
|
||||
if (checked) {
|
||||
setRoles((prev) => prev.filter((r) => r !== "freight_forwarder"));
|
||||
setRoles((prev) => prev.filter((r) => !AGENT_ROLES.includes(r)));
|
||||
// Ethiopian is then the only answer left, so it is made rather than asked.
|
||||
setNationality("ethiopian");
|
||||
// Which also rules out the investment licence — that is a foreign
|
||||
@@ -244,8 +287,9 @@ export default function OnboardingWizardDialog({
|
||||
nationality?: CompanyNationality;
|
||||
cooperative?: boolean;
|
||||
investorLicence?: boolean;
|
||||
transitAgentId?: string;
|
||||
}) => api.companies.startOnboarding.call(vars),
|
||||
onSuccess: async () => {
|
||||
onSuccess: async (_data, vars) => {
|
||||
// Nationality drives the server-resolved identity requirements (Fayda vs
|
||||
// passport), the document set and the PoA copy — all read from
|
||||
// onboardingRequirements/profile. Re-entering role selection can change
|
||||
@@ -260,6 +304,13 @@ export default function OnboardingWizardDialog({
|
||||
queryKey: api.companies.getProfile.queryKey(),
|
||||
}),
|
||||
]);
|
||||
// A transit-agent-only company is done: the API marked its onboarding
|
||||
// complete on this very call, so there is no form to go to — straight
|
||||
// to the closing panel (kept open by `completed` once the gate lets go).
|
||||
if (isTransitAgentOnlyRoles(vars.roles)) {
|
||||
setCompleted(true);
|
||||
return;
|
||||
}
|
||||
setPhase("form");
|
||||
},
|
||||
onError: (err) => setStartError(extractApiError(err).message),
|
||||
@@ -324,28 +375,62 @@ export default function OnboardingWizardDialog({
|
||||
useEffect(() => {
|
||||
if (!companyAlreadyStarted || resumedRef.current) return;
|
||||
resumedRef.current = true;
|
||||
setRoles(existingProfiles.map((p) => p.type));
|
||||
const savedRoles = existingProfiles.map((p) => p.type);
|
||||
setRoles(savedRoles);
|
||||
setNationality(savedNationality);
|
||||
setTransitAgentId(savedTransitAgentId);
|
||||
setCooperative(company?.company?.attributes?.cooperative === true);
|
||||
setInvestorLicence(company?.company?.attributes?.investorLicence === true);
|
||||
// Resume into the form only when profiles exist; otherwise send the user to
|
||||
// role selection so the missing operational profiles get created.
|
||||
setPhase(hasOperationalProfiles ? "form" : "nationality-role");
|
||||
// role selection so the missing operational profiles get created. A
|
||||
// roster-role draft that predates the transit-agent link lands on that
|
||||
// question instead, since the API will not let it finish without one.
|
||||
const agentUnlinked = needsAgentFor(savedRoles) && !savedTransitAgentId;
|
||||
setPhase(
|
||||
!hasOperationalProfiles
|
||||
? "nationality-role"
|
||||
: agentUnlinked
|
||||
? "transit-agent"
|
||||
: "form",
|
||||
);
|
||||
const idx = FORM_STEPS.indexOf(resumeFormStep);
|
||||
if (idx > furthestIdxRef.current) furthestIdxRef.current = idx;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [companyAlreadyStarted, resumeFormStep]);
|
||||
|
||||
const needsAgent = needsAgentFor(roles);
|
||||
const transitAgentOnly = isTransitAgentOnlyRoles(roles);
|
||||
|
||||
const startDraft = useCallback(
|
||||
(agentId: string | null) => {
|
||||
setStartError(null);
|
||||
startMutation.mutate({
|
||||
companyType: companyTypeForRoles(roles),
|
||||
roles: roles as ProfileTypeValue[],
|
||||
nationality: nationality ?? undefined,
|
||||
cooperative,
|
||||
investorLicence,
|
||||
...(agentId ? { transitAgentId: agentId } : {}),
|
||||
});
|
||||
},
|
||||
[roles, nationality, cooperative, investorLicence, startMutation],
|
||||
);
|
||||
|
||||
// A roster role has one more question before its draft exists; everyone
|
||||
// else goes straight to the draft.
|
||||
const handleRolesContinue = useCallback(() => {
|
||||
setStartError(null);
|
||||
startMutation.mutate({
|
||||
companyType: companyTypeForRoles(roles),
|
||||
roles: roles as ProfileTypeValue[],
|
||||
nationality: nationality ?? undefined,
|
||||
cooperative,
|
||||
investorLicence,
|
||||
});
|
||||
}, [roles, nationality, cooperative, investorLicence, startMutation]);
|
||||
if (needsAgent) {
|
||||
setPhase("transit-agent");
|
||||
return;
|
||||
}
|
||||
startDraft(null);
|
||||
}, [needsAgent, startDraft]);
|
||||
|
||||
const handleTransitAgentContinue = useCallback(() => {
|
||||
if (!transitAgentId) return;
|
||||
startDraft(transitAgentId);
|
||||
}, [transitAgentId, startDraft]);
|
||||
|
||||
// Back from the form's first step returns to nationality/role selection.
|
||||
// Safe to re-enter: startOnboarding is idempotent — it reuses the existing
|
||||
@@ -422,8 +507,12 @@ export default function OnboardingWizardDialog({
|
||||
const effectiveNationality: CompanyNationality =
|
||||
nationality ?? savedNationality ?? "ethiopian";
|
||||
|
||||
// Per-role license cards for the final step (from the created profiles).
|
||||
const roleProfiles: RoleLicenseProfile[] = existingProfiles.map((p) => ({
|
||||
// Per-role license cards for the final step (from the created profiles). A
|
||||
// transit agent holds no licence here — its roster entry is its
|
||||
// registration — so it gets no card.
|
||||
const roleProfiles: RoleLicenseProfile[] = existingProfiles
|
||||
.filter((p) => p.type !== TRANSIT_AGENT_ROLE)
|
||||
.map((p) => ({
|
||||
id: p.id,
|
||||
type: p.type,
|
||||
reference: p.reference,
|
||||
@@ -432,9 +521,10 @@ export default function OnboardingWizardDialog({
|
||||
}));
|
||||
|
||||
// The active step across the whole journey, driving the header + progress pill.
|
||||
const wizardSteps = wizardStepsFor(needsAgent, transitAgentOnly);
|
||||
const activeStep: WizardStep = phase === "form" ? formStep : phase;
|
||||
const stepMeta = STEP_META[activeStep];
|
||||
const activeIdx = WIZARD_STEPS.indexOf(activeStep);
|
||||
const activeIdx = wizardSteps.indexOf(activeStep);
|
||||
|
||||
// Prefer the backend-resolved document code; fall back to the local mapping
|
||||
// only until the requirements query lands (the documents step is reached well
|
||||
@@ -566,13 +656,16 @@ export default function OnboardingWizardDialog({
|
||||
{stepMeta.description}
|
||||
</Text>
|
||||
</Box>
|
||||
<ProgressPill current={activeIdx} total={WIZARD_STEPS.length} />
|
||||
<ProgressPill current={activeIdx} total={wizardSteps.length} />
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
>
|
||||
{completed ? (
|
||||
<OnboardingCompletePanel onClose={handleClose} />
|
||||
<OnboardingCompletePanel
|
||||
onClose={handleClose}
|
||||
transitAgentOnly={transitAgentOnly}
|
||||
/>
|
||||
) : (
|
||||
<Stack gap="xl">
|
||||
{phase === "nationality-role" ? (
|
||||
@@ -622,9 +715,9 @@ export default function OnboardingWizardDialog({
|
||||
value={roles}
|
||||
onChange={setRoles}
|
||||
embedded
|
||||
// Forwarding is licensed work — a co-op holds no licence, so
|
||||
// the role is not offered rather than refused later.
|
||||
excludeTypes={cooperative ? ["freight_forwarder"] : undefined}
|
||||
// Forwarding and transit work are licensed — a co-op holds no
|
||||
// licence, so the roles are not offered rather than refused later.
|
||||
excludeTypes={cooperative ? AGENT_ROLES : undefined}
|
||||
/>
|
||||
{startError && (
|
||||
<Text size="sm" c="red">
|
||||
@@ -647,6 +740,44 @@ export default function OnboardingWizardDialog({
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : phase === "transit-agent" ? (
|
||||
<Stack gap="lg">
|
||||
<TransitAgentSelect
|
||||
value={transitAgentId}
|
||||
onChange={setTransitAgentId}
|
||||
disabled={startMutation.isPending}
|
||||
/>
|
||||
{startError && (
|
||||
<Text size="sm" c="red">
|
||||
{startError}
|
||||
</Text>
|
||||
)}
|
||||
<Group justify="space-between" pt="xs">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
setStartError(null);
|
||||
setPhase("nationality-role");
|
||||
}}
|
||||
disabled={startMutation.isPending}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
onClick={handleTransitAgentContinue}
|
||||
disabled={!transitAgentId}
|
||||
loading={startMutation.isPending}
|
||||
rightSection={
|
||||
startMutation.isPending ? undefined : (
|
||||
<ArrowRight size={16} />
|
||||
)
|
||||
}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : (
|
||||
<CompanyProfileForm {...formProps} />
|
||||
)}
|
||||
@@ -661,7 +792,14 @@ export default function OnboardingWizardDialog({
|
||||
* and sets the expectation that their company is now under review, and that
|
||||
* bookings unlock per profile as the team approves each one.
|
||||
*/
|
||||
function OnboardingCompletePanel({ onClose }: { onClose: () => void }) {
|
||||
function OnboardingCompletePanel({
|
||||
onClose,
|
||||
transitAgentOnly = false,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
/** No documents were asked for: the roster entry is the whole application. */
|
||||
transitAgentOnly?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Stack gap="lg" align="center" py="md" ta="center">
|
||||
<Box
|
||||
@@ -677,8 +815,9 @@ function OnboardingCompletePanel({ onClose }: { onClose: () => void }) {
|
||||
<Box>
|
||||
<Title order={3}>You're all set!</Title>
|
||||
<Text c="edr-muted" size="sm" mt={4} maw={460}>
|
||||
Thanks for completing your company profile. Your application has been
|
||||
submitted and is now with our team for review.
|
||||
{transitAgentOnly
|
||||
? "Your transit agent registration has been submitted and is now with our team for review."
|
||||
: "Thanks for completing your company profile. Your application has been submitted and is now with our team for review."}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -696,8 +835,9 @@ function OnboardingCompletePanel({ onClose }: { onClose: () => void }) {
|
||||
className="mt-0.5 shrink-0 text-[var(--mantine-color-edr-green-7)]"
|
||||
/>
|
||||
<Text size="sm" ta="left">
|
||||
Each operational profile (importer, exporter, freight forwarder) is
|
||||
reviewed and approved individually.
|
||||
{transitAgentOnly
|
||||
? "Once approved, bookings that customers assign to you appear under Assigned Bookings, and you can upload their clearance documents there."
|
||||
: "Each operational profile (importer, exporter, freight forwarder, transit agent) is reviewed and approved individually."}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
@@ -706,14 +846,15 @@ function OnboardingCompletePanel({ onClose }: { onClose: () => void }) {
|
||||
className="mt-0.5 shrink-0 text-[var(--mantine-color-edr-green-7)]"
|
||||
/>
|
||||
<Text size="sm" ta="left">
|
||||
You can start creating bookings under a profile as soon as it's
|
||||
approved — we'll let you know the moment that happens.
|
||||
{transitAgentOnly
|
||||
? "You can see assigned bookings right away — we'll let you know the moment your registration is approved."
|
||||
: "You can start creating bookings under a profile as soon as it's approved — we'll let you know the moment that happens."}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Button color="edr-green" size="md" onClick={onClose} mt="xs">
|
||||
Continue to Dashboard
|
||||
{transitAgentOnly ? "Go to Assigned Bookings" : "Continue to Dashboard"}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ const ROLE_LABELS: Record<string, string> = {
|
||||
freight_forwarder: "Freight Forwarder",
|
||||
dj_freight_forwarder: "DJ Freight Forwarder",
|
||||
transporter: "Transporter",
|
||||
transit_agent: "Transit Agent",
|
||||
};
|
||||
|
||||
/** Field key the synthesized per-profile upload setting is keyed on. */
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import {
|
||||
Alert,
|
||||
Anchor,
|
||||
Button,
|
||||
Loader,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AlertCircle, Phone, SearchX } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { usePortalContent } from "@/hooks/usePortalContent";
|
||||
import { api } from "@/services/api";
|
||||
|
||||
interface TransitAgentSelectProps {
|
||||
/** The transit agent picked, if any. */
|
||||
value: string | null;
|
||||
onChange: (transitAgentId: string | null) => void;
|
||||
disabled?: boolean;
|
||||
/** Defaults read as onboarding ("which agent is your company?"). */
|
||||
label?: string;
|
||||
description?: string;
|
||||
error?: string;
|
||||
/** What to do when the agent is not listed. */
|
||||
notFoundHint?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which Ethiopian transit agent a freight forwarder IS.
|
||||
*
|
||||
* A forwarder and an Ethiopian transit agent are one business, so the role is
|
||||
* taken by picking the company's own roster entry rather than by typing a
|
||||
* name — that is what later lets GL assign work to the same row the customer
|
||||
* signs contracts under. The roster is staff-maintained, so a company that
|
||||
* is not on it cannot add itself: the fallback is the support line, shown
|
||||
* on request rather than up front so it does not read as the expected path.
|
||||
*/
|
||||
export default function TransitAgentSelect({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
label = "Which transit agent is your company? *",
|
||||
description = "Freight forwarders are registered with EDR as Ethiopian transit agents. Search for your company's name as it appears on your transit licence.",
|
||||
error,
|
||||
notFoundHint = "Once support adds you, come back here and pick your company to continue.",
|
||||
}: TransitAgentSelectProps) {
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
...api.companies.forwarderTransitAgents.queryOptions(),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: 1,
|
||||
});
|
||||
const { data: content } = usePortalContent();
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
|
||||
const options = useMemo(
|
||||
() => (data ?? []).map((a) => ({ value: a.id, label: a.name })),
|
||||
[data],
|
||||
);
|
||||
|
||||
const supportPhone = content?.contact.phone ?? "";
|
||||
const supportPhoneTel = supportPhone.replace(/\s+/g, "");
|
||||
const supportEmail = content?.contact.email ?? "";
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
<Text size="sm" c="edr-muted">
|
||||
Loading the transit agent list…
|
||||
</Text>
|
||||
<Loader size="sm" color="edr-green" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Alert color="yellow" icon={<AlertCircle size={16} />}>
|
||||
We couldn't load the transit agent list.{" "}
|
||||
<Anchor component="button" type="button" onClick={() => refetch()}>
|
||||
Try again
|
||||
</Anchor>
|
||||
.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Select
|
||||
label={label}
|
||||
description={description}
|
||||
error={error}
|
||||
placeholder={
|
||||
options.length === 0
|
||||
? "No transit agents are listed yet"
|
||||
: "Type to search by company name"
|
||||
}
|
||||
data={options}
|
||||
value={value}
|
||||
onChange={(v) => {
|
||||
onChange(v);
|
||||
if (v) setNotFound(false);
|
||||
}}
|
||||
disabled={disabled || options.length === 0}
|
||||
searchable
|
||||
clearable
|
||||
nothingFoundMessage="No transit agent matches that name"
|
||||
maxDropdownHeight={280}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="edr-green"
|
||||
size="compact-sm"
|
||||
leftSection={<SearchX size={14} />}
|
||||
onClick={() => setNotFound((open) => !open)}
|
||||
aria-expanded={notFound}
|
||||
>
|
||||
I didn't find the transit agent
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{notFound && (
|
||||
<Alert
|
||||
color="edr-green"
|
||||
variant="light"
|
||||
icon={<Phone size={16} />}
|
||||
title="Not on the list? Ask support to register you"
|
||||
>
|
||||
<Text size="sm">
|
||||
Only transit agents registered by EDR appear here. Call{" "}
|
||||
{supportPhone ? (
|
||||
<Anchor href={`tel:${supportPhoneTel}`} fw={600}>
|
||||
{supportPhone}
|
||||
</Anchor>
|
||||
) : (
|
||||
"support"
|
||||
)}
|
||||
{supportEmail && (
|
||||
<>
|
||||
{" "}
|
||||
or email{" "}
|
||||
<Anchor href={`mailto:${supportEmail}`} fw={600}>
|
||||
{supportEmail}
|
||||
</Anchor>
|
||||
</>
|
||||
)}{" "}
|
||||
with the company name and transit licence number. {notFoundHint}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -226,6 +226,10 @@ export const URL_CONSTANTS = {
|
||||
},
|
||||
|
||||
// Public — no session required; the sign-up screen links to these pages.
|
||||
TRANSIT_AGENTS_API: {
|
||||
/** Active Ethiopian transit agents (id + name) a forwarder can register as. */
|
||||
FORWARDER_OPTIONS: "/api/transit-agents/forwarder-options",
|
||||
},
|
||||
PORTAL_CONTENT: {
|
||||
PUBLIC: "/api/support-content",
|
||||
},
|
||||
|
||||
@@ -9,4 +9,5 @@ export const PROFILE_TYPE_LABELS: Record<string, string> = {
|
||||
freight_forwarder: "Freight Forwarder",
|
||||
dj_freight_forwarder: "DJ Freight Forwarder",
|
||||
transporter: "Transporter",
|
||||
transit_agent: "Transit Agent",
|
||||
};
|
||||
|
||||
@@ -213,8 +213,30 @@ const useAuth = () => {
|
||||
// as long as they have at least one backoffice-approved operational role.
|
||||
const companyProfiles = companyInfo?.company?.companyProfiles ?? [];
|
||||
const hasActiveProfile = companyProfiles.some((p) => p.status === "active");
|
||||
// The roster roles: a transit agent, or a freight forwarder (which is one
|
||||
// too). A company holding either, and linked to its roster entry, sees the
|
||||
// bookings customers assign to it. The tab shows from the moment the role is
|
||||
// requested — that is how it learns work is waiting — but acting on them
|
||||
// waits for approval (the API enforces the same split).
|
||||
const agentProfiles = companyProfiles.filter(
|
||||
(p) => p.type === "transit_agent" || p.type === "freight_forwarder",
|
||||
);
|
||||
const canSeeAssignedBookings =
|
||||
agentProfiles.length > 0 && Boolean(companyInfo?.company?.transitAgentId);
|
||||
const assignedBookingsUnlocked = agentProfiles.some(
|
||||
(p) => p.status === "active",
|
||||
);
|
||||
// A company that does nothing but act as a transit agent has no contracts,
|
||||
// bookings or invoices of its own: its portal is the assigned-bookings list.
|
||||
const isTransitAgentOnly =
|
||||
companyProfiles.length > 0 &&
|
||||
companyProfiles.every((p) => p.type === "transit_agent");
|
||||
const hasPendingProfile = companyProfiles.some((p) => p.status === "pending");
|
||||
const canBook = hasActiveProfile;
|
||||
// Booking needs a TRADE role in service — the transit agent role carries no
|
||||
// bookings of its own.
|
||||
const canBook = companyProfiles.some(
|
||||
(p) => p.status === "active" && p.type !== "transit_agent",
|
||||
);
|
||||
|
||||
// Profile-edit review: while a change request is pending the customer is
|
||||
// locked out of editing and of creating new contracts/bookings; a rejected
|
||||
@@ -249,11 +271,14 @@ const useAuth = () => {
|
||||
* by the API for any company that has an eTrade record.
|
||||
*/
|
||||
licenceNumber?: string,
|
||||
/** The roster entry, for the transit agent / forwarder roles. */
|
||||
transitAgentId?: string,
|
||||
): Promise<Result<void>> => {
|
||||
try {
|
||||
const created = await api.companies.createCompanyProfile.call({
|
||||
type,
|
||||
licenceNumber,
|
||||
transitAgentId,
|
||||
});
|
||||
if (licenseFiles.length > 0) {
|
||||
await companiesService.uploadProfileLicense(created.id, licenseFiles);
|
||||
@@ -316,6 +341,9 @@ const useAuth = () => {
|
||||
canBook,
|
||||
hasActiveProfile,
|
||||
hasPendingProfile,
|
||||
canSeeAssignedBookings,
|
||||
assignedBookingsUnlocked,
|
||||
isTransitAgentOnly,
|
||||
companyType,
|
||||
companyStatus,
|
||||
isCompanyApproved,
|
||||
|
||||
@@ -426,6 +426,7 @@ const ROLE_LABELS: Record<string, string> = {
|
||||
exporter: "Exporter",
|
||||
freight_forwarder: "Freight Forwarder",
|
||||
dj_freight_forwarder: "DJ Freight Forwarder",
|
||||
transit_agent: "Transit Agent",
|
||||
transporter: "Transporter",
|
||||
};
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useForm, Controller } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import TransitAgentSelect from "@/components/onboarding/TransitAgentSelect";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
@@ -632,15 +634,22 @@ function NewShipmentBookingForm({
|
||||
? { requestedWagons: Number(values.requestedWagons) }
|
||||
: {}),
|
||||
}),
|
||||
// Customer's own clearing agent — collected at completion; the server
|
||||
// requires all three for a without-customs import/export booking.
|
||||
...(values.customsClearingAgent?.trim()
|
||||
? {
|
||||
customsClearingAgent: values.customsClearingAgent.trim(),
|
||||
customsClearingAgentEmail: values.customsClearingAgentEmail.trim(),
|
||||
customsClearingAgentPhone: values.customsClearingAgentPhone.trim(),
|
||||
}
|
||||
: {}),
|
||||
// Who clears customs — collected at completion of a without-customs
|
||||
// import/export booking. Either a registered transit agent (the booking
|
||||
// is assigned to that forwarder) or the customer's own agent, for which
|
||||
// the server requires all three fields. Never both.
|
||||
...(values.clearingAgentMode === "transit_agent" &&
|
||||
values.transitAgentId?.trim()
|
||||
? { transitAgentId: values.transitAgentId.trim() }
|
||||
: values.customsClearingAgent?.trim()
|
||||
? {
|
||||
customsClearingAgent: values.customsClearingAgent.trim(),
|
||||
customsClearingAgentEmail:
|
||||
values.customsClearingAgentEmail.trim(),
|
||||
customsClearingAgentPhone:
|
||||
values.customsClearingAgentPhone.trim(),
|
||||
}
|
||||
: {}),
|
||||
...(values.notes ? { notes: values.notes } : {}),
|
||||
};
|
||||
}
|
||||
@@ -2081,18 +2090,66 @@ function EquipmentReturnStep({ form }: { form: ShipmentForm }) {
|
||||
|
||||
/**
|
||||
* Completion of a without-customs import/export booking: the customer names
|
||||
* their own customs clearing agent per booking — name, email and phone are
|
||||
* all required (the schema and the server both enforce it).
|
||||
* who clears customs for it, one of two ways. Pick a registered Ethiopian
|
||||
* transit agent — a freight forwarder on the platform, which then gets the
|
||||
* booking in its own work list and a notice — or type their own agent's name,
|
||||
* email and phone (all required; the schema and the server both enforce it).
|
||||
*/
|
||||
function ClearingAgentStep({ form }: { form: ShipmentForm }) {
|
||||
const mode = form.watch("clearingAgentMode");
|
||||
return (
|
||||
<StepCard>
|
||||
<StepHeader
|
||||
icon={<FileText size={22} />}
|
||||
title="Customs Clearing Agent"
|
||||
description="Your service does not include customs clearance — enter the agent handling customs for this booking."
|
||||
description="Your service does not include customs clearance — tell us who handles customs for this booking."
|
||||
/>
|
||||
<Stack gap="sm">
|
||||
<Controller
|
||||
name="clearingAgentMode"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
radius={10}
|
||||
color="edr-green"
|
||||
value={field.value}
|
||||
onChange={(v) => {
|
||||
field.onChange(v);
|
||||
// Switching clears the other option so only one is ever sent.
|
||||
if (v === "transit_agent") {
|
||||
form.setValue("customsClearingAgent", "", { shouldDirty: true });
|
||||
form.setValue("customsClearingAgentEmail", "", { shouldDirty: true });
|
||||
form.setValue("customsClearingAgentPhone", "", { shouldDirty: true });
|
||||
} else {
|
||||
form.setValue("transitAgentId", "", { shouldDirty: true });
|
||||
}
|
||||
}}
|
||||
data={[
|
||||
{ value: "transit_agent", label: "Registered transit agent" },
|
||||
{ value: "manual", label: "Enter agent details" },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{mode === "transit_agent" ? (
|
||||
<Controller
|
||||
name="transitAgentId"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TransitAgentSelect
|
||||
value={field.value || null}
|
||||
onChange={(v) => field.onChange(v ?? "")}
|
||||
label="Transit agent *"
|
||||
description="Registered Ethiopian transit agents (freight forwarders). The booking is assigned to the one you pick and they are notified."
|
||||
error={fieldState.error?.message}
|
||||
notFoundHint="Only transit agents registered with EDR are listed. Ask your forwarder to register, or switch to entering their details instead."
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
{mode === "manual" ? (
|
||||
<>
|
||||
<Controller
|
||||
name="customsClearingAgent"
|
||||
control={form.control}
|
||||
@@ -2139,6 +2196,8 @@ function ClearingAgentStep({ form }: { form: ShipmentForm }) {
|
||||
)}
|
||||
/>
|
||||
</Group>
|
||||
</>
|
||||
) : null}
|
||||
</Stack>
|
||||
</StepCard>
|
||||
);
|
||||
|
||||
@@ -119,6 +119,10 @@ const shipmentFormBase = z.object({
|
||||
customsClearingAgent: z.string().default(""),
|
||||
customsClearingAgentEmail: z.string().default(""),
|
||||
customsClearingAgentPhone: z.string().default(""),
|
||||
// The other way to name who clears customs: a registered Ethiopian transit
|
||||
// agent (a freight forwarder on the platform). One of the two, never both.
|
||||
clearingAgentMode: z.enum(["manual", "transit_agent"]).default("manual"),
|
||||
transitAgentId: z.string().default(""),
|
||||
notes: z.string().default(""),
|
||||
});
|
||||
|
||||
@@ -146,7 +150,15 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) {
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.requiresClearingAgent) {
|
||||
if (ctx.requiresClearingAgent && data.clearingAgentMode === "transit_agent") {
|
||||
if (!data.transitAgentId.trim()) {
|
||||
refineCtx.addIssue({
|
||||
code: "custom",
|
||||
path: ["transitAgentId"],
|
||||
message: "Pick the transit agent handling customs for this booking.",
|
||||
});
|
||||
}
|
||||
} else if (ctx.requiresClearingAgent) {
|
||||
if (!data.customsClearingAgent.trim()) {
|
||||
refineCtx.addIssue({
|
||||
code: "custom",
|
||||
@@ -407,6 +419,8 @@ export const initialShipmentFormValues: DeepPartial<ShipmentFormValues> = {
|
||||
customsClearingAgent: "",
|
||||
customsClearingAgentEmail: "",
|
||||
customsClearingAgentPhone: "",
|
||||
clearingAgentMode: "manual",
|
||||
transitAgentId: "",
|
||||
notes: "",
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Pagination,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
DataTable,
|
||||
usePagination,
|
||||
type ColumnDef,
|
||||
type DataTableFooterProps,
|
||||
} from "@edr/ui-common";
|
||||
import {
|
||||
Building2,
|
||||
ClipboardList,
|
||||
Clock3,
|
||||
Inbox,
|
||||
PackageCheck,
|
||||
Paperclip,
|
||||
RefreshCw,
|
||||
Search,
|
||||
ShipWheel,
|
||||
Truck,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import {
|
||||
transitAssignmentsService,
|
||||
type TransitAssignment,
|
||||
type TransitAssignmentStatus,
|
||||
} from "@/services/transit-assignments.service";
|
||||
|
||||
const headerCell =
|
||||
"whitespace-nowrap text-[10px] font-semibold uppercase tracking-[0.08em] text-edr-muted";
|
||||
|
||||
const prettyStatus = (s?: string | null) =>
|
||||
(s ?? "")
|
||||
.toLowerCase()
|
||||
.replace(/_/g, " ")
|
||||
.replace(/^\w/, (c) => c.toUpperCase());
|
||||
|
||||
const STATUS_META: Record<
|
||||
TransitAssignmentStatus,
|
||||
{ label: string; color: string }
|
||||
> = {
|
||||
NOT_STARTED: { label: "Not started", color: "gray" },
|
||||
IN_PROGRESS: { label: "In progress", color: "blue" },
|
||||
FINISHED: { label: "Finished", color: "edr-green" },
|
||||
};
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: "NOT_STARTED", label: "Not started" },
|
||||
{ value: "IN_PROGRESS", label: "In progress" },
|
||||
{ value: "FINISHED", label: "Finished" },
|
||||
];
|
||||
|
||||
function shipmentColor(status?: string | null): string {
|
||||
switch (status) {
|
||||
case "DISPATCHED":
|
||||
return "teal";
|
||||
case "SCHEDULED":
|
||||
return "blue";
|
||||
case "MANUAL_ONLY":
|
||||
return "orange";
|
||||
default:
|
||||
return "gray";
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null): string {
|
||||
if (!value) return "—";
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime())
|
||||
? "—"
|
||||
: d.toLocaleDateString(undefined, {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
/** DataTable footer: row range left, rows-per-page + pager right. */
|
||||
function TablePager<T>({ table, pagination }: DataTableFooterProps<T>) {
|
||||
const pageIndex = pagination.pageIndex ?? 0;
|
||||
const pageSize = pagination.pageSize ?? 10;
|
||||
const total = pagination.totalCount ?? 0;
|
||||
const pageCount = Math.max(
|
||||
1,
|
||||
pagination.pageCount ?? Math.ceil(total / pageSize),
|
||||
);
|
||||
const start = total === 0 ? 0 : pageIndex * pageSize + 1;
|
||||
const end = Math.min((pageIndex + 1) * pageSize, total);
|
||||
|
||||
return (
|
||||
<Group
|
||||
justify="space-between"
|
||||
gap="sm"
|
||||
wrap="wrap"
|
||||
px="md"
|
||||
py={10}
|
||||
style={{ borderTop: "1px solid var(--mantine-color-edr-divider-6)" }}
|
||||
>
|
||||
<Text fz={12} c="edr-muted">
|
||||
Showing {start}–{end} of {total} bookings
|
||||
</Text>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text fz={12} c="edr-muted">
|
||||
Rows
|
||||
</Text>
|
||||
<Select
|
||||
size="xs"
|
||||
w={70}
|
||||
radius="md"
|
||||
value={String(pageSize)}
|
||||
data={["10", "25", "50"]}
|
||||
onChange={(v) => v && table.setPageSize(Number(v))}
|
||||
allowDeselect={false}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
aria-label="Rows per page"
|
||||
/>
|
||||
</Group>
|
||||
<Pagination
|
||||
size="sm"
|
||||
radius="md"
|
||||
color="edr-ink"
|
||||
total={pageCount}
|
||||
value={pageIndex + 1}
|
||||
onChange={(p) => table.setPageIndex(p - 1)}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The freight forwarder's work list: bookings customers assigned to the
|
||||
* transit agent this company registered itself as, read through the same
|
||||
* `/transit-assignments/my` endpoint the Djibouti transit officer uses.
|
||||
*
|
||||
* List only for now — no detail page. The forwarder sees the work from the
|
||||
* moment its role is requested; documents and other actions unlock once the
|
||||
* role is approved, which the banner says.
|
||||
*/
|
||||
export default function AssignedBookingsPage() {
|
||||
const { assignedBookingsUnlocked } = useAuth();
|
||||
const [query, setQuery] = useState("");
|
||||
const [debouncedQuery] = useDebouncedValue(query.trim(), 300);
|
||||
const [status, setStatus] = useState<TransitAssignmentStatus | null>(null);
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
|
||||
const { data, isPending, isError, error, isFetching, refetch } = useQuery({
|
||||
queryKey: [
|
||||
"transit-assignments",
|
||||
{
|
||||
search: debouncedQuery,
|
||||
status,
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
},
|
||||
],
|
||||
queryFn: () =>
|
||||
transitAssignmentsService.list({
|
||||
search: debouncedQuery || undefined,
|
||||
status: status ?? undefined,
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const items = data?.items ?? [];
|
||||
const total = data?.meta?.total ?? 0;
|
||||
const pageCount = data?.meta?.totalPages ?? 1;
|
||||
const hasFilters = Boolean(debouncedQuery) || Boolean(status);
|
||||
|
||||
const columns: ColumnDef<TransitAssignment, unknown>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "booking",
|
||||
header: () => <span className={headerCell}>Booking</span>,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 py-1">
|
||||
<div className="flex size-[30px] shrink-0 items-center justify-center rounded-[9px] bg-edr-divider text-edr-muted">
|
||||
<PackageCheck size={15} strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<Text fz={13} fw={600} c="edr-text">
|
||||
{r.booking?.reference ?? "—"}
|
||||
</Text>
|
||||
<Group gap={4} wrap="nowrap" align="flex-start">
|
||||
<Building2
|
||||
size={10}
|
||||
className="mt-[3px] shrink-0 text-edr-muted opacity-70"
|
||||
/>
|
||||
<Text fz={11} c="edr-muted">
|
||||
{r.customerName ?? "—"}
|
||||
</Text>
|
||||
</Group>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "shipment",
|
||||
header: () => <span className={headerCell}>Shipment</span>,
|
||||
cell: ({ row }) => {
|
||||
const b = row.original.booking;
|
||||
const isImport = b?.tradeDirection === "IMPORT";
|
||||
const Icon = isImport ? Truck : ShipWheel;
|
||||
return (
|
||||
<Stack gap={5} py={2}>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
radius="sm"
|
||||
color={shipmentColor(b?.schedulingStatus)}
|
||||
>
|
||||
{prettyStatus(b?.schedulingStatus) || "—"}
|
||||
</Badge>
|
||||
{b?.tradeDirection ? (
|
||||
<span
|
||||
className="inline-flex w-fit items-center gap-1 rounded-[5px] px-1.5 py-[2px] text-[10px] font-medium leading-none"
|
||||
style={{
|
||||
background: `var(--mantine-color-${isImport ? "blue" : "teal"}-0)`,
|
||||
color: `var(--mantine-color-${isImport ? "blue" : "teal"}-7)`,
|
||||
}}
|
||||
>
|
||||
<Icon size={10} />
|
||||
{prettyStatus(b.tradeDirection)}
|
||||
</span>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: () => <span className={headerCell}>Status</span>,
|
||||
cell: ({ row }) => {
|
||||
const m = STATUS_META[row.original.status];
|
||||
return (
|
||||
<Badge size="sm" variant="light" radius="sm" color={m.color}>
|
||||
{m.label}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "assignedAt",
|
||||
header: () => <span className={headerCell}>Assigned</span>,
|
||||
cell: ({ row }) => (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Clock3 size={12} className="shrink-0 text-edr-muted" />
|
||||
<Text fz={12} c="edr-text">
|
||||
{formatDate(row.original.assignedAt)}
|
||||
</Text>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "documents",
|
||||
header: () => <span className={headerCell}>Documents</span>,
|
||||
cell: ({ row }) => (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Paperclip size={12} className="shrink-0 text-edr-muted" />
|
||||
<Text fz={12} c="edr-text">
|
||||
{row.original.files?.length ?? 0}
|
||||
</Text>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Group gap="sm" align="center">
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-edr-soft text-edr-green-7">
|
||||
<ClipboardList size={20} />
|
||||
</div>
|
||||
<Box>
|
||||
<Title order={2}>Assigned Bookings</Title>
|
||||
<Text c="edr-muted" size="sm">
|
||||
Bookings customers have assigned to you for customs clearance.
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Button
|
||||
variant="light"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
leftSection={<RefreshCw size={14} />}
|
||||
loading={isFetching && !isPending}
|
||||
onClick={() => void refetch()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{!assignedBookingsUnlocked ? (
|
||||
<Alert color="yellow" variant="light" radius="md">
|
||||
Your transit agent registration is still under review. You can see
|
||||
the bookings assigned to you, but uploading documents and other
|
||||
actions unlock once it is approved.
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Card
|
||||
withBorder
|
||||
shadow="sm"
|
||||
radius="lg"
|
||||
p={0}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<Group
|
||||
gap="sm"
|
||||
wrap="wrap"
|
||||
px="md"
|
||||
py={12}
|
||||
style={{
|
||||
borderBottom: "1px solid var(--mantine-color-edr-divider-6)",
|
||||
}}
|
||||
>
|
||||
<TextInput
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.currentTarget.value);
|
||||
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||
}}
|
||||
placeholder="Search by booking reference or customer"
|
||||
leftSection={<Search size={14} />}
|
||||
rightSection={
|
||||
query ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Clear search"
|
||||
className="text-edr-muted"
|
||||
onClick={() => setQuery("")}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
) : null
|
||||
}
|
||||
radius="md"
|
||||
size="sm"
|
||||
w={{ base: "100%", sm: 320 }}
|
||||
/>
|
||||
<Select
|
||||
value={status}
|
||||
onChange={(v) => {
|
||||
setStatus((v as TransitAssignmentStatus | null) ?? null);
|
||||
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||
}}
|
||||
data={STATUS_OPTIONS}
|
||||
placeholder="All statuses"
|
||||
clearable
|
||||
radius="md"
|
||||
size="sm"
|
||||
w={{ base: "100%", sm: 180 }}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{!isPending && !isError && items.length === 0 ? (
|
||||
<Stack align="center" gap="xs" py={48}>
|
||||
<Inbox size={28} className="text-edr-muted" />
|
||||
<Text fw={600} c="edr-text">
|
||||
{hasFilters ? "No bookings match" : "No bookings assigned yet"}
|
||||
</Text>
|
||||
<Text fz={13} c="edr-muted" ta="center" maw={420}>
|
||||
{hasFilters
|
||||
? "Try a different reference or clear the filters."
|
||||
: "When a customer picks your company as the transit agent on a booking, it shows up here and you are notified."}
|
||||
</Text>
|
||||
{hasFilters ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
setStatus(null);
|
||||
}}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
) : null}
|
||||
</Stack>
|
||||
) : (
|
||||
<Box w="100%" miw={0}>
|
||||
<DataTable<TransitAssignment, unknown>
|
||||
columns={columns}
|
||||
data={items}
|
||||
status={isPending ? "loading" : isError ? "error" : "success"}
|
||||
error={
|
||||
isError
|
||||
? {
|
||||
message: (error as Error).message,
|
||||
onRetry: () => void refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none rounded-none bg-transparent"
|
||||
footer={(p) => <TablePager {...p} />}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
1
apps/edr-freight-web/portal/src/pages/forwarder/index.ts
Normal file
1
apps/edr-freight-web/portal/src/pages/forwarder/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as AssignedBookingsPage } from "./AssignedBookingsPage";
|
||||
@@ -15,6 +15,12 @@ import EtradeBusinessSelect, {
|
||||
businessLabel,
|
||||
useEtradeBusinesses,
|
||||
} from "@/components/onboarding/EtradeBusinessSelect";
|
||||
import TransitAgentSelect from "@/components/onboarding/TransitAgentSelect";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import { AGENT_ROLE_TYPES } from "./companyRoles";
|
||||
|
||||
const isAgentRole = (type: string) =>
|
||||
(AGENT_ROLE_TYPES as readonly string[]).includes(type);
|
||||
import type { CompanyProfileResponse } from "@/services/companies.service";
|
||||
import type { ProfileResponse } from "@/types/profile";
|
||||
import RoleCard from "./RoleCard";
|
||||
@@ -52,6 +58,11 @@ function roleStatusView(p: CompanyProfileResponse): {
|
||||
|
||||
export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
|
||||
const queryClient = useQueryClient();
|
||||
// The roster entry the company already named, if any. A roster role added
|
||||
// here asks for it only when there is none — the link is per company.
|
||||
const { company } = useAuth();
|
||||
const linkedTransitAgentId = company?.company?.transitAgentId ?? null;
|
||||
const [transitAgentId, setTransitAgentId] = useState<string | null>(null);
|
||||
|
||||
const options = useMemo(
|
||||
() => rolesForCompanyType(profile.companyType),
|
||||
@@ -115,11 +126,13 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
|
||||
profiles: types.map((type) => ({
|
||||
type,
|
||||
licenceNumber: licenceByType[type],
|
||||
...(isAgentRole(type) && transitAgentId ? { transitAgentId } : {}),
|
||||
})),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setSelected(new Set());
|
||||
setLicenceByType({});
|
||||
setTransitAgentId(null);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.companies.getProfile.queryKey(),
|
||||
});
|
||||
@@ -145,14 +158,22 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
|
||||
},
|
||||
});
|
||||
|
||||
// Every selected role needs its business named first — the API rejects a role
|
||||
// added without one, so the button is what tells the user, not a 400.
|
||||
// Every selected licensed role needs its business named first — the API
|
||||
// rejects a role added without one, so the button is what tells the user,
|
||||
// not a 400. A transit agent holds no business: its roster entry is its
|
||||
// registration.
|
||||
const missingLicence =
|
||||
businessRequired &&
|
||||
Array.from(selected).some((type) => !licenceByType[type]);
|
||||
Array.from(selected).some(
|
||||
(type) => type !== "transit_agent" && !licenceByType[type],
|
||||
);
|
||||
// A roster role needs the transit agent named, once per company.
|
||||
const agentRequired =
|
||||
!linkedTransitAgentId && Array.from(selected).some(isAgentRole);
|
||||
const missingAgent = agentRequired && !transitAgentId;
|
||||
|
||||
const handleSave = () => {
|
||||
if (selected.size === 0 || missingLicence) return;
|
||||
if (selected.size === 0 || missingLicence || missingAgent) return;
|
||||
mutation.mutate(Array.from(selected));
|
||||
};
|
||||
|
||||
@@ -164,7 +185,7 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
|
||||
</Group>
|
||||
<Text c="edr-muted" size="sm" mb="lg">
|
||||
{profile.companyType === "customer"
|
||||
? "Select the service(s) your company operates as — importer, exporter and/or freight forwarder."
|
||||
? "Select the service(s) your company operates as — importer, exporter, freight forwarder and/or transit agent."
|
||||
: "Your company's operational role."}
|
||||
</Text>
|
||||
|
||||
@@ -252,7 +273,9 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
|
||||
Say which of your eTrade business licences each new role operates as.
|
||||
</Text>
|
||||
{options
|
||||
.filter((opt) => selected.has(opt.type))
|
||||
.filter(
|
||||
(opt) => selected.has(opt.type) && opt.type !== "transit_agent",
|
||||
)
|
||||
.map((opt) => (
|
||||
<EtradeBusinessSelect
|
||||
key={opt.type}
|
||||
@@ -269,6 +292,16 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{agentRequired && (
|
||||
<Stack gap="sm" mt="lg">
|
||||
<TransitAgentSelect
|
||||
value={transitAgentId}
|
||||
onChange={setTransitAgentId}
|
||||
disabled={mutation.isPending}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{options.length > 0 && (
|
||||
<Group
|
||||
justify="space-between"
|
||||
@@ -298,7 +331,7 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
|
||||
type="button"
|
||||
leftSection={<Save size={16} />}
|
||||
loading={mutation.isPending}
|
||||
disabled={selected.size === 0 || missingLicence}
|
||||
disabled={selected.size === 0 || missingLicence || missingAgent}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{selected.size > 1 ? "Add Roles" : "Add Role"}
|
||||
|
||||
@@ -49,6 +49,7 @@ const ROLE_LABELS: Record<string, string> = {
|
||||
exporter: "Exporter",
|
||||
freight_forwarder: "Freight Forwarder",
|
||||
dj_freight_forwarder: "DJ Freight Forwarder",
|
||||
transit_agent: "Transit Agent",
|
||||
transporter: "Transporter",
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { ArrowDownToLine, ArrowUpFromLine, Building2 } from "lucide-react";
|
||||
import {
|
||||
ArrowDownToLine,
|
||||
ArrowUpFromLine,
|
||||
Building2,
|
||||
Truck,
|
||||
} from "lucide-react";
|
||||
|
||||
export interface RoleMeta {
|
||||
type: string;
|
||||
@@ -28,12 +33,29 @@ export const FREIGHT_FORWARDER: RoleMeta = {
|
||||
icon: <Building2 size={22} />,
|
||||
};
|
||||
|
||||
export const TRANSIT_AGENT: RoleMeta = {
|
||||
type: "transit_agent",
|
||||
label: "Transit Agent",
|
||||
description:
|
||||
"Clear customs for bookings that importers and exporters assign to you.",
|
||||
icon: <Truck size={22} />,
|
||||
};
|
||||
|
||||
/** The roles that are an Ethiopian transit-agent roster entry. */
|
||||
export const AGENT_ROLE_TYPES = ["freight_forwarder", "transit_agent"] as const;
|
||||
|
||||
/**
|
||||
* Importer / Exporter / Freight Forwarder — the services a "customer" company
|
||||
* can hold. A single company may register for any combination, each getting its
|
||||
* own business license.
|
||||
* Importer / Exporter / Freight Forwarder / Transit Agent — the services a
|
||||
* "customer" company can hold. A single company may register for any
|
||||
* combination; the licensed ones each get their own business licence, the
|
||||
* transit agent is identified by its roster entry instead.
|
||||
*/
|
||||
export const CUSTOMER_ROLES: RoleMeta[] = [IMPORTER, EXPORTER, FREIGHT_FORWARDER];
|
||||
export const CUSTOMER_ROLES: RoleMeta[] = [
|
||||
IMPORTER,
|
||||
EXPORTER,
|
||||
FREIGHT_FORWARDER,
|
||||
TRANSIT_AGENT,
|
||||
];
|
||||
|
||||
// dj_freight_forwarder and transporter are intentionally not exposed yet.
|
||||
export function rolesForCompanyType(companyType: string): RoleMeta[] {
|
||||
|
||||
@@ -60,6 +60,7 @@ import type {
|
||||
ChangeRequestResponse,
|
||||
CompanyDocument,
|
||||
CompanyInfoResponse,
|
||||
ForwarderTransitAgentOption,
|
||||
CompanyNationality,
|
||||
CompanyProfileResponse,
|
||||
LicenseFile,
|
||||
@@ -220,12 +221,23 @@ export const api = {
|
||||
),
|
||||
|
||||
addCompanyProfiles: endpoint<
|
||||
{ profiles: { type: string; licenceNumber?: string }[] },
|
||||
{
|
||||
profiles: {
|
||||
type: string;
|
||||
licenceNumber?: string;
|
||||
transitAgentId?: string;
|
||||
}[];
|
||||
},
|
||||
CompanyProfileResponse[]
|
||||
>("companies", "addCompanyProfiles", companiesService.addCompanyProfiles),
|
||||
|
||||
createCompanyProfile: endpoint<
|
||||
{ type: ProfileTypeValue; businessLicense?: string; licenceNumber?: string },
|
||||
{
|
||||
type: ProfileTypeValue;
|
||||
businessLicense?: string;
|
||||
licenceNumber?: string;
|
||||
transitAgentId?: string;
|
||||
},
|
||||
CompanyProfileResponse
|
||||
>(
|
||||
"companies",
|
||||
@@ -257,10 +269,18 @@ export const api = {
|
||||
cooperative?: boolean;
|
||||
/** Foreign investment licence: registration typed, no eTrade lookup. */
|
||||
investorLicence?: boolean;
|
||||
/** Which Ethiopian transit agent the company is — required with the forwarder role. */
|
||||
transitAgentId?: string;
|
||||
},
|
||||
CompanyInfoResponse
|
||||
>("companies", "startOnboarding", companiesService.startOnboarding),
|
||||
|
||||
forwarderTransitAgents: endpoint<void, ForwarderTransitAgentOption[]>(
|
||||
"companies",
|
||||
"forwarderTransitAgents",
|
||||
companiesService.listForwarderTransitAgents,
|
||||
),
|
||||
|
||||
revertToRegularCompany: endpoint<void, CompanyInfoResponse>(
|
||||
"companies",
|
||||
"revertToRegularCompany",
|
||||
|
||||
@@ -12,7 +12,8 @@ export type ProfileTypeValue =
|
||||
| "exporter"
|
||||
| "freight_forwarder"
|
||||
| "dj_freight_forwarder"
|
||||
| "transporter";
|
||||
| "transporter"
|
||||
| "transit_agent";
|
||||
|
||||
export type CompanyNationality = "ethiopian" | "foreign";
|
||||
|
||||
@@ -69,11 +70,24 @@ export interface CompanyResponse {
|
||||
email: string | null;
|
||||
website: string | null;
|
||||
attributes: Record<string, any> | null;
|
||||
/**
|
||||
* The transit-agent roster entry this company registered itself as when it
|
||||
* took the freight-forwarder role — the two are the same business. Null for
|
||||
* importers/exporters and for forwarders linked before the field existed.
|
||||
*/
|
||||
transitAgentId?: string | null;
|
||||
transitAgent?: ForwarderTransitAgentOption | null;
|
||||
companyProfiles?: CompanyProfileResponse[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** One entry of the Ethiopian transit-agent roster, as offered to a forwarder. */
|
||||
export interface ForwarderTransitAgentOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface CompanyProfileResponse {
|
||||
id: string;
|
||||
type: string;
|
||||
@@ -149,8 +163,6 @@ export interface TransitAgentInfoResponse {
|
||||
email: string | null;
|
||||
phoneNumber: string | null;
|
||||
isActive: boolean;
|
||||
validFrom: string;
|
||||
validTo: string;
|
||||
company: null;
|
||||
profile: null;
|
||||
review: null;
|
||||
@@ -369,7 +381,11 @@ export const companiesService = {
|
||||
},
|
||||
|
||||
addCompanyProfiles: async (payload: {
|
||||
profiles: { type: string; licenceNumber?: string }[];
|
||||
profiles: {
|
||||
type: string;
|
||||
licenceNumber?: string;
|
||||
transitAgentId?: string;
|
||||
}[];
|
||||
}): Promise<CompanyProfileResponse[]> => {
|
||||
const response = await client.post<ApiResponse<CompanyProfileResponse[]>>(
|
||||
URL_CONSTANTS.COMPANIES_API.COMPANY_PROFILES,
|
||||
@@ -383,6 +399,7 @@ export const companiesService = {
|
||||
type: ProfileTypeValue;
|
||||
businessLicense?: string;
|
||||
licenceNumber?: string;
|
||||
transitAgentId?: string;
|
||||
}): Promise<CompanyProfileResponse> => {
|
||||
const response = await client.post<ApiResponse<CompanyProfileResponse>>(
|
||||
URL_CONSTANTS.COMPANIES_API.COMPANY_PROFILE,
|
||||
@@ -421,6 +438,7 @@ export const companiesService = {
|
||||
nationality?: CompanyNationality;
|
||||
cooperative?: boolean;
|
||||
investorLicence?: boolean;
|
||||
transitAgentId?: string;
|
||||
}): Promise<CompanyInfoResponse> => {
|
||||
const response = await client.post<ApiResponse<CompanyInfoResponse>>(
|
||||
URL_CONSTANTS.COMPANIES_API.ONBOARDING_START,
|
||||
@@ -429,6 +447,20 @@ export const companiesService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
/**
|
||||
* The Ethiopian transit agents a freight forwarder may register itself as.
|
||||
* A company that is not listed has to ask support to be added — there is no
|
||||
* self-service path, since the roster is what GL assigns work from.
|
||||
*/
|
||||
listForwarderTransitAgents: async (): Promise<
|
||||
ForwarderTransitAgentOption[]
|
||||
> => {
|
||||
const response = await client.get<
|
||||
ApiResponse<ForwarderTransitAgentOption[]>
|
||||
>(URL_CONSTANTS.TRANSIT_AGENTS_API.FORWARDER_OPTIONS);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Give up the foreign investment-licence route and go back through eTrade.
|
||||
* The API clears the typed registration and reopens onboarding at the company
|
||||
|
||||
@@ -1053,6 +1053,12 @@ export interface CreateBookingUnderContractDto {
|
||||
customsClearingAgent?: string;
|
||||
customsClearingAgentEmail?: string;
|
||||
customsClearingAgentPhone?: string;
|
||||
/**
|
||||
* The alternative to typing a clearing agent: a registered Ethiopian transit
|
||||
* agent (a freight forwarder on the platform). Assigns the booking to it and
|
||||
* notifies the forwarder company; the typed agent fields are then ignored.
|
||||
*/
|
||||
transitAgentId?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user