Merge pull request #1514 from Tria-plc/freight_feature/usermanagement

Freight feature/usermanagement
This commit is contained in:
marshal
2026-09-07 10:14:10 +03:00
committed by GitHub
76 changed files with 3317 additions and 400 deletions

View File

@@ -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}`,
);
}
}
}

View File

@@ -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);
}

View File

@@ -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

View File

@@ -66,6 +66,7 @@ function makeService(company: Record<string, unknown> | null) {
{} as never,
{} as never,
{} as never,
{} as never,
);
jest

View File

@@ -101,6 +101,7 @@ function makeService(
{ changeRequestSubmitted: jest.fn() } as never,
{} as never,
{} as never,
{} as never,
);
jest

View File

@@ -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

View File

@@ -75,6 +75,7 @@ function makeService(status: ProfileStatus) {
companyNotifier as never,
dataSource as never,
{} as never,
{} as never,
);
return { service, profile, written, companyProfilesRepo };

View File

@@ -70,6 +70,7 @@ function makeService(attributes: Record<string, unknown> = {}) {
{} as never,
{} as never,
{} as never,
{} as never,
);
jest

View File

@@ -54,6 +54,7 @@ function makeService(existing: ExistingProfile[]) {
{} as never,
{} as never,
{} as never,
{} as never,
);
jest

View File

@@ -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 =

View File

@@ -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",
}),
);
});
});

View File

@@ -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";

View File

@@ -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;
}
}

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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),

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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[];

View File

@@ -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 };
}

View File

@@ -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,

View File

@@ -27,6 +27,8 @@ describe('ContractBookingService — customs booking gate', () => {
{} as never, // bookingBatchService
{} as never, // bookingTransitionService
{} as never, // consolidationApprovalService
{} as never, // transitAgentsRepository
{} as never, // transitAssignmentsService
);
}

View File

@@ -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 };
}

View File

@@ -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 };
}

View File

@@ -20,6 +20,9 @@ import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingPricingService } from '../bookings/booking-pricing.service';
import { BookingTransitionService } from '../bookings/booking-transition.service';
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
import { TransitAgentCountry } from '../transit-agents/entities/transit-agent.entity';
import { TransitAgentsRepository } from '../transit-agents/transit-agents.repository';
import { TransitAssignmentsService } from '../transit-assignments/transit-assignments.service';
import { ConsolidationService } from '../bookings/consolidation.service';
import { ConsolidationApprovalService } from '../bookings/consolidation-approval.service';
import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
@@ -136,6 +139,8 @@ export class ContractBookingService {
private readonly bookingTransitionService: BookingTransitionService,
@Inject(forwardRef(() => ConsolidationApprovalService))
private readonly consolidationApprovalService: ConsolidationApprovalService,
private readonly transitAgentsRepository: TransitAgentsRepository,
private readonly transitAssignmentsService: TransitAssignmentsService,
) {}
async createUnderContract(
@@ -857,34 +862,66 @@ export class ContractBookingService {
if (!dto.scheduledDate) {
throw new BadRequestException('A binding shipment day is required');
}
// Without-customs import/export: the customer's own clearing agent (name,
// email, phone) is captured per booking at completion. A resubmit may omit
// the fields and keep what the booking already stored. Customs contracts
// (GL clears) and intercity (no border) never collect an agent.
// Without-customs import/export: the customer names who clears customs for
// this booking, one of two ways. Either a registered Ethiopian transit
// agent (a freight forwarder on the platform) — the booking is assigned to
// it and the forwarder is told — or their own clearing agent typed in
// (name, email, phone). A resubmit may omit the typed fields and keep what
// the booking already stored. Customs contracts (GL clears) and intercity
// (no border) never collect an agent.
let assignedTransitAgent: { id: string; name: string } | null = null;
if (
!contract.customsClearingEnabled &&
contract.tradeDirection !== 'DOMESTIC'
) {
const agentName =
dto.customsClearingAgent?.trim() || booking.customsClearingAgent || null;
const agentEmail =
dto.customsClearingAgentEmail?.trim() ||
booking.customsClearingAgentEmail ||
null;
const agentPhone =
dto.customsClearingAgentPhone?.trim() ||
booking.customsClearingAgentPhone ||
null;
if (!agentName || !agentEmail || !agentPhone) {
throw new BadRequestException(
'Customs clearing agent name, email and phone are required to complete this booking.',
);
if (dto.transitAgentId) {
const agent = await this.transitAgentsRepository.findById(dto.transitAgentId);
if (
!agent ||
!agent.isActive ||
agent.country !== TransitAgentCountry.Ethiopia
) {
throw new BadRequestException(
'The selected transit agent is not an active Ethiopian transit agent — pick another one or enter your clearing agent details.',
);
}
// The forwarder company's own contact goes on the booking, so the
// customer sees who to reach; an Ethiopian agent row carries none.
const [forwarder]: Array<{ email: string | null; phone: string | null }> =
await this.dataSource.query(
`SELECT email, phone FROM freight.companies
WHERE transit_agent_id = $1 AND deleted_at IS NULL
LIMIT 1`,
[agent.id],
);
await this.bookingsRepository.update(booking.id, {
customsClearingAgent: agent.name,
customsClearingAgentEmail: forwarder?.email ?? null,
customsClearingAgentPhone: forwarder?.phone ?? null,
} as never);
assignedTransitAgent = { id: agent.id, name: agent.name };
} else {
const agentName =
dto.customsClearingAgent?.trim() || booking.customsClearingAgent || null;
const agentEmail =
dto.customsClearingAgentEmail?.trim() ||
booking.customsClearingAgentEmail ||
null;
const agentPhone =
dto.customsClearingAgentPhone?.trim() ||
booking.customsClearingAgentPhone ||
null;
if (!agentName || !agentEmail || !agentPhone) {
throw new BadRequestException(
'Customs clearing agent name, email and phone are required to complete this booking — or pick a registered transit agent.',
);
}
await this.bookingsRepository.update(booking.id, {
customsClearingAgent: agentName,
customsClearingAgentEmail: agentEmail,
customsClearingAgentPhone: agentPhone,
} as never);
}
await this.bookingsRepository.update(booking.id, {
customsClearingAgent: agentName,
customsClearingAgentEmail: agentEmail,
customsClearingAgentPhone: agentPhone,
} as never);
}
// No expiry gate here on purpose: this booking was already initiated
// before the contract lapsed (createUnderContract/initiateUnderContract
@@ -1092,6 +1129,18 @@ export class ContractBookingService {
dto.scheduledDate,
dto.trainScheduleId ?? null,
);
// The forwarder's work list and its notice come AFTER the booking is
// committed: a customer must never be told a forwarder has the job when
// the completion itself was refused a line above.
if (assignedTransitAgent) {
await this.transitAssignmentsService.ensureAssignment(
booking.id,
assignedTransitAgent.id,
(actorPermissions as { id?: string } | undefined)?.id,
);
void this.bookingNotifier.transitAgentAssigned(completed, assignedTransitAgent);
}
return { booking: completed, warnings };
}

View File

@@ -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({

View File

@@ -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()

View File

@@ -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

View File

@@ -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()

View File

@@ -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;

View File

@@ -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');
});
});

View File

@@ -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

View File

@@ -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);
});
});

View File

@@ -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,

View File

@@ -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()

View File

@@ -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;

View File

@@ -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" })

View File

@@ -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" },
});
}

View File

@@ -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();
});
});
});

View File

@@ -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);
}
/**

View File

@@ -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,

View File

@@ -93,6 +93,7 @@ describe("TransitAssignmentsService", () => {
files as never,
milestones as never,
trainSchedules as never,
{ findOne: jest.fn().mockResolvedValue(null) } as never, // externalProfiles
);
});

View File

@@ -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.");