mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 16:40:56 +00:00
- Introduced new boolean fields (isHazardous, isReefer, isReturn) in UnitDraft and related interfaces to allow individual container handling options. - Updated emptyUnit function to initialize these new fields. - Modified GlCreateBookingForm to handle and display these options for each container. - Adjusted calculations for hazardous, reefer, and return quantities based on the new handling options. - Updated the schema for container units and booking container lines to include handling options. - Added migration to support the new return flag in the database. - Enhanced various components to reflect gross weight calculations, ensuring consistency across the application.
582 lines
20 KiB
TypeScript
582 lines
20 KiB
TypeScript
import { Inject, Injectable, BadRequestException } from '@nestjs/common';
|
|
import { DataSource } from 'typeorm';
|
|
import { BookingApprovalStep } from '../bookings/entities/booking-approval-step.entity';
|
|
import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.entity';
|
|
import { Rate, RateTrigger } from './entities/rate.entity';
|
|
import {
|
|
ICargoTypesRepository,
|
|
CARGO_TYPES_REPOSITORY,
|
|
} from './interfaces/cargo-types.repository.interface';
|
|
import {
|
|
IServiceTypesRepository,
|
|
SERVICE_TYPES_REPOSITORY,
|
|
} from './interfaces/service-types.repository.interface';
|
|
import {
|
|
IWeightLimitRulesRepository,
|
|
WEIGHT_LIMIT_RULES_REPOSITORY,
|
|
} from './interfaces/weight-limit-rules.repository.interface';
|
|
import {
|
|
IPriorityConfigsRepository,
|
|
PRIORITY_CONFIGS_REPOSITORY,
|
|
} from './interfaces/priority-configs.repository.interface';
|
|
import {
|
|
IRatesRepository,
|
|
RATES_REPOSITORY,
|
|
} from './interfaces/rates.repository.interface';
|
|
import {
|
|
IApprovalRulesRepository,
|
|
APPROVAL_RULES_REPOSITORY,
|
|
} from './interfaces/approval-rules.repository.interface';
|
|
import {
|
|
IShippingLinesRepository,
|
|
SHIPPING_LINES_REPOSITORY,
|
|
} from './interfaces/shipping-lines.repository.interface';
|
|
import { DEFAULT_APPROVAL_RULE_ROWS } from './approval-rules.defaults';
|
|
import { GOVERNMENT_PRIORITY_BONUS } from './government-priority.constants';
|
|
|
|
export interface BookingContainerEvalInput {
|
|
containerTypeId: string;
|
|
quantity: number;
|
|
vgmPerUnitTons: number;
|
|
totalVgmTons: number;
|
|
isReefer?: boolean;
|
|
isOverweight?: boolean;
|
|
overweightExcessTons?: number | null;
|
|
/**
|
|
* How many individual containers on this line opted into each handling
|
|
* service. PER_CONTAINER surcharges bill these counts, not the line
|
|
* quantity — 20 containers with 10 hazardous bill hazard on 10.
|
|
*/
|
|
hazardousQuantity?: number;
|
|
reeferQuantity?: number;
|
|
returnQuantity?: number;
|
|
}
|
|
|
|
export interface BookingEvaluationInput {
|
|
cargoTypeId?: string | null;
|
|
freightType?: 'CONTAINER' | 'BULK';
|
|
serviceTypeId: string;
|
|
paymentCurrency: string;
|
|
tradeDirection: string;
|
|
isHazardous: boolean;
|
|
/** Booking-level reefer flag; ORed with per-container reefer. */
|
|
isReefer?: boolean;
|
|
/**
|
|
* Booking ships with empty-container return (equipment_return = WITH_RETURN,
|
|
* container freight only). Fires the WITH_RETURN surcharge like hazard/reefer.
|
|
*/
|
|
withReturn?: boolean;
|
|
isGovernment?: boolean;
|
|
allowConsolidation?: boolean;
|
|
shippingLineId?: string | null;
|
|
/**
|
|
* Booking's cargo type needs EDR-provided lashing/securing (cargoType
|
|
* hasLashing = true). Fires the flat LASHING surcharge. Resolved by the
|
|
* engine from cargoTypeId when omitted.
|
|
*/
|
|
hasLashing?: boolean;
|
|
totalWagons: number;
|
|
/**
|
|
* Total bulk tonnage on the booking (cargoTotalWeightVgm). Used to scale
|
|
* PER_TON surcharges (e.g. the bulk reefer surcharge). 0/undefined for
|
|
* container freight, which is scaled by container count instead.
|
|
*/
|
|
bulkTons?: number;
|
|
containers: BookingContainerEvalInput[];
|
|
}
|
|
|
|
export interface AppliedCargoModifier {
|
|
/** The trigger-based rate that produced this surcharge line. */
|
|
rateId: string;
|
|
/** Stable display/audit code, derived from the rate's trigger + rateType. */
|
|
surchargeCode: string;
|
|
triggerValue: number | null;
|
|
calculatedAmount: number;
|
|
currency: string;
|
|
}
|
|
|
|
export interface ContainerWeightResult {
|
|
containerTypeId: string;
|
|
weightLimitRuleId: string | null;
|
|
isOverweight: boolean;
|
|
overweightExcessTons: number | null;
|
|
}
|
|
|
|
export interface RuleEvaluationResult {
|
|
priorityScore: number;
|
|
appliedModifiers: AppliedCargoModifier[];
|
|
containerWeightResults: ContainerWeightResult[];
|
|
warnings: string[];
|
|
hardBlocked: string[];
|
|
requiresDirectorApproval: boolean;
|
|
}
|
|
|
|
@Injectable()
|
|
export class RuleEngineService {
|
|
constructor(
|
|
@Inject(CARGO_TYPES_REPOSITORY)
|
|
private readonly cargoTypesRepo: ICargoTypesRepository,
|
|
@Inject(SERVICE_TYPES_REPOSITORY)
|
|
private readonly serviceTypesRepo: IServiceTypesRepository,
|
|
@Inject(WEIGHT_LIMIT_RULES_REPOSITORY)
|
|
private readonly weightLimitRulesRepo: IWeightLimitRulesRepository,
|
|
@Inject(PRIORITY_CONFIGS_REPOSITORY)
|
|
private readonly priorityConfigsRepo: IPriorityConfigsRepository,
|
|
@Inject(RATES_REPOSITORY)
|
|
private readonly ratesRepo: IRatesRepository,
|
|
@Inject(APPROVAL_RULES_REPOSITORY)
|
|
private readonly approvalRulesRepo: IApprovalRulesRepository,
|
|
@Inject(SHIPPING_LINES_REPOSITORY)
|
|
private readonly shippingLinesRepo: IShippingLinesRepository,
|
|
private readonly dataSource: DataSource,
|
|
) {}
|
|
|
|
/**
|
|
* Evaluate all rule engine rules against a booking snapshot.
|
|
*/
|
|
async evaluate(input: BookingEvaluationInput): Promise<RuleEvaluationResult> {
|
|
const warnings: string[] = [];
|
|
const hardBlocked: string[] = [];
|
|
const appliedModifiers: AppliedCargoModifier[] = [];
|
|
const containerWeightResults: ContainerWeightResult[] = [];
|
|
let priorityScore = 0;
|
|
let requiresDirectorApproval = false;
|
|
|
|
if (input.freightType === 'BULK') {
|
|
requiresDirectorApproval = true;
|
|
}
|
|
|
|
// Lashing is a cargo-type property: a booking incurs the flat LASHING
|
|
// surcharge when its cargo type has hasLashing = true. Resolve it here so
|
|
// matchesTrigger can fire the LASHING rate. Falls back to an explicit
|
|
// input flag when no cargo type is set (e.g. container bookings).
|
|
let hasLashing = input.hasLashing === true;
|
|
if (input.cargoTypeId) {
|
|
const cargoType = await this.cargoTypesRepo.findById(input.cargoTypeId);
|
|
if (!cargoType) {
|
|
hardBlocked.push(`Cargo type ${input.cargoTypeId} not found`);
|
|
} else {
|
|
if (cargoType.requiresDirectorApproval) {
|
|
requiresDirectorApproval = true;
|
|
}
|
|
if (cargoType.hasLashing) {
|
|
hasLashing = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
hardBlocked.push(
|
|
...(await this.capacityViolations(input.containers, input.tradeDirection)),
|
|
);
|
|
|
|
for (const container of input.containers) {
|
|
const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId(
|
|
container.containerTypeId,
|
|
input.tradeDirection,
|
|
);
|
|
const rule = rules[0];
|
|
let isOverweight = container.isOverweight ?? false;
|
|
let excess = container.overweightExcessTons ?? null;
|
|
|
|
if (rule) {
|
|
const maxTotal = Number(rule.maxVgmTons) * container.quantity;
|
|
const totalVgm = container.totalVgmTons;
|
|
if (totalVgm > maxTotal) {
|
|
isOverweight = true;
|
|
excess = Math.max(0, totalVgm - maxTotal);
|
|
warnings.push(
|
|
`Container type ${container.containerTypeId} VGM ${totalVgm}t exceeds limit ${maxTotal}t`,
|
|
);
|
|
}
|
|
containerWeightResults.push({
|
|
containerTypeId: container.containerTypeId,
|
|
weightLimitRuleId: rule.id,
|
|
isOverweight,
|
|
overweightExcessTons: excess,
|
|
});
|
|
} else {
|
|
containerWeightResults.push({
|
|
containerTypeId: container.containerTypeId,
|
|
weightLimitRuleId: null,
|
|
isOverweight,
|
|
overweightExcessTons: excess,
|
|
});
|
|
}
|
|
}
|
|
|
|
const serviceType = await this.serviceTypesRepo.findById(input.serviceTypeId);
|
|
const includesCustoms = serviceType?.includesCustoms ?? false;
|
|
|
|
// Additive priority blocks, each keyed on the booking's total wagon count:
|
|
// - WAGON rules apply regardless of currency.
|
|
// - CURRENCY rules apply only when the payment currency matches.
|
|
// - CUSTOMS rules apply only when the service type includes customs.
|
|
const priorityConfigs = await this.priorityConfigsRepo.findAllActive();
|
|
const wagonsInRange = (cfg: { minWagonCount: number; maxWagonCount: number }) =>
|
|
input.totalWagons >= cfg.minWagonCount &&
|
|
input.totalWagons <= cfg.maxWagonCount;
|
|
|
|
for (const cfg of priorityConfigs) {
|
|
const applies =
|
|
cfg.type === 'WAGON' ||
|
|
(cfg.type === 'CURRENCY' && cfg.currency === input.paymentCurrency) ||
|
|
(cfg.type === 'CUSTOMS' && includesCustoms);
|
|
if (applies && wagonsInRange(cfg)) {
|
|
priorityScore += cfg.scorePoints;
|
|
}
|
|
}
|
|
|
|
if (input.isGovernment) {
|
|
priorityScore += GOVERNMENT_PRIORITY_BONUS;
|
|
}
|
|
|
|
let shippingLineMapped = false;
|
|
if (input.shippingLineId) {
|
|
const line = await this.shippingLinesRepo.findById(input.shippingLineId);
|
|
shippingLineMapped = Boolean(line?.mappedToCode);
|
|
}
|
|
|
|
const hasReefer =
|
|
input.isReefer === true || input.containers.some((c) => c.isReefer);
|
|
const hasOverweight = containerWeightResults.some((r) => r.isOverweight);
|
|
|
|
// Surcharges are now self-describing rates: any LIVE rate whose `trigger`
|
|
// is not ALWAYS. Each fires independently and stacks on top of base freight
|
|
// — hazard + reefer + overweight all add together, each with its own unit.
|
|
//
|
|
// A given surcharge identity (same trigger + rateType + unit + value +
|
|
// scope) must contribute exactly ONE line. Duplicate LIVE rate rows — e.g.
|
|
// from a non-idempotent seeder — would otherwise repeat the same surcharge
|
|
// many times and inflate the total, so we collapse them to one row each.
|
|
const liveRates = await this.ratesRepo.findLiveRates();
|
|
const surchargeRates = this.dedupeRatesBySignature(
|
|
liveRates.filter((r) => r.trigger && r.trigger !== 'ALWAYS'),
|
|
);
|
|
|
|
for (const rate of surchargeRates) {
|
|
const triggered = this.matchesTrigger(rate.trigger, {
|
|
isHazardous: input.isHazardous,
|
|
hasReefer,
|
|
withReturn: input.withReturn ?? false,
|
|
hasOverweight,
|
|
shippingLineMapped,
|
|
allowConsolidation: input.allowConsolidation ?? false,
|
|
hasLashing,
|
|
});
|
|
if (!triggered) continue;
|
|
|
|
// Surcharges scale by their own rateUnit, so the same trigger can bill the
|
|
// right way per freight shape — e.g. a PER_TON reefer rate multiplies the
|
|
// bulk tonnage, while a PER_CONTAINER reefer rate multiplies the container
|
|
// count. triggerValue records the quantity billed (shown on the breakdown).
|
|
const rateValue = Number(rate.rateValue);
|
|
const containerCount = input.containers.reduce(
|
|
(sum, c) => sum + Number(c.quantity || 0),
|
|
0,
|
|
);
|
|
const overweightExcessTons = containerWeightResults.reduce(
|
|
(sum, r) => sum + (r.overweightExcessTons ?? 0),
|
|
0,
|
|
);
|
|
/**
|
|
* Containers that opted into this trigger's handling service, summed
|
|
* across lines. null when the trigger isn't per-container handling (or
|
|
* no line carries a count) so the caller falls back to the full count.
|
|
*/
|
|
const optedInCount = (trigger: string | null): number | null => {
|
|
const field =
|
|
trigger === 'HAZARDOUS'
|
|
? 'hazardousQuantity'
|
|
: trigger === 'REEFER'
|
|
? 'reeferQuantity'
|
|
: trigger === 'WITH_RETURN'
|
|
? 'returnQuantity'
|
|
: null;
|
|
if (!field) return null;
|
|
const total = input.containers.reduce(
|
|
(sum, c) => sum + Number(c[field] ?? 0),
|
|
0,
|
|
);
|
|
return total > 0 ? total : null;
|
|
};
|
|
|
|
let triggerValue: number | null = null;
|
|
let calculatedAmount: number;
|
|
|
|
switch (rate.rateUnit) {
|
|
case 'PER_TON':
|
|
// OVERWEIGHT bills the excess tons; every other PER_TON surcharge
|
|
// (e.g. bulk reefer) bills the full bulk tonnage.
|
|
triggerValue =
|
|
rate.trigger === 'OVERWEIGHT'
|
|
? overweightExcessTons
|
|
: Number(input.bulkTons ?? 0);
|
|
calculatedAmount = triggerValue * rateValue;
|
|
break;
|
|
case 'PER_CONTAINER':
|
|
// Handling surcharges bill only the containers that opted in, not the
|
|
// whole line — 20 containers with 10 hazardous bill hazard on 10.
|
|
// Legacy bookings carry no per-container counts (all 0) while their
|
|
// booking-level flag is set, so fall back to the full count there.
|
|
triggerValue = optedInCount(rate.trigger) ?? containerCount;
|
|
calculatedAmount = triggerValue * rateValue;
|
|
break;
|
|
case 'PER_WAGON':
|
|
triggerValue = input.totalWagons;
|
|
calculatedAmount = triggerValue * rateValue;
|
|
break;
|
|
case 'FLAT':
|
|
default:
|
|
// FLAT (and any unknown unit) bills once.
|
|
calculatedAmount = rateValue;
|
|
break;
|
|
}
|
|
|
|
// Safety guard: never include a surcharge with a non-positive amount (a
|
|
// zero-rate or zero-trigger line would otherwise show as a confusing
|
|
// "free" surcharge on the breakdown).
|
|
if (!(calculatedAmount > 0)) continue;
|
|
|
|
appliedModifiers.push({
|
|
rateId: rate.id,
|
|
surchargeCode: this.surchargeCode(rate),
|
|
triggerValue,
|
|
calculatedAmount,
|
|
currency: rate.currency,
|
|
});
|
|
}
|
|
|
|
return {
|
|
priorityScore,
|
|
appliedModifiers,
|
|
containerWeightResults,
|
|
warnings,
|
|
hardBlocked,
|
|
requiresDirectorApproval,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Messages for container lines whose total weight exceeds the hard capacity
|
|
* ceiling (weight_limit_rules.max_capacity_tons). Non-empty ⇒ the booking
|
|
* must not be created at all. Overweight (above maxVgmTons but within
|
|
* capacity) is NOT reported here — that is a surcharge, not a block.
|
|
*/
|
|
async capacityViolations(
|
|
containers: Array<{
|
|
containerTypeId: string;
|
|
quantity: number;
|
|
totalVgmTons: number;
|
|
}>,
|
|
tradeDirection: string,
|
|
): Promise<string[]> {
|
|
const violations: string[] = [];
|
|
for (const container of containers) {
|
|
const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId(
|
|
container.containerTypeId,
|
|
tradeDirection,
|
|
);
|
|
const rule = rules[0];
|
|
if (!rule || rule.maxCapacityTons == null) continue;
|
|
const perUnit = Number(rule.maxCapacityTons);
|
|
const maxTotal = perUnit * container.quantity;
|
|
if (container.totalVgmTons > maxTotal) {
|
|
const label = rule.containerType?.code ?? container.containerTypeId;
|
|
violations.push(
|
|
`${label} total weight ${container.totalVgmTons}t exceeds the maximum capacity of ${maxTotal}t (${perUnit}t per unit) — the booking cannot be created; reduce the cargo weight`,
|
|
);
|
|
}
|
|
}
|
|
return violations;
|
|
}
|
|
|
|
/**
|
|
* Ensure ITMLS default approval chains exist (container + bulk). Idempotent.
|
|
*/
|
|
async ensureDefaultApprovalRules(): Promise<void> {
|
|
for (const flag of [false, true] as const) {
|
|
const existing = await this.approvalRulesRepo.findChainForCargo(flag);
|
|
if (existing.length > 0) continue;
|
|
|
|
const rows = DEFAULT_APPROVAL_RULE_ROWS.filter(
|
|
(r) => r.requiresDirectorApproval === flag,
|
|
);
|
|
for (const row of rows) {
|
|
await this.approvalRulesRepo.create({
|
|
requiresDirectorApproval: row.requiresDirectorApproval,
|
|
stepOrder: row.stepOrder,
|
|
requiredRole: row.requiredRole,
|
|
actionLabel: row.actionLabel,
|
|
blocksRole: row.blocksRole,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Instantiate booking_approval_step rows from approval_rules by freight type.
|
|
*/
|
|
async instantiateApprovalSteps(
|
|
bookingId: string,
|
|
options: {
|
|
freightType: 'CONTAINER' | 'BULK';
|
|
cargoTypeId?: string | null;
|
|
},
|
|
): Promise<BookingApprovalStep[]> {
|
|
await this.ensureDefaultApprovalRules();
|
|
|
|
let requiresDirectorApproval = false;
|
|
|
|
if (options.cargoTypeId) {
|
|
const cargoType = await this.cargoTypesRepo.findById(options.cargoTypeId);
|
|
if (!cargoType) {
|
|
throw new BadRequestException(`Cargo type ${options.cargoTypeId} not found`);
|
|
}
|
|
requiresDirectorApproval = cargoType.requiresDirectorApproval;
|
|
}
|
|
|
|
const chain = await this.approvalRulesRepo.findChainForCargo(
|
|
requiresDirectorApproval,
|
|
);
|
|
|
|
if (chain.length === 0) {
|
|
throw new BadRequestException(
|
|
`Approval chain could not be loaded for requiresDirectorApproval=${requiresDirectorApproval}.`,
|
|
);
|
|
}
|
|
|
|
const stepRepo = this.dataSource.getRepository(BookingApprovalStep);
|
|
const steps: BookingApprovalStep[] = [];
|
|
|
|
for (const rule of chain) {
|
|
const step = stepRepo.create({
|
|
bookingId,
|
|
approvalRuleId: rule.id,
|
|
stepOrder: rule.stepOrder,
|
|
requiredRole: rule.requiredRole,
|
|
blocksRole: rule.blocksRole ?? null,
|
|
status: 'PENDING',
|
|
});
|
|
steps.push(await stepRepo.save(step));
|
|
}
|
|
|
|
return steps;
|
|
}
|
|
|
|
/**
|
|
* Snapshot only the rates used in a booking's final price.
|
|
*/
|
|
async snapshotRates(
|
|
bookingId: string,
|
|
rates: Array<{
|
|
id: string;
|
|
rateType: string;
|
|
rateValue: number;
|
|
rateUnit: string;
|
|
currency: string;
|
|
}>,
|
|
): Promise<BookingRateSnapshot[]> {
|
|
const snapshotRepo = this.dataSource.getRepository(BookingRateSnapshot);
|
|
const now = new Date();
|
|
const seen = new Set<string>();
|
|
const snapshots: BookingRateSnapshot[] = [];
|
|
|
|
for (const rate of rates) {
|
|
if (seen.has(rate.id)) continue;
|
|
seen.add(rate.id);
|
|
|
|
const snapshot = snapshotRepo.create({
|
|
bookingId,
|
|
rateId: rate.id,
|
|
rateType: rate.rateType,
|
|
rateValue: rate.rateValue,
|
|
rateUnit: rate.rateUnit,
|
|
currency: rate.currency,
|
|
snapshottedAt: now,
|
|
});
|
|
snapshots.push(await snapshotRepo.save(snapshot));
|
|
}
|
|
|
|
return snapshots;
|
|
}
|
|
|
|
/** Guard helper — throws BadRequestException if hardBlocked is non-empty. */
|
|
assertNoHardBlocks(result: RuleEvaluationResult): void {
|
|
if (result.hardBlocked.length > 0) {
|
|
throw new BadRequestException(result.hardBlocked.join('; '));
|
|
}
|
|
}
|
|
|
|
private matchesTrigger(
|
|
trigger: RateTrigger,
|
|
state: {
|
|
isHazardous: boolean;
|
|
hasReefer: boolean;
|
|
withReturn: boolean;
|
|
hasOverweight: boolean;
|
|
shippingLineMapped: boolean;
|
|
allowConsolidation: boolean;
|
|
hasLashing: boolean;
|
|
},
|
|
): boolean {
|
|
// Coerce defensively: a flag may arrive as the string "true"/"false" (e.g.
|
|
// from multipart form-data) and a non-empty "false" string is truthy.
|
|
const truthy = (v: unknown): boolean => v === true || v === 'true';
|
|
switch (trigger) {
|
|
case 'HAZARDOUS':
|
|
return truthy(state.isHazardous);
|
|
case 'REEFER':
|
|
return truthy(state.hasReefer);
|
|
case 'WITH_RETURN':
|
|
return truthy(state.withReturn);
|
|
case 'OVERWEIGHT':
|
|
return truthy(state.hasOverweight);
|
|
case 'SHIPPING_LINE':
|
|
return truthy(state.shippingLineMapped);
|
|
case 'CONSOLIDATION':
|
|
return truthy(state.allowConsolidation);
|
|
case 'LASHING':
|
|
return truthy(state.hasLashing);
|
|
// CANCELLATION / DEMURRAGE / PIL_EXTRA_FEE are contextual charges applied
|
|
// explicitly elsewhere (not auto-triggered by a booking's cargo flags).
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** Stable surcharge code for display + audit, derived from the rate. */
|
|
private surchargeCode(rate: Rate): string {
|
|
return rate.rateType ?? rate.trigger;
|
|
}
|
|
|
|
/**
|
|
* Collapse rates that describe the same charge to a single representative.
|
|
*
|
|
* Two rates are "the same" when they would produce an identical price line:
|
|
* same trigger, rateType, unit, value, currency, and scoping (container /
|
|
* cargo type). Duplicate rows (e.g. a seeder run more than once) therefore
|
|
* stack into one line instead of repeating — keeping the breakdown clean and
|
|
* the total correct. The first row of each signature is kept so an existing
|
|
* rateId is preserved for snapshotting.
|
|
*/
|
|
private dedupeRatesBySignature(rates: Rate[]): Rate[] {
|
|
const seen = new Set<string>();
|
|
const result: Rate[] = [];
|
|
for (const rate of rates) {
|
|
const signature = [
|
|
rate.trigger,
|
|
rate.rateType,
|
|
rate.rateUnit,
|
|
Number(rate.rateValue),
|
|
rate.currency,
|
|
rate.containerTypeId ?? '',
|
|
rate.cargoTypeId ?? '',
|
|
].join('|');
|
|
if (seen.has(signature)) continue;
|
|
seen.add(signature);
|
|
result.push(rate);
|
|
}
|
|
return result;
|
|
}
|
|
}
|