mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 15:25:45 +00:00
feat: Implement container hazardous-cargo surcharge logic
- Added support for container hazardous-cargo surcharge (HAZARDOUS billed PER_CONTAINER) in the rule engine. - Introduced new method in to calculate and apply container hazard charges based on booking details. - Updated to handle per-container hazard rates, ensuring they are scoped by trade direction and lane. - Enhanced tests to cover scenarios for container hazard rates, including validation for required fields and conflict checks. - Created a migration to update existing rates and enforce new constraints for container hazard rates in the database.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { RatesService } from '../modules/rule-engine/services/rates.service';
|
||||
import { Rate } from '../modules/rule-engine/entities/rate.entity';
|
||||
import { Rate, isContainerHazardRate } from '../modules/rule-engine/entities/rate.entity';
|
||||
import {
|
||||
ContractDirection,
|
||||
ContractFreight,
|
||||
@@ -109,12 +109,21 @@ export class ContractRateScheduleBuilder {
|
||||
// Fuel is sold per lane + commodity — only lanes matching the contract's
|
||||
// direction belong on its schedule, labeled with their leg.
|
||||
if (rate.trigger === 'FUEL') {
|
||||
if (this.fuelDirectionMatches(rate, direction)) {
|
||||
if (this.laneDirectionMatches(rate, direction)) {
|
||||
surcharges.push(this.fuelRow(rate));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// The container hazard surcharge is sold per lane (+ box size) — only a
|
||||
// container contract on a matching direction shows it, with its leg.
|
||||
if (isContainerHazardRate(rate.trigger, rate.rateUnit)) {
|
||||
if (freight === 'CON' && this.laneDirectionMatches(rate, direction)) {
|
||||
surcharges.push(this.lanedSurchargeRow(rate));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Everything left is a trigger-based charge (surcharge / demurrage / customs).
|
||||
surcharges.push(this.surchargeRow(rate));
|
||||
}
|
||||
@@ -203,12 +212,28 @@ export class ContractRateScheduleBuilder {
|
||||
};
|
||||
}
|
||||
|
||||
private fuelDirectionMatches(rate: Rate, direction: ContractDirection): boolean {
|
||||
/** Lane-sold surcharges (fuel, container hazard) match on the contract's direction. */
|
||||
private laneDirectionMatches(rate: Rate, direction: ContractDirection): boolean {
|
||||
const want =
|
||||
direction === 'IMP' ? 'IMPORT' : direction === 'EXP' ? 'EXPORT' : 'DOMESTIC';
|
||||
return rate.tradeDirection === want;
|
||||
}
|
||||
|
||||
/** A lane-sold surcharge row — the leg rides along in the charge label. */
|
||||
private lanedSurchargeRow(rate: Rate): RateScheduleRow {
|
||||
const origin = rate.originYard?.label ?? rate.originYard?.code ?? '—';
|
||||
const destination =
|
||||
rate.destinationYard?.label ?? rate.destinationYard?.code ?? '—';
|
||||
const label = TRIGGER_ROUTE_LABELS[rate.trigger] ?? this.titleCase(rate.trigger);
|
||||
return {
|
||||
route: `${label} (${origin} → ${destination})`,
|
||||
cargo: this.cargoLabel(rate),
|
||||
currency: rate.currency,
|
||||
amount: this.formatAmount(rate.rateValue),
|
||||
unit: this.unitLabel(rate.rateUnit),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuel row — the lane matters, so it rides along in the charge label.
|
||||
* Per-liter collapses to one flat total (base liters × rate value); the
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* The container hazardous-cargo surcharge (HAZARDOUS billed PER_CONTAINER) is
|
||||
* now sold per trade direction + origin → destination lane, optionally per
|
||||
* container type (20ft / 40ft) — the same shape as the empty-return service.
|
||||
* The per-ton (bulk) hazard rate keeps its global, unscoped shape.
|
||||
*
|
||||
* - CK_rates_yard_scope gains the per-container hazard rate in its
|
||||
* yard-carrying branch. Drop-and-recreate is the established shape for this
|
||||
* constraint — see 3890000000000-EmptyContainerRateScope.
|
||||
* - Existing lane-less per-container hazard rows cannot satisfy the new
|
||||
* branch and no longer match the way the engine prices container hazard
|
||||
* (per lane + size), so they are SUPERSEDED — kept for the audit trail, out
|
||||
* of the unique pattern index and out of pricing. The rates team re-enters
|
||||
* the surcharge per lane; until then a hazardous container booking on that
|
||||
* lane hard-blocks rather than shipping the service for free.
|
||||
*/
|
||||
export class ContainerHazardRateScope3960000000000 implements MigrationInterface {
|
||||
name = "ContainerHazardRateScope3960000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.rates
|
||||
SET status = 'SUPERSEDED'
|
||||
WHERE trigger = 'HAZARDOUS'
|
||||
AND rate_unit = 'PER_CONTAINER'
|
||||
AND (origin_yard_id IS NULL OR destination_yard_id IS NULL)
|
||||
AND deleted_at IS NULL
|
||||
AND status <> 'SUPERSEDED'
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
|
||||
deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
|
||||
CASE
|
||||
WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'EMPTY_CONTAINER', 'INTERCITY'))
|
||||
OR trigger IN ('CUSTOMS_CLEARANCE', 'ETHIOPIAN_CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL')
|
||||
OR (trigger = 'HAZARDOUS' AND rate_unit = 'PER_CONTAINER')
|
||||
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
|
||||
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
|
||||
END
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// Lane-scoped per-container hazard rows have no place under the old
|
||||
// constraint (surcharges carried no yards) — retire them the same way.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.rates
|
||||
SET status = 'SUPERSEDED'
|
||||
WHERE trigger = 'HAZARDOUS'
|
||||
AND rate_unit = 'PER_CONTAINER'
|
||||
AND (origin_yard_id IS NOT NULL OR destination_yard_id IS NOT NULL)
|
||||
AND deleted_at IS NULL
|
||||
AND status <> 'SUPERSEDED'
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
|
||||
deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
|
||||
CASE
|
||||
WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'EMPTY_CONTAINER', 'INTERCITY'))
|
||||
OR trigger IN ('CUSTOMS_CLEARANCE', 'ETHIOPIAN_CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL')
|
||||
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
|
||||
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
|
||||
END
|
||||
)
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
RATE_UNITS,
|
||||
} from '../entities/rate.entity';
|
||||
|
||||
// DOMESTIC is accepted only for FUEL rates (an intercity fuel lane).
|
||||
// DOMESTIC is accepted only for FUEL and per-container HAZARDOUS rates (an intercity lane).
|
||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH', 'DOMESTIC'] as const;
|
||||
// ETB is accepted only for last-mile rates; the service forces USD elsewhere.
|
||||
const CURRENCIES = ['USD', 'ETB'] as const;
|
||||
@@ -26,7 +26,10 @@ export class CreateRateDto {
|
||||
@IsIn([...RATE_TRIGGERS])
|
||||
trigger!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'FK to container_types.id — set for container/intercity-container rates' })
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'FK to container_types.id — set for container/intercity-container rates, and optionally on the per-container HAZARDOUS surcharge (20ft / 40ft price differently; omitted = the lane catch-all)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
containerTypeId?: string;
|
||||
@@ -61,7 +64,7 @@ export class CreateRateDto {
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'FK to yards.id — origin of the leg this rate prices. Required for base freight (bulk/container/intercity), rejected for surcharges and first/last mile.',
|
||||
'FK to yards.id — origin of the leg this rate prices. Required for base freight (bulk/container/intercity) and the lane-sold surcharges (customs clearance, empty return, fuel, per-container HAZARDOUS); rejected for every other surcharge and first/last mile.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
@@ -69,7 +72,7 @@ export class CreateRateDto {
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'FK to yards.id — destination of the leg this rate prices. Required for base freight (bulk/container/intercity), rejected for surcharges and first/last mile.',
|
||||
'FK to yards.id — destination of the leg this rate prices. Required for base freight (bulk/container/intercity) and the lane-sold surcharges (customs clearance, empty return, fuel, per-container HAZARDOUS); rejected for every other surcharge and first/last mile.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
|
||||
@@ -88,6 +88,10 @@ export type RateAppliesTo = typeof RATE_APPLIES_TO[number];
|
||||
*/
|
||||
export const RATE_TRIGGERS = [
|
||||
'ALWAYS',
|
||||
// Hazardous cargo. Two shapes under one trigger, told apart by the unit:
|
||||
// PER_CONTAINER is the container surcharge, sold per direction + lane and
|
||||
// optionally per box size (20ft / 40ft) like the empty-return service;
|
||||
// PER_TON is the bulk surcharge, direction-agnostic and unscoped.
|
||||
'HAZARDOUS',
|
||||
'OVERWEIGHT',
|
||||
'REEFER',
|
||||
@@ -121,6 +125,16 @@ export type RateTrigger = typeof RATE_TRIGGERS[number];
|
||||
export const isCustomsClearanceTrigger = (trigger: string): boolean =>
|
||||
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'ETHIOPIAN_CUSTOMS_CLEARANCE';
|
||||
|
||||
/**
|
||||
* The container hazardous-cargo surcharge: HAZARDOUS billed per container.
|
||||
* It is sold per trade direction + origin → destination lane, optionally
|
||||
* narrowed to one container type (20ft / 40ft), and priced by the
|
||||
* route-matched block in RuleEngineService — never by the additive loop.
|
||||
* The per-ton (bulk) hazard rate keeps the old global, unscoped shape.
|
||||
*/
|
||||
export const isContainerHazardRate = (trigger: string, rateUnit: string): boolean =>
|
||||
trigger === 'HAZARDOUS' && rateUnit === 'PER_CONTAINER';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'rates' })
|
||||
@Index(['rateType'])
|
||||
@Index(['status'])
|
||||
@@ -159,8 +173,10 @@ export class Rate extends BaseEntity {
|
||||
/**
|
||||
* The leg this rate prices. Base freight (trigger = ALWAYS) is quoted per
|
||||
* route — "container import, Djibouti → Dire Dawa" — so both yards are
|
||||
* required for BULK/CONTAINER/INTERCITY and NULL for everything else. The
|
||||
* `CK_rates_yard_scope` DB constraint enforces both halves of that.
|
||||
* required for BULK/CONTAINER/EMPTY_CONTAINER/INTERCITY, for the lane-sold
|
||||
* surcharges (customs clearance, empty return, fuel, container hazard) and
|
||||
* NULL for everything else. The `CK_rates_yard_scope` DB constraint enforces
|
||||
* both halves of that.
|
||||
*/
|
||||
@Column({ name: 'origin_yard_id', type: 'uuid', nullable: true })
|
||||
originYardId?: string | null;
|
||||
|
||||
@@ -3,12 +3,14 @@ import type { BookingEvaluationInput } from './rule-engine.service';
|
||||
import type { Rate } from './entities/rate.entity';
|
||||
|
||||
describe('RuleEngineService — requested service without a configured surcharge rate', () => {
|
||||
// The bulk (per-ton) hazard rate — global, no lane. These bookings carry no
|
||||
// containers, so they are bulk-shaped and price off this one.
|
||||
const hazardRate: Rate = {
|
||||
id: 'rate-hazard',
|
||||
rateType: 'HAZARD_SURCHARGE',
|
||||
trigger: 'HAZARDOUS',
|
||||
rateValue: 50,
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
rateUnit: 'PER_TON',
|
||||
currency: 'USD',
|
||||
status: 'LIVE',
|
||||
containerTypeId: null,
|
||||
@@ -650,6 +652,7 @@ describe('RuleEngineService — shipping-line rates override the standard ones',
|
||||
shippingLineCompanyId: LINE,
|
||||
} as Rate;
|
||||
|
||||
// Container hazard is sold per direction + lane (+ optional box size).
|
||||
const standardHazard: Rate = {
|
||||
id: 'rate-hazard-standard',
|
||||
rateType: 'HAZARD_SURCHARGE',
|
||||
@@ -661,6 +664,9 @@ describe('RuleEngineService — shipping-line rates override the standard ones',
|
||||
containerTypeId: null,
|
||||
cargoTypeId: null,
|
||||
shippingLineCompanyId: null,
|
||||
tradeDirection: 'IMPORT',
|
||||
originYardId: 'yard-dj',
|
||||
destinationYardId: 'yard-adama',
|
||||
} as Rate;
|
||||
|
||||
const lineHazard: Rate = {
|
||||
@@ -764,3 +770,161 @@ describe('RuleEngineService — shipping-line rates override the standard ones',
|
||||
expect(result.hardBlocked[0]).toContain('hazardous');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RuleEngineService — container hazard surcharge per lane and box size', () => {
|
||||
const lane = {
|
||||
tradeDirection: 'IMPORT',
|
||||
originYardId: 'yard-dj',
|
||||
destinationYardId: 'yard-adama',
|
||||
};
|
||||
const catchAll: Rate = {
|
||||
id: 'rate-hazard-lane',
|
||||
rateType: 'HAZARD_SURCHARGE',
|
||||
trigger: 'HAZARDOUS',
|
||||
rateValue: 50,
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
currency: 'USD',
|
||||
status: 'LIVE',
|
||||
containerTypeId: null,
|
||||
cargoTypeId: null,
|
||||
...lane,
|
||||
} as Rate;
|
||||
const forty: Rate = {
|
||||
...catchAll,
|
||||
id: 'rate-hazard-lane-40',
|
||||
rateValue: 90,
|
||||
containerTypeId: 'ct-40',
|
||||
} as Rate;
|
||||
const bulkHazard: Rate = {
|
||||
...catchAll,
|
||||
id: 'rate-hazard-bulk',
|
||||
rateValue: 3,
|
||||
rateUnit: 'PER_TON',
|
||||
tradeDirection: null,
|
||||
originYardId: null,
|
||||
destinationYardId: null,
|
||||
} as Rate;
|
||||
|
||||
const buildService = (rates: Rate[]) =>
|
||||
new RuleEngineService(
|
||||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||
{ findActiveByContainerTypeId: jest.fn().mockResolvedValue([]) } as never,
|
||||
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
|
||||
{ findLiveRates: jest.fn().mockResolvedValue(rates) } as never,
|
||||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
const booking = (overrides: Partial<BookingEvaluationInput> = {}): BookingEvaluationInput => ({
|
||||
serviceTypeId: 'svc-1',
|
||||
paymentCurrency: 'USD',
|
||||
isHazardous: false,
|
||||
totalWagons: 2,
|
||||
...lane,
|
||||
containers: [
|
||||
{ containerTypeId: 'ct-20', quantity: 3, vgmPerUnitTons: 10, totalVgmTons: 30, hazardousQuantity: 2 },
|
||||
{ containerTypeId: 'ct-40', quantity: 1, vgmPerUnitTons: 10, totalVgmTons: 10, hazardousQuantity: 1 },
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const hazardOf = (result: { appliedModifiers: Array<{ surchargeCode: string }> }) =>
|
||||
result.appliedModifiers.filter((m) => m.surchargeCode === 'HAZARD_SURCHARGE');
|
||||
|
||||
it('bills each line off the lane rate for its own box size, catch-all otherwise', async () => {
|
||||
const result = await buildService([catchAll, forty]).evaluate(booking());
|
||||
expect(result.hardBlocked).toHaveLength(0);
|
||||
const lines = hazardOf(result);
|
||||
expect(lines).toHaveLength(2);
|
||||
// 2 hazardous 20ft on the lane catch-all, 1 hazardous 40ft on the 40ft rate.
|
||||
expect(lines).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ rateId: catchAll.id, triggerValue: 2, calculatedAmount: 100, unitPriceUsd: 50, billingUnit: 'PER_CONTAINER' }),
|
||||
expect.objectContaining({ rateId: forty.id, triggerValue: 1, calculatedAmount: 90, unitPriceUsd: 90 }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('bills the opted-in count, not the whole line', async () => {
|
||||
const result = await buildService([catchAll]).evaluate(
|
||||
booking({
|
||||
containers: [
|
||||
{ containerTypeId: 'ct-20', quantity: 10, vgmPerUnitTons: 10, totalVgmTons: 100, hazardousQuantity: 4 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(hazardOf(result)).toEqual([
|
||||
expect.objectContaining({ triggerValue: 4, calculatedAmount: 200 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to every container when only the legacy booking-level flag is set', async () => {
|
||||
const result = await buildService([catchAll]).evaluate(
|
||||
booking({
|
||||
isHazardous: true,
|
||||
containers: [
|
||||
{ containerTypeId: 'ct-20', quantity: 3, vgmPerUnitTons: 10, totalVgmTons: 30 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(hazardOf(result)).toEqual([
|
||||
expect.objectContaining({ triggerValue: 3, calculatedAmount: 150 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('never bills the per-container rate a second time through the additive loop', async () => {
|
||||
const result = await buildService([catchAll]).evaluate(
|
||||
booking({
|
||||
isHazardous: true,
|
||||
containers: [
|
||||
{ containerTypeId: 'ct-20', quantity: 2, vgmPerUnitTons: 10, totalVgmTons: 20 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(hazardOf(result)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('hard-blocks when the lane has no per-container hazard rate', async () => {
|
||||
const result = await buildService([catchAll]).evaluate(
|
||||
booking({ destinationYardId: 'yard-elsewhere' }),
|
||||
);
|
||||
expect(hazardOf(result)).toHaveLength(0);
|
||||
expect(result.hardBlocked).toHaveLength(1);
|
||||
expect(result.hardBlocked[0]).toContain('hazardous');
|
||||
expect(result.hardBlocked[0]).toContain('route');
|
||||
});
|
||||
|
||||
it('hard-blocks when the rate is for the other direction', async () => {
|
||||
const result = await buildService([catchAll]).evaluate(
|
||||
booking({ tradeDirection: 'EXPORT', originYardId: 'yard-adama', destinationYardId: 'yard-dj' }),
|
||||
);
|
||||
expect(result.hardBlocked).toHaveLength(1);
|
||||
expect(result.hardBlocked[0]).toContain('hazardous');
|
||||
});
|
||||
|
||||
it('does not let the bulk per-ton rate stand in for a container booking', async () => {
|
||||
const result = await buildService([bulkHazard]).evaluate(booking());
|
||||
expect(hazardOf(result)).toHaveLength(0);
|
||||
expect(result.hardBlocked).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('bills a bulk booking off the global per-ton rate, untouched by the lane rule', async () => {
|
||||
const result = await buildService([bulkHazard, catchAll]).evaluate(
|
||||
booking({ isHazardous: true, containers: [], bulkTons: 40, totalWagons: 1 }),
|
||||
);
|
||||
expect(result.hardBlocked).toHaveLength(0);
|
||||
expect(hazardOf(result)).toEqual([
|
||||
expect.objectContaining({ rateId: bulkHazard.id, triggerValue: 40, calculatedAmount: 120 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('hard-blocks a hazardous bulk booking when only the container rate exists', async () => {
|
||||
const result = await buildService([catchAll]).evaluate(
|
||||
booking({ isHazardous: true, containers: [], bulkTons: 40, totalWagons: 1 }),
|
||||
);
|
||||
expect(result.hardBlocked).toHaveLength(1);
|
||||
expect(result.hardBlocked[0]).toContain('hazardous');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Inject, Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.entity';
|
||||
import { Rate, RateTrigger } from './entities/rate.entity';
|
||||
import { Rate, RateTrigger, isContainerHazardRate } from './entities/rate.entity';
|
||||
import { isBulkQuantityUnit } from './entities/rate-unit.util';
|
||||
import {
|
||||
ICargoTypesRepository,
|
||||
@@ -366,9 +366,11 @@ export class RuleEngineService {
|
||||
// hard block — pricing would otherwise ship the service for free. System-
|
||||
// derived charges (consolidation, overweight, shipping line, lashing) stay
|
||||
// exempt: the customer never opted into those, so they must not block.
|
||||
const isContainerBooking = input.containers.length > 0;
|
||||
const requestedServices: Array<{
|
||||
trigger: RateTrigger;
|
||||
wanted: boolean;
|
||||
configured: boolean;
|
||||
label: string;
|
||||
}> = [
|
||||
{
|
||||
@@ -376,6 +378,15 @@ export class RuleEngineService {
|
||||
wanted:
|
||||
truthy(input.isHazardous) ||
|
||||
input.containers.some((c) => Number(c.hazardousQuantity ?? 0) > 0),
|
||||
// Container hazard is sold per lane + box size and checked by the
|
||||
// route-matched block below (which blocks per missing lane/type rate);
|
||||
// bulk hazard is the global per-ton rate this loop can vouch for.
|
||||
configured: isContainerBooking
|
||||
? true
|
||||
: surchargeRates.some(
|
||||
(r) =>
|
||||
r.trigger === 'HAZARDOUS' && !isContainerHazardRate(r.trigger, r.rateUnit),
|
||||
),
|
||||
label: 'hazardous cargo',
|
||||
},
|
||||
{
|
||||
@@ -383,11 +394,12 @@ export class RuleEngineService {
|
||||
wanted:
|
||||
hasReefer ||
|
||||
input.containers.some((c) => Number(c.reeferQuantity ?? 0) > 0),
|
||||
configured: surchargeRates.some((r) => r.trigger === 'REEFER'),
|
||||
label: 'refrigerated (reefer) cargo',
|
||||
},
|
||||
];
|
||||
for (const svc of requestedServices) {
|
||||
if (svc.wanted && !surchargeRates.some((r) => r.trigger === svc.trigger)) {
|
||||
if (svc.wanted && !svc.configured) {
|
||||
hardBlocked.push(
|
||||
`No ${svc.label} surcharge rate is configured — the booking cannot ` +
|
||||
`be priced with this service. Remove the ${svc.label} option or ` +
|
||||
@@ -411,6 +423,10 @@ export class RuleEngineService {
|
||||
// Fuel is sold per lane + cargo type — billed by the route-matched
|
||||
// block below, never by this route-agnostic loop.
|
||||
if (rate.trigger === 'FUEL') continue;
|
||||
// The container hazard surcharge is sold per lane + box size like the
|
||||
// empty-return service — billed by its own route-matched block below.
|
||||
// The per-ton bulk hazard rate stays additive here.
|
||||
if (isContainerHazardRate(rate.trigger, rate.rateUnit)) continue;
|
||||
const triggered = this.matchesTrigger(rate.trigger, {
|
||||
isHazardous: input.isHazardous,
|
||||
hasReefer,
|
||||
@@ -521,6 +537,10 @@ export class RuleEngineService {
|
||||
appliedModifiers.push(...withReturn.modifiers);
|
||||
hardBlocked.push(...withReturn.blocked);
|
||||
|
||||
const containerHazard = this.containerHazardCharges(input, liveRates);
|
||||
appliedModifiers.push(...containerHazard.modifiers);
|
||||
hardBlocked.push(...containerHazard.blocked);
|
||||
|
||||
if (hasLashing) {
|
||||
appliedModifiers.push(...this.lashingCharges(input, liveRates));
|
||||
}
|
||||
@@ -730,6 +750,80 @@ export class RuleEngineService {
|
||||
return { modifiers, blocked: [...new Set(blocked)] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Container hazardous-cargo surcharge — sold per direction + route, optionally
|
||||
* per container type, exactly like the empty-return service. Each container
|
||||
* line that opted in (hazardousQuantity, or every container when only the
|
||||
* legacy booking-level flag is set) bills the route-matched PER_CONTAINER
|
||||
* HAZARDOUS rate for its own container type, falling back to the lane's
|
||||
* catch-all (no type) rate; a line with no matching rate hard-blocks the
|
||||
* booking instead of shipping the service for free. Bulk bookings never
|
||||
* reach here — their per-ton hazard rate is billed by the additive loop.
|
||||
* ponytail: bills the LIVE route rate, not a frozen contract snapshot — one
|
||||
* HAZARD_SURCHARGE snapshot code can't hold per-size route prices.
|
||||
*/
|
||||
private containerHazardCharges(
|
||||
input: BookingEvaluationInput,
|
||||
liveRates: Rate[],
|
||||
): { modifiers: AppliedCargoModifier[]; blocked: string[] } {
|
||||
const modifiers: AppliedCargoModifier[] = [];
|
||||
const blocked: string[] = [];
|
||||
if (input.containers.length === 0) return { modifiers, blocked };
|
||||
const bookingLevel = truthy(input.isHazardous);
|
||||
const wanted =
|
||||
bookingLevel ||
|
||||
input.containers.some((c) => Number(c.hazardousQuantity ?? 0) > 0);
|
||||
if (!wanted) return { modifiers, blocked };
|
||||
|
||||
const onLeg = liveRates.filter(
|
||||
(r) =>
|
||||
isContainerHazardRate(r.trigger, r.rateUnit) &&
|
||||
r.currency === 'USD' &&
|
||||
r.tradeDirection === input.tradeDirection &&
|
||||
r.originYardId === input.originYardId &&
|
||||
r.destinationYardId === input.destinationYardId,
|
||||
);
|
||||
|
||||
for (const container of input.containers) {
|
||||
const qty =
|
||||
Number(container.hazardousQuantity ?? 0) > 0
|
||||
? Number(container.hazardousQuantity)
|
||||
: bookingLevel
|
||||
? Number(container.quantity || 0)
|
||||
: 0;
|
||||
if (!(qty > 0)) continue;
|
||||
|
||||
// The rate scoped to this box size wins over the lane's catch-all.
|
||||
const rate =
|
||||
onLeg.find((r) => r.containerTypeId === container.containerTypeId) ??
|
||||
onLeg.find((r) => !r.containerTypeId);
|
||||
if (!rate) {
|
||||
blocked.push(
|
||||
'No hazardous cargo surcharge rate is configured for this container ' +
|
||||
'type on this route — remove the hazardous option or ask EDR to ' +
|
||||
'configure its per-container rate for this origin → destination.',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const rateValue = Number(rate.rateValue);
|
||||
const amount = qty * rateValue;
|
||||
if (!(amount > 0)) continue;
|
||||
modifiers.push({
|
||||
rateId: rate.id,
|
||||
surchargeCode: this.surchargeCode(rate),
|
||||
triggerValue: qty,
|
||||
calculatedAmount: amount,
|
||||
currency: rate.currency,
|
||||
unitPriceUsd: rateValue,
|
||||
billingUnit: rate.rateUnit,
|
||||
});
|
||||
}
|
||||
|
||||
// Same block deduplicated — several lines missing the rate is one problem.
|
||||
return { modifiers, blocked: [...new Set(blocked)] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cargo securing / lashing — BULK only, sold per trade direction, optionally
|
||||
* narrowed to one leaf commodity (the commodity-scoped rate wins over the
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
import { BadRequestException, ConflictException } from '@nestjs/common';
|
||||
|
||||
import { RatesService } from './rates.service';
|
||||
import type { Rate } from '../entities/rate.entity';
|
||||
@@ -101,8 +101,9 @@ describe('RatesService — one rate per pattern', () => {
|
||||
|
||||
/**
|
||||
* Additive surcharges are billed per matching rate, each by its own unit, so
|
||||
* hazard is legitimately per-container for boxes AND per-ton for bulk. The
|
||||
* unit stays part of their identity or the second one could never be created.
|
||||
* the bulk (per-ton) hazard rate coexists with the lane-sold container one.
|
||||
* The unit stays part of their identity or the second one could never be
|
||||
* created.
|
||||
*/
|
||||
it('keeps the unit in the key for an additive surcharge', async () => {
|
||||
await service.create(
|
||||
@@ -110,17 +111,44 @@ describe('RatesService — one rate per pattern', () => {
|
||||
appliesTo: 'OTHER',
|
||||
trigger: 'HAZARDOUS',
|
||||
rateValue: 300,
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
rateUnit: 'PER_TON',
|
||||
} as never,
|
||||
'staff-1',
|
||||
);
|
||||
|
||||
expect(repository.findByPattern.mock.calls[0][0]).toMatchObject({
|
||||
rateType: 'HAZARD_SURCHARGE',
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
rateUnit: 'PER_TON',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the per-ton hazard rate global — direction and lane are dropped', async () => {
|
||||
await service.create(
|
||||
{
|
||||
appliesTo: 'OTHER',
|
||||
trigger: 'HAZARDOUS',
|
||||
tradeDirection: 'IMPORT',
|
||||
originYardId: DJ,
|
||||
destinationYardId: ET,
|
||||
containerTypeId: CT20,
|
||||
rateValue: 5,
|
||||
rateUnit: 'PER_TON',
|
||||
} as never,
|
||||
'staff-1',
|
||||
);
|
||||
|
||||
expect(repository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
rateType: 'HAZARD_SURCHARGE',
|
||||
rateUnit: 'PER_TON',
|
||||
tradeDirection: null,
|
||||
originYardId: null,
|
||||
destinationYardId: null,
|
||||
containerTypeId: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('treats lashing as singly resolved — one unit per direction', async () => {
|
||||
await service.create(
|
||||
{
|
||||
@@ -138,3 +166,166 @@ describe('RatesService — one rate per pattern', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The container hazard surcharge (HAZARDOUS per container) is sold per
|
||||
* direction + lane, optionally per box size — the same shape as the
|
||||
* empty-return service. Pricing resolves exactly one rate per lane + size, so
|
||||
* the unit leaves the identity and the lane joins it.
|
||||
*/
|
||||
describe('RatesService — per-container hazard is sold per lane', () => {
|
||||
const DJ = '11111111-1111-4000-8000-000000000001';
|
||||
const ET = '11111111-1111-4000-8000-000000000002';
|
||||
const ET2 = '11111111-1111-4000-8000-000000000004';
|
||||
const CT20 = '11111111-1111-4000-8000-000000000003';
|
||||
|
||||
let repository: { findByPattern: jest.Mock; create: jest.Mock };
|
||||
let service: RatesService;
|
||||
|
||||
beforeEach(() => {
|
||||
repository = {
|
||||
findByPattern: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn(async (r) => ({ id: 'rate-new', ...r })),
|
||||
};
|
||||
service = new RatesService(
|
||||
repository as never,
|
||||
{
|
||||
findById: jest.fn(async (id: string) => ({
|
||||
id,
|
||||
country: id === DJ ? 'Djibouti' : 'Ethiopia',
|
||||
label: id === DJ ? 'Doraleh' : id === ET ? 'Gelan' : 'Dire Dawa',
|
||||
})),
|
||||
} as never,
|
||||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||
{ findById: jest.fn() } as never,
|
||||
);
|
||||
});
|
||||
|
||||
const containerHazard = {
|
||||
appliesTo: 'OTHER',
|
||||
trigger: 'HAZARDOUS',
|
||||
rateValue: 300,
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
};
|
||||
|
||||
it('requires a trade direction', async () => {
|
||||
await expect(
|
||||
service.create(
|
||||
{ ...containerHazard, originYardId: DJ, destinationYardId: ET } as never,
|
||||
'staff-1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(repository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requires both yards of the lane', async () => {
|
||||
await expect(
|
||||
service.create(
|
||||
{ ...containerHazard, tradeDirection: 'IMPORT' } as never,
|
||||
'staff-1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(repository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a lane that contradicts the direction', async () => {
|
||||
// Export runs Ethiopia → Djibouti; this leg is the import shape.
|
||||
await expect(
|
||||
service.create(
|
||||
{
|
||||
...containerHazard,
|
||||
tradeDirection: 'EXPORT',
|
||||
originYardId: DJ,
|
||||
destinationYardId: ET,
|
||||
} as never,
|
||||
'staff-1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('files the rate per direction + lane + box size, unit out of the key', async () => {
|
||||
await service.create(
|
||||
{
|
||||
...containerHazard,
|
||||
tradeDirection: 'IMPORT',
|
||||
originYardId: DJ,
|
||||
destinationYardId: ET,
|
||||
containerTypeId: CT20,
|
||||
} as never,
|
||||
'staff-1',
|
||||
);
|
||||
|
||||
const pattern = repository.findByPattern.mock.calls[0][0];
|
||||
expect(pattern).not.toHaveProperty('rateUnit');
|
||||
expect(pattern).toMatchObject({
|
||||
rateType: 'HAZARD_SURCHARGE',
|
||||
tradeDirection: 'IMPORT',
|
||||
originYardId: DJ,
|
||||
destinationYardId: ET,
|
||||
containerTypeId: CT20,
|
||||
});
|
||||
expect(repository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
rateType: 'HAZARD_SURCHARGE',
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
tradeDirection: 'IMPORT',
|
||||
originYardId: DJ,
|
||||
destinationYardId: ET,
|
||||
containerTypeId: CT20,
|
||||
cargoTypeId: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts a lane catch-all with no box size', async () => {
|
||||
await service.create(
|
||||
{
|
||||
...containerHazard,
|
||||
tradeDirection: 'EXPORT',
|
||||
originYardId: ET,
|
||||
destinationYardId: DJ,
|
||||
} as never,
|
||||
'staff-1',
|
||||
);
|
||||
expect(repository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tradeDirection: 'EXPORT',
|
||||
originYardId: ET,
|
||||
destinationYardId: DJ,
|
||||
containerTypeId: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts a DOMESTIC (intercity) lane inside Ethiopia', async () => {
|
||||
await service.create(
|
||||
{
|
||||
...containerHazard,
|
||||
tradeDirection: 'DOMESTIC',
|
||||
originYardId: ET,
|
||||
destinationYardId: ET2,
|
||||
} as never,
|
||||
'staff-1',
|
||||
);
|
||||
expect(repository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ tradeDirection: 'DOMESTIC', originYardId: ET, destinationYardId: ET2 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a second rate for the same lane + box size', async () => {
|
||||
repository.findByPattern.mockResolvedValue({ id: 'rate-existing' } as Rate);
|
||||
await expect(
|
||||
service.create(
|
||||
{
|
||||
...containerHazard,
|
||||
tradeDirection: 'IMPORT',
|
||||
originYardId: DJ,
|
||||
destinationYardId: ET,
|
||||
containerTypeId: CT20,
|
||||
} as never,
|
||||
'staff-1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { ShippingLineCompaniesService } from '../../shipping-lines/shipping-line
|
||||
import { CreateRateDto } from '../dto/create-rate.dto';
|
||||
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { UpdateRateDto } from '../dto/update-rate.dto';
|
||||
import { Rate, isCustomsClearanceTrigger } from '../entities/rate.entity';
|
||||
import { Rate, isContainerHazardRate, isCustomsClearanceTrigger } from '../entities/rate.entity';
|
||||
import { deriveRateType } from '../entities/rate-type.util';
|
||||
import { CargoUom, allowedRateUnits, isRateUnitAllowed } from '../entities/rate-unit.util';
|
||||
import {
|
||||
@@ -39,7 +39,11 @@ const CARGO_KIND_TRIGGERS: readonly Rate['trigger'][] = [
|
||||
'ETHIOPIAN_CUSTOMS_CLEARANCE',
|
||||
'CANCELLATION',
|
||||
];
|
||||
/** Surcharges that keep a trade direction (everything else is direction-agnostic). */
|
||||
/**
|
||||
* Surcharges that keep a trade direction (everything else is direction-agnostic).
|
||||
* Container hazard (HAZARDOUS billed PER_CONTAINER) is directed too, but is
|
||||
* keyed on the unit rather than the trigger — see {@link isDirectedSurcharge}.
|
||||
*/
|
||||
const DIRECTED_SURCHARGE_TRIGGERS: readonly Rate['trigger'][] = [
|
||||
'CUSTOMS_CLEARANCE',
|
||||
'ETHIOPIAN_CUSTOMS_CLEARANCE',
|
||||
@@ -148,18 +152,29 @@ export class RatesService {
|
||||
|
||||
/**
|
||||
* Rates sold per direction + route. Base freight always; customs clearance,
|
||||
* empty-container return and fuel are the surcharges that are too — their
|
||||
* fee depends on the lane (and, for returns, the container type).
|
||||
* empty-container return, fuel and the container hazard surcharge are the
|
||||
* surcharges that are too — their fee depends on the lane (and, for returns
|
||||
* and container hazard, the container type).
|
||||
*/
|
||||
private isRouteScoped(appliesTo: Rate['appliesTo'], trigger: Rate['trigger']): boolean {
|
||||
private isRouteScoped(
|
||||
appliesTo: Rate['appliesTo'],
|
||||
trigger: Rate['trigger'],
|
||||
rateUnit: Rate['rateUnit'],
|
||||
): boolean {
|
||||
return (
|
||||
this.isBaseFreight(appliesTo, trigger) ||
|
||||
isCustomsClearanceTrigger(trigger) ||
|
||||
trigger === 'WITH_RETURN' ||
|
||||
trigger === 'FUEL'
|
||||
trigger === 'FUEL' ||
|
||||
isContainerHazardRate(trigger, rateUnit)
|
||||
);
|
||||
}
|
||||
|
||||
/** Surcharges that carry a trade direction; everything else is direction-agnostic. */
|
||||
private isDirectedSurcharge(trigger: Rate['trigger'], rateUnit: Rate['rateUnit']): boolean {
|
||||
return DIRECTED_SURCHARGE_TRIGGERS.includes(trigger) || isContainerHazardRate(trigger, rateUnit);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when pricing resolves exactly ONE rate for this shape (base freight,
|
||||
* customs clearance, lashing, empty-container return — all `find()`-based
|
||||
@@ -174,9 +189,10 @@ export class RatesService {
|
||||
private resolvesSingleRate(
|
||||
appliesTo: Rate['appliesTo'],
|
||||
trigger: Rate['trigger'],
|
||||
rateUnit: Rate['rateUnit'],
|
||||
): boolean {
|
||||
return (
|
||||
this.isRouteScoped(appliesTo, trigger) ||
|
||||
this.isRouteScoped(appliesTo, trigger, rateUnit) ||
|
||||
trigger === 'LASHING' ||
|
||||
trigger === 'CANCELLATION'
|
||||
);
|
||||
@@ -191,8 +207,8 @@ export class RatesService {
|
||||
appliesTo: Rate['appliesTo'],
|
||||
tradeDirection: string | null,
|
||||
): { origin: YardCountry; destination: YardCountry } {
|
||||
// DOMESTIC only reaches here on a FUEL rate's intercity lane — it stays
|
||||
// inside Ethiopia exactly like intercity base freight.
|
||||
// DOMESTIC only reaches here on a FUEL or container-hazard rate's intercity
|
||||
// lane — it stays inside Ethiopia exactly like intercity base freight.
|
||||
if (appliesTo === 'INTERCITY' || tradeDirection === 'DOMESTIC') {
|
||||
return { origin: YardCountry.ETHIOPIA, destination: YardCountry.ETHIOPIA };
|
||||
}
|
||||
@@ -212,12 +228,13 @@ export class RatesService {
|
||||
private async resolveYardScope(input: {
|
||||
appliesTo: Rate['appliesTo'];
|
||||
trigger: Rate['trigger'];
|
||||
rateUnit: Rate['rateUnit'];
|
||||
tradeDirection: string | null;
|
||||
originYardId?: string | null;
|
||||
destinationYardId?: string | null;
|
||||
}): Promise<YardScope> {
|
||||
const { appliesTo, trigger, tradeDirection } = input;
|
||||
if (!this.isRouteScoped(appliesTo, trigger)) {
|
||||
const { appliesTo, trigger, rateUnit, tradeDirection } = input;
|
||||
if (!this.isRouteScoped(appliesTo, trigger, rateUnit)) {
|
||||
return { originYardId: null, destinationYardId: null };
|
||||
}
|
||||
|
||||
@@ -263,14 +280,36 @@ export class RatesService {
|
||||
private assertScopeCoherent(input: {
|
||||
appliesTo: Rate['appliesTo'];
|
||||
trigger: Rate['trigger'];
|
||||
rateUnit: Rate['rateUnit'];
|
||||
tradeDirection: string | null;
|
||||
intercityKind: string | null;
|
||||
cargoKind: string | null;
|
||||
containerTypeId: string | null;
|
||||
cargoTypeId: string | null;
|
||||
}): void {
|
||||
const { appliesTo, trigger, tradeDirection, intercityKind, cargoKind } = input;
|
||||
const { appliesTo, trigger, rateUnit, tradeDirection, intercityKind, cargoKind } = input;
|
||||
const { containerTypeId, cargoTypeId } = input;
|
||||
if (isContainerHazardRate(trigger, rateUnit)) {
|
||||
// The container hazard surcharge is sold per lane like the empty-return
|
||||
// service: the direction says which countries the leg spans (DOMESTIC =
|
||||
// intercity, inside Ethiopia) and the box size may narrow it (a 20ft and
|
||||
// a 40ft hazardous box price differently; no size = the lane's catch-all).
|
||||
if (
|
||||
tradeDirection !== 'IMPORT' &&
|
||||
tradeDirection !== 'EXPORT' &&
|
||||
tradeDirection !== 'DOMESTIC'
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'A per-container hazardous surcharge must say whether it covers IMPORT, EXPORT or DOMESTIC (intercity).',
|
||||
);
|
||||
}
|
||||
if (cargoTypeId) {
|
||||
throw new BadRequestException(
|
||||
'A per-container hazardous surcharge cannot be scoped to a bulk cargo type.',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isCustomsClearanceTrigger(trigger) || trigger === 'CANCELLATION') {
|
||||
// Both fees are sold per direction + cargo kind + type: customs clearance
|
||||
// per lane, the wagon cancellation fee per direction only.
|
||||
@@ -602,18 +641,12 @@ export class RatesService {
|
||||
// Surcharges (trigger ≠ ALWAYS) carry no direction/scope — clear them so
|
||||
// the engine never accidentally narrows a surcharge by container/direction.
|
||||
// Exceptions: the directed surcharges (customs clearance, cancellation,
|
||||
// empty-container return, lashing, fuel) keep direction + cargo scope.
|
||||
// empty-container return, lashing, fuel, per-container hazard) keep
|
||||
// direction + cargo scope.
|
||||
const isSurcharge = trigger !== 'ALWAYS';
|
||||
const cargoKind = CARGO_KIND_TRIGGERS.includes(trigger)
|
||||
? ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? null)
|
||||
: null;
|
||||
const containerTypeId =
|
||||
trigger === 'WITH_RETURN' ||
|
||||
(CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'CONTAINER')
|
||||
? (dto.containerTypeId ?? null)
|
||||
: isSurcharge
|
||||
? null
|
||||
: (dto.containerTypeId ?? null);
|
||||
const cargoTypeId =
|
||||
(CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'BULK') ||
|
||||
trigger === 'LASHING' ||
|
||||
@@ -622,12 +655,30 @@ export class RatesService {
|
||||
: isSurcharge
|
||||
? null
|
||||
: (dto.cargoTypeId ?? null);
|
||||
// The unit is resolved before the scope because for hazard it IS the shape:
|
||||
// per container is the lane-sold container surcharge (direction + yards +
|
||||
// optional box size), per ton the global bulk one.
|
||||
const rateUnit = await this.resolveRateUnit(
|
||||
appliesTo,
|
||||
trigger,
|
||||
dto.rateUnit as Rate['rateUnit'] | undefined,
|
||||
cargoKind,
|
||||
cargoTypeId,
|
||||
);
|
||||
const containerTypeId =
|
||||
trigger === 'WITH_RETURN' ||
|
||||
isContainerHazardRate(trigger, rateUnit) ||
|
||||
(CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'CONTAINER')
|
||||
? (dto.containerTypeId ?? null)
|
||||
: isSurcharge
|
||||
? null
|
||||
: (dto.containerTypeId ?? null);
|
||||
// Intercity never leaves Ethiopia, so it has no trade direction to store —
|
||||
// its yard pair already says where it runs. (Fuel is the exception: its
|
||||
// intercity lane is stored as DOMESTIC, since appliesTo = OTHER says
|
||||
// nothing about the direction.)
|
||||
// its yard pair already says where it runs. (Fuel and container hazard are
|
||||
// the exception: their intercity lane is stored as DOMESTIC, since
|
||||
// appliesTo = OTHER says nothing about the direction.)
|
||||
const tradeDirection =
|
||||
DIRECTED_SURCHARGE_TRIGGERS.includes(trigger)
|
||||
this.isDirectedSurcharge(trigger, rateUnit)
|
||||
? (dto.tradeDirection ?? null)
|
||||
: isSurcharge || appliesTo === 'INTERCITY'
|
||||
? null
|
||||
@@ -637,6 +688,7 @@ export class RatesService {
|
||||
this.assertScopeCoherent({
|
||||
appliesTo,
|
||||
trigger,
|
||||
rateUnit,
|
||||
tradeDirection,
|
||||
intercityKind,
|
||||
cargoKind,
|
||||
@@ -646,6 +698,7 @@ export class RatesService {
|
||||
const { originYardId, destinationYardId } = await this.resolveYardScope({
|
||||
appliesTo,
|
||||
trigger,
|
||||
rateUnit,
|
||||
tradeDirection,
|
||||
originYardId: dto.originYardId,
|
||||
destinationYardId: dto.destinationYardId,
|
||||
@@ -662,13 +715,6 @@ export class RatesService {
|
||||
tradeDirection,
|
||||
isBulk: this.resolvesToBulk(appliesTo, intercityKind),
|
||||
});
|
||||
const rateUnit = await this.resolveRateUnit(
|
||||
appliesTo,
|
||||
trigger,
|
||||
dto.rateUnit as Rate['rateUnit'] | undefined,
|
||||
cargoKind,
|
||||
cargoTypeId,
|
||||
);
|
||||
|
||||
const { minKm, maxKm } = this.resolveLastMileBand({
|
||||
appliesTo,
|
||||
@@ -689,7 +735,7 @@ export class RatesService {
|
||||
|
||||
await this.assertNoDuplicatePattern({
|
||||
rateType,
|
||||
...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }),
|
||||
...(this.resolvesSingleRate(appliesTo, trigger, rateUnit) ? {} : { rateUnit }),
|
||||
shippingLineCompanyId,
|
||||
containerTypeId,
|
||||
cargoTypeId,
|
||||
@@ -826,15 +872,6 @@ export class RatesService {
|
||||
: ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ??
|
||||
(existing.containerTypeId ? 'CONTAINER' : 'BULK'));
|
||||
|
||||
const keepsContainerType =
|
||||
!isSurcharge ||
|
||||
trigger === 'WITH_RETURN' ||
|
||||
(CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'CONTAINER');
|
||||
const containerTypeId = !keepsContainerType
|
||||
? null
|
||||
: dto.containerTypeId !== undefined
|
||||
? dto.containerTypeId
|
||||
: existing.containerTypeId;
|
||||
const keepsCargoType =
|
||||
!isSurcharge ||
|
||||
(CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'BULK') ||
|
||||
@@ -845,8 +882,32 @@ export class RatesService {
|
||||
: dto.cargoTypeId !== undefined
|
||||
? dto.cargoTypeId
|
||||
: existing.cargoTypeId;
|
||||
// Re-validate the unit against the (possibly changed) shape before the
|
||||
// scope is settled: for hazard the unit decides whether the rate is the
|
||||
// lane-sold container surcharge or the global bulk one. Overweight is
|
||||
// forced to PER_TON.
|
||||
const requestedUnit = (dto.rateUnit as Rate['rateUnit']) ?? existing.rateUnit;
|
||||
const rateUnit = await this.resolveRateUnit(
|
||||
appliesTo,
|
||||
trigger,
|
||||
requestedUnit,
|
||||
cargoKind,
|
||||
cargoTypeId ?? null,
|
||||
);
|
||||
updates.rateUnit = rateUnit;
|
||||
|
||||
const keepsContainerType =
|
||||
!isSurcharge ||
|
||||
trigger === 'WITH_RETURN' ||
|
||||
isContainerHazardRate(trigger, rateUnit) ||
|
||||
(CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'CONTAINER');
|
||||
const containerTypeId = !keepsContainerType
|
||||
? null
|
||||
: dto.containerTypeId !== undefined
|
||||
? dto.containerTypeId
|
||||
: existing.containerTypeId;
|
||||
const tradeDirection =
|
||||
DIRECTED_SURCHARGE_TRIGGERS.includes(trigger)
|
||||
this.isDirectedSurcharge(trigger, rateUnit)
|
||||
? dto.tradeDirection !== undefined
|
||||
? dto.tradeDirection
|
||||
: existing.tradeDirection
|
||||
@@ -868,6 +929,7 @@ export class RatesService {
|
||||
this.assertScopeCoherent({
|
||||
appliesTo,
|
||||
trigger,
|
||||
rateUnit,
|
||||
tradeDirection: updates.tradeDirection,
|
||||
intercityKind,
|
||||
cargoKind,
|
||||
@@ -879,6 +941,7 @@ export class RatesService {
|
||||
const yardScope = await this.resolveYardScope({
|
||||
appliesTo,
|
||||
trigger,
|
||||
rateUnit,
|
||||
tradeDirection: updates.tradeDirection,
|
||||
originYardId:
|
||||
dto.originYardId !== undefined ? dto.originYardId : existing.originYardId,
|
||||
@@ -910,18 +973,6 @@ export class RatesService {
|
||||
});
|
||||
updates.rateType = rateType;
|
||||
|
||||
// Re-validate the unit against the (possibly changed) shape; overweight is
|
||||
// forced to PER_TON.
|
||||
const requestedUnit = (dto.rateUnit as Rate['rateUnit']) ?? existing.rateUnit;
|
||||
const rateUnit = await this.resolveRateUnit(
|
||||
appliesTo,
|
||||
trigger,
|
||||
requestedUnit,
|
||||
cargoKind,
|
||||
updates.cargoTypeId,
|
||||
);
|
||||
updates.rateUnit = rateUnit;
|
||||
|
||||
const { minKm, maxKm } = this.resolveLastMileBand({
|
||||
appliesTo,
|
||||
rateUnit,
|
||||
@@ -948,7 +999,7 @@ export class RatesService {
|
||||
// Guard the pattern uniqueness for the new identity, ignoring this row.
|
||||
await this.assertNoDuplicatePattern({
|
||||
rateType,
|
||||
...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }),
|
||||
...(this.resolvesSingleRate(appliesTo, trigger, rateUnit) ? {} : { rateUnit }),
|
||||
shippingLineCompanyId,
|
||||
containerTypeId: updates.containerTypeId,
|
||||
cargoTypeId: updates.cargoTypeId,
|
||||
|
||||
Reference in New Issue
Block a user