This commit is contained in:
Marshal
2026-07-23 14:18:24 +00:00
parent 15a6bab5d0
commit 127676055b
15 changed files with 362 additions and 160 deletions

View File

@@ -0,0 +1,30 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Lashing is now BULK-only and sold per trade direction (IMPORT / EXPORT),
* optionally narrowed to one leaf commodity. Rates that no longer fit —
* container-scoped, or carrying no direction — cannot be mapped and are
* retired (SUPERSEDED + soft-deleted), kept readable for snapshot history.
* Matched on trigger, not rate_type (CONSOLIDATION shares rate_type LASHING).
*/
export class LashingBulkOnlyPerDirection2890000000000 implements MigrationInterface {
name = 'LashingBulkOnlyPerDirection2890000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE freight.rates
SET status = 'SUPERSEDED',
deleted_at = now(),
updated_at = now()
WHERE deleted_at IS NULL
AND "trigger" = 'LASHING'
AND (container_type_id IS NOT NULL
OR trade_direction IS NULL
OR trade_direction NOT IN ('IMPORT', 'EXPORT'));
`);
}
public async down(): Promise<void> {
// Retired rates stay retired — re-enter per-direction bulk rates instead.
}
}

View File

@@ -379,6 +379,25 @@ describe('BookingPricingService — customs clearance fee billed on the booking
expect(line!.amount).toBe(600); expect(line!.amount).toBe(600);
}); });
it('the fee scoped to the booking commodity wins over the catch-all', async () => {
const service = makeService({
liveRates: [
{ ...bulkFeePerTon, id: 'rate-cc-catchall', rateValue: 5 } as Rate,
{
...bulkFeePerTon,
id: 'rate-cc-sugar',
rateValue: 9,
cargoTypeId: 'cargo-1',
} as Rate,
],
});
const result = await service.computePriceForBooking(bulkBooking());
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE');
expect(line!.unitAmount).toBe(9); // commodity rate, not the 5 USD catch-all
expect(line!.amount).toBe(1080);
});
it('bills a PER_WAGON bulk fee on ceil(tons ÷ wagon capacity)', async () => { it('bills a PER_WAGON bulk fee on ceil(tons ÷ wagon capacity)', async () => {
const service = makeService({ const service = makeService({
liveRates: [{ ...bulkFeePerTon, rateUnit: 'PER_WAGON', rateValue: 50 } as Rate], liveRates: [{ ...bulkFeePerTon, rateUnit: 'PER_WAGON', rateValue: 50 } as Rate],

View File

@@ -1028,8 +1028,15 @@ export class BookingPricingService {
// Bulk — one fee for the whole booking. The bulk snapshot and the legacy // Bulk — one fee for the whole booking. The bulk snapshot and the legacy
// flat snapshot share the CUSTOMS_CLEARANCE code; both are the agreed fee. // flat snapshot share the CUSTOMS_CLEARANCE code; both are the agreed fee.
// Live lookup: the rate scoped to the booking's commodity wins; a
// commodity-less rate (legacy) is the catch-all fallback.
const frozen = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency); const frozen = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency);
const live = onLeg.find((r) => !r.containerTypeId); const live =
(booking.cargoTypeId
? onLeg.find(
(r) => !r.containerTypeId && r.cargoTypeId === booking.cargoTypeId,
)
: undefined) ?? onLeg.find((r) => !r.containerTypeId && !r.cargoTypeId);
if (!frozen && !live) { if (!frozen && !live) {
blocked.push(missingRateMessage('bulk cargo')); blocked.push(missingRateMessage('bulk cargo'));
return { lineItems, usedRates, blocked }; return { lineItems, usedRates, blocked };

View File

@@ -189,6 +189,36 @@ export class ContractPricingService {
}); });
} }
} }
// Lashing / cargo securing — BULK only, shown when the contract's commodity
// needs lashing (cargoType.hasLashing). The commodity-scoped rate for the
// contract's direction wins over the commodity-wide catch-all; billed at
// booking on the live rate (per ton / per wagon), this line is display.
if (contract.freightType === 'BULK') {
const scope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
if (scope?.cargoType?.hasLashing) {
const onDirection = liveRates.filter(
(r) =>
r.trigger === 'LASHING' &&
r.currency === 'USD' &&
!r.containerTypeId &&
r.tradeDirection === contract.tradeDirection,
);
const lashing =
onDirection.find((r) => r.cargoTypeId === scope.cargoTypeId) ??
onDirection.find((r) => !r.cargoTypeId);
if (lashing && Number(lashing.rateValue) > 0) {
lineItems.push({
code: 'LASHING',
label: `Lashing / cargo securing (${scope.cargoType.cargoTypeName})`,
unit: toContractUnit(lashing.rateUnit),
unitPrice: convert(Number(lashing.rateValue)),
cargoTypeCode: scope.cargoType.code ?? null,
conditionalOn: 'has_lashing',
});
}
}
}
// Empty-container return service — container contracts only, toggled on the // Empty-container return service — container contracts only, toggled on the
// contract like hazard/reefer. Billed at booking per WITH_RETURN container. // contract like hazard/reefer. Billed at booking per WITH_RETURN container.
if ( if (
@@ -295,18 +325,26 @@ export class ContractPricingService {
}); });
} }
} else { } else {
// Bulk fee = the route's type-less customs rate (per ton / per wagon). // Bulk fee the rate scoped to the contract's commodity wins; a
const rate = onLeg.find((r) => !r.containerTypeId); // commodity-less rate (legacy) is the catch-all fallback.
const scope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
const rate =
(scope?.cargoTypeId
? onLeg.find(
(r) => !r.containerTypeId && r.cargoTypeId === scope.cargoTypeId,
)
: undefined) ?? onLeg.find((r) => !r.containerTypeId && !r.cargoTypeId);
if (!rate || Number(rate.rateValue) <= 0) { if (!rate || Number(rate.rateValue) <= 0) {
throw new UnprocessableEntityException( throw new UnprocessableEntityException(
'No bulk customs clearance service fee is configured for this direction and route. Ask the rates team to set a live bulk CUSTOMS_CLEARANCE rate for this origin → destination.', 'No bulk customs clearance service fee is configured for this cargo type on this direction and route. Ask the rates team to set a live bulk CUSTOMS_CLEARANCE rate for this commodity and origin → destination.',
); );
} }
lineItems.push({ lineItems.push({
code: 'CUSTOMS_CLEARANCE', code: 'CUSTOMS_CLEARANCE',
label: 'Customs clearance service (bulk)', label: `Customs clearance service (${scope?.cargoType?.cargoTypeName ?? 'bulk'})`,
unit: toContractUnit(rate.rateUnit), unit: toContractUnit(rate.rateUnit),
unitPrice: convert(Number(rate.rateValue)), unitPrice: convert(Number(rate.rateValue)),
cargoTypeCode: scope?.cargoType?.code ?? null,
isClearance: true, isClearance: true,
}); });
} }

View File

@@ -51,7 +51,7 @@ export class CreateRateDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
enum: CARGO_KINDS, enum: CARGO_KINDS,
description: description:
'Whether a customs clearance / lashing rate covers containers or bulk. Required when trigger = CUSTOMS_CLEARANCE or LASHING. Not stored — container fees carry a containerTypeId, bulk fees none.', 'Whether a customs clearance rate covers containers or bulk. Required when trigger = CUSTOMS_CLEARANCE. Not stored — container fees carry a containerTypeId, bulk fees none.',
}) })
@IsOptional() @IsOptional()
@IsIn([...CARGO_KINDS]) @IsIn([...CARGO_KINDS])

View File

@@ -13,7 +13,7 @@ import type { RateAppliesTo, RateTrigger, RateUnit } from './rate.entity';
export function allowedRateUnits(input: { export function allowedRateUnits(input: {
appliesTo: RateAppliesTo; appliesTo: RateAppliesTo;
trigger: RateTrigger; trigger: RateTrigger;
/** CUSTOMS_CLEARANCE / LASHING only: which cargo kind the fee covers. */ /** CUSTOMS_CLEARANCE only: which cargo kind the fee covers. */
cargoKind?: 'CONTAINER' | 'BULK' | null; cargoKind?: 'CONTAINER' | 'BULK' | null;
}): RateUnit[] { }): RateUnit[] {
const { appliesTo, trigger } = input; const { appliesTo, trigger } = input;
@@ -37,12 +37,14 @@ export function allowedRateUnits(input: {
case 'CANCELLATION': case 'CANCELLATION':
return ['FLAT', 'PER_INVOICE']; return ['FLAT', 'PER_INVOICE'];
case 'CUSTOMS_CLEARANCE': case 'CUSTOMS_CLEARANCE':
case 'LASHING':
// Sold per cargo kind: container fees bill per box or per wagon, bulk // Sold per cargo kind: container fees bill per box or per wagon, bulk
// fees per ton or per wagon. Billed on the booking invoice. // fees per ton or per wagon. Billed on the booking invoice.
return input.cargoKind === 'BULK' return input.cargoKind === 'BULK'
? ['PER_TON', 'PER_WAGON'] ? ['PER_TON', 'PER_WAGON']
: ['PER_CONTAINER', 'PER_WAGON']; : ['PER_CONTAINER', 'PER_WAGON'];
case 'LASHING':
// Bulk-only cargo securing — per ton or per wagon.
return ['PER_TON', 'PER_WAGON'];
case 'CONSOLIDATION': case 'CONSOLIDATION':
return ['PER_CONTAINER', 'FLAT']; return ['PER_CONTAINER', 'FLAT'];
case 'SHIPPING_LINE': case 'SHIPPING_LINE':

View File

@@ -308,70 +308,43 @@ describe('RuleEngineService — empty-container return per route + container typ
}); });
}); });
describe('RuleEngineService — lashing per cargo kind', () => { describe('RuleEngineService — lashing (bulk-only, per direction + commodity)', () => {
const lashing20: Rate = { const lashingBulkImport: Rate = {
id: 'rate-lash-20', id: 'rate-lash-bulk',
rateType: 'LASHING', rateType: 'LASHING',
trigger: 'LASHING', trigger: 'LASHING',
rateValue: 30, rateValue: 2,
rateUnit: 'PER_CONTAINER', rateUnit: 'PER_TON',
currency: 'USD', currency: 'USD',
status: 'LIVE', status: 'LIVE',
containerTypeId: 'ct-20', containerTypeId: null,
cargoTypeId: null, cargoTypeId: null,
tradeDirection: null, tradeDirection: 'IMPORT',
originYardId: null, originYardId: null,
destinationYardId: null, destinationYardId: null,
} as Rate; } as Rate;
const lashingBulk: Rate = {
...lashing20,
id: 'rate-lash-bulk',
rateValue: 2,
rateUnit: 'PER_TON',
containerTypeId: null,
} as Rate;
const buildService = (rates: Rate[]): RuleEngineService => const buildService = (rates: Rate[]): RuleEngineService =>
new RuleEngineService( new RuleEngineService(
{ findById: jest.fn().mockResolvedValue(null) } as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
{ {
findActiveByContainerTypeId: jest findById: jest
.fn() .fn()
.mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons: null }]), .mockResolvedValue({ hasLashing: true, requiresDirectorApproval: false }),
} as never, } as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
{ findActiveByContainerTypeId: jest.fn().mockResolvedValue([]) } as never,
{ findAllActive: jest.fn().mockResolvedValue([]) } as never, { findAllActive: jest.fn().mockResolvedValue([]) } as never,
{ findLiveRates: jest.fn().mockResolvedValue(rates) } as never, { findLiveRates: jest.fn().mockResolvedValue(rates) } as never,
{ findById: jest.fn().mockResolvedValue(null) } as never, { findById: jest.fn().mockResolvedValue(null) } as never,
{} as never, {} as never,
); );
const containerInput = (overrides: Partial<BookingEvaluationInput> = {}): BookingEvaluationInput => ({
serviceTypeId: 'svc-1',
paymentCurrency: 'USD',
tradeDirection: 'IMPORT',
isHazardous: false,
hasLashing: true,
totalWagons: 2,
containers: [
{
containerTypeId: 'ct-20',
quantity: 4,
vgmPerUnitTons: 10,
totalVgmTons: 40,
wagonsPerUnit: 0.5,
},
],
...overrides,
});
const bulkInput = (overrides: Partial<BookingEvaluationInput> = {}): BookingEvaluationInput => ({ const bulkInput = (overrides: Partial<BookingEvaluationInput> = {}): BookingEvaluationInput => ({
serviceTypeId: 'svc-1', serviceTypeId: 'svc-1',
paymentCurrency: 'USD', paymentCurrency: 'USD',
tradeDirection: 'IMPORT', tradeDirection: 'IMPORT',
isHazardous: false, isHazardous: false,
hasLashing: true, cargoTypeId: 'cargo-sugar',
totalWagons: 0, totalWagons: 0,
bulkTons: 100, bulkTons: 100,
bulkWagons: 3, bulkWagons: 3,
@@ -382,53 +355,70 @@ describe('RuleEngineService — lashing per cargo kind', () => {
const lashingMods = (result: Awaited<ReturnType<RuleEngineService['evaluate']>>) => const lashingMods = (result: Awaited<ReturnType<RuleEngineService['evaluate']>>) =>
result.appliedModifiers.filter((m) => m.surchargeCode === 'LASHING'); result.appliedModifiers.filter((m) => m.surchargeCode === 'LASHING');
it('bills each container line at its own container type rate', async () => { it('bulk lashing bills per ton on the direction-matched rate', async () => {
const result = await buildService([lashing20]).evaluate(containerInput()); const result = await buildService([lashingBulkImport]).evaluate(bulkInput());
const mods = lashingMods(result); const mods = lashingMods(result);
expect(mods).toHaveLength(1); expect(mods).toHaveLength(1);
expect(mods[0].triggerValue).toBe(4);
expect(mods[0].calculatedAmount).toBe(120);
expect(mods[0].billingUnit).toBe('PER_CONTAINER');
});
it('PER_WAGON container lashing bills the wagons the line occupies', async () => {
const result = await buildService([
{ ...lashing20, rateUnit: 'PER_WAGON' } as Rate,
]).evaluate(containerInput());
const mods = lashingMods(result);
expect(mods[0].triggerValue).toBe(2);
expect(mods[0].calculatedAmount).toBe(60);
expect(mods[0].billingUnit).toBe('PER_WAGON');
});
it('an unmatched container type simply bills nothing (lenient)', async () => {
const result = await buildService([
{ ...lashing20, containerTypeId: 'ct-40' } as Rate,
]).evaluate(containerInput());
expect(lashingMods(result)).toHaveLength(0);
});
it('bulk lashing bills per ton on the tonnage', async () => {
const result = await buildService([lashingBulk]).evaluate(bulkInput());
const mods = lashingMods(result);
expect(mods[0].triggerValue).toBe(100); expect(mods[0].triggerValue).toBe(100);
expect(mods[0].calculatedAmount).toBe(200); expect(mods[0].calculatedAmount).toBe(200);
expect(mods[0].billingUnit).toBe('PER_TON'); expect(mods[0].billingUnit).toBe('PER_TON');
}); });
it('a rate for the other direction never bills', async () => {
const result = await buildService([
{ ...lashingBulkImport, tradeDirection: 'EXPORT' } as Rate,
]).evaluate(bulkInput());
expect(lashingMods(result)).toHaveLength(0);
});
it('PER_WAGON bulk lashing bills the wagons the bulk occupies', async () => { it('PER_WAGON bulk lashing bills the wagons the bulk occupies', async () => {
const result = await buildService([ const result = await buildService([
{ ...lashingBulk, rateUnit: 'PER_WAGON', rateValue: 25 } as Rate, { ...lashingBulkImport, rateUnit: 'PER_WAGON', rateValue: 25 } as Rate,
]).evaluate(bulkInput()); ]).evaluate(bulkInput());
const mods = lashingMods(result); const mods = lashingMods(result);
expect(mods[0].triggerValue).toBe(3); expect(mods[0].triggerValue).toBe(3);
expect(mods[0].calculatedAmount).toBe(75); expect(mods[0].calculatedAmount).toBe(75);
}); });
it('no lashing charge when the cargo does not need lashing', async () => { it('the commodity-scoped rate wins over the commodity-wide catch-all', async () => {
const result = await buildService([lashing20]).evaluate( const result = await buildService([
containerInput({ hasLashing: false }), lashingBulkImport,
{ ...lashingBulkImport, id: 'rate-lash-sugar', rateValue: 7, cargoTypeId: 'cargo-sugar' } as Rate,
]).evaluate(bulkInput());
const mods = lashingMods(result);
expect(mods).toHaveLength(1);
expect(mods[0].unitPriceUsd).toBe(7);
expect(mods[0].calculatedAmount).toBe(700);
});
it('container bookings never incur lashing (bulk-only service)', async () => {
const result = await buildService([lashingBulkImport]).evaluate(
bulkInput({
cargoTypeId: null,
hasLashing: true,
containers: [
{ containerTypeId: 'ct-20', quantity: 4, vgmPerUnitTons: 10, totalVgmTons: 40 },
],
}),
); );
expect(lashingMods(result)).toHaveLength(0); expect(lashingMods(result)).toHaveLength(0);
}); });
it('no lashing charge when the cargo does not need lashing', async () => {
const service = new RuleEngineService(
{
findById: jest
.fn()
.mockResolvedValue({ hasLashing: false, requiresDirectorApproval: false }),
} 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([lashingBulkImport]) } as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
{} as never,
);
const result = await service.evaluate(bulkInput());
expect(lashingMods(result)).toHaveLength(0);
});
}); });

View File

@@ -581,66 +581,50 @@ export class RuleEngineService {
} }
/** /**
* Cargo securing / lashing — sold per cargo kind, like the customs clearance * Cargo securing / lashing — BULK only, sold per trade direction, optionally
* fee. Container bookings bill each container line at its own container * narrowed to one leaf commodity (the commodity-scoped rate wins over the
* type's lashing rate (PER_CONTAINER × boxes, or PER_WAGON × the wagons the * commodity-wide catch-all). Bills PER_TON × tonnage or PER_WAGON × the
* line occupies); bulk bookings bill the type-less rate (PER_TON × tonnage, * wagons the bulk occupies. Container bookings never incur lashing, and an
* or PER_WAGON × the wagons the bulk occupies). An unconfigured rate simply * unconfigured rate simply bills nothing — same leniency as hazard/reefer.
* bills nothing — same leniency as hazard/reefer.
*/ */
private lashingCharges( private lashingCharges(
input: BookingEvaluationInput, input: BookingEvaluationInput,
liveRates: Rate[], liveRates: Rate[],
): AppliedCargoModifier[] { ): AppliedCargoModifier[] {
const modifiers: AppliedCargoModifier[] = []; const modifiers: AppliedCargoModifier[] = [];
const lashingRates = liveRates.filter( if (input.containers.length > 0) return modifiers; // bulk-only service
(r) => r.trigger === 'LASHING' && r.currency === 'USD',
const onDirection = liveRates.filter(
(r) =>
r.trigger === 'LASHING' &&
r.currency === 'USD' &&
!r.containerTypeId &&
r.tradeDirection === input.tradeDirection,
); );
if (lashingRates.length === 0) return modifiers; const rate =
(input.cargoTypeId
const push = (rate: Rate, billedQty: number): void => { ? onDirection.find((r) => r.cargoTypeId === input.cargoTypeId)
const rateValue = Number(rate.rateValue); : undefined) ?? onDirection.find((r) => !r.cargoTypeId);
const amount =
rate.rateUnit === 'FLAT' ? rateValue : billedQty * rateValue;
if (!(amount > 0)) return;
modifiers.push({
rateId: rate.id,
surchargeCode: this.surchargeCode(rate),
triggerValue: rate.rateUnit === 'FLAT' ? 1 : billedQty,
calculatedAmount: amount,
currency: rate.currency,
unitPriceUsd: rateValue,
billingUnit: rate.rateUnit,
});
};
if (input.containers.length > 0) {
for (const container of input.containers) {
const qty = Number(container.quantity || 0);
if (!(qty > 0)) continue;
const rate = lashingRates.find(
(r) => r.containerTypeId === container.containerTypeId,
);
if (!rate) continue;
const billedQty =
rate.rateUnit === 'PER_WAGON'
? Math.ceil(qty * (container.wagonsPerUnit ?? 1))
: qty;
push(rate, billedQty);
}
return modifiers;
}
// Bulk — the type-less rate covers the whole booking.
const rate = lashingRates.find((r) => !r.containerTypeId);
if (!rate) return modifiers; if (!rate) return modifiers;
const billedQty = const billedQty =
rate.rateUnit === 'PER_TON' rate.rateUnit === 'PER_TON'
? Math.max(0, Number(input.bulkTons ?? 0)) ? Math.max(0, Number(input.bulkTons ?? 0))
: rate.rateUnit === 'PER_WAGON' : rate.rateUnit === 'PER_WAGON'
? Math.max(0, Number(input.bulkWagons ?? 0)) ? Math.max(0, Number(input.bulkWagons ?? 0))
: 1; : 1;
push(rate, billedQty); const rateValue = Number(rate.rateValue);
const amount = rate.rateUnit === 'FLAT' ? rateValue : billedQty * rateValue;
if (!(amount > 0)) return modifiers;
modifiers.push({
rateId: rate.id,
surchargeCode: this.surchargeCode(rate),
triggerValue: rate.rateUnit === 'FLAT' ? 1 : billedQty,
calculatedAmount: amount,
currency: rate.currency,
unitPriceUsd: rateValue,
billingUnit: rate.rateUnit,
});
return modifiers; return modifiers;
} }

View File

@@ -194,14 +194,8 @@ export class RatesService {
}): void { }): void {
const { appliesTo, trigger, tradeDirection, intercityKind, cargoKind } = input; const { appliesTo, trigger, tradeDirection, intercityKind, cargoKind } = input;
const { containerTypeId, cargoTypeId } = input; const { containerTypeId, cargoTypeId } = input;
if (trigger === 'CUSTOMS_CLEARANCE' || trigger === 'LASHING') { if (trigger === 'CUSTOMS_CLEARANCE') {
const label = trigger === 'CUSTOMS_CLEARANCE' ? 'customs clearance' : 'lashing'; if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') {
// Customs is sold per direction; lashing applies both ways.
if (
trigger === 'CUSTOMS_CLEARANCE' &&
tradeDirection !== 'IMPORT' &&
tradeDirection !== 'EXPORT'
) {
throw new BadRequestException( throw new BadRequestException(
'A customs clearance rate must say whether it covers IMPORT or EXPORT.', 'A customs clearance rate must say whether it covers IMPORT or EXPORT.',
); );
@@ -211,17 +205,44 @@ export class RatesService {
// that absence is what marks it as the bulk fee. // that absence is what marks it as the bulk fee.
if (cargoKind !== 'CONTAINER' && cargoKind !== 'BULK') { if (cargoKind !== 'CONTAINER' && cargoKind !== 'BULK') {
throw new BadRequestException( throw new BadRequestException(
`A ${label} rate must say whether it covers containers or bulk.`, 'A customs clearance rate must say whether it covers containers or bulk.',
); );
} }
if (cargoKind === 'CONTAINER' && !containerTypeId) { if (cargoKind === 'CONTAINER' && !containerTypeId) {
throw new BadRequestException( throw new BadRequestException(
`A container ${label} rate must name the container type it covers.`, 'A container customs clearance rate must name the container type it covers.',
); );
} }
if (cargoKind === 'BULK' && containerTypeId) { if (cargoKind === 'BULK' && containerTypeId) {
throw new BadRequestException( throw new BadRequestException(
`A bulk ${label} rate cannot be scoped to a container type.`, 'A bulk customs clearance rate cannot be scoped to a container type.',
);
}
// The bulk customs fee names the commodity it covers (sugar and
// fertilizer clear differently).
if (cargoKind === 'BULK' && !cargoTypeId) {
throw new BadRequestException(
'A bulk customs clearance rate must name the bulk cargo type it covers.',
);
}
if (cargoKind === 'CONTAINER' && cargoTypeId) {
throw new BadRequestException(
'A container customs clearance rate cannot be scoped to a bulk cargo type.',
);
}
return;
}
if (trigger === 'LASHING') {
// Bulk-only cargo securing, sold per direction. May narrow to one leaf
// commodity (specific wins over the commodity-wide catch-all).
if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') {
throw new BadRequestException(
'A lashing rate must say whether it covers IMPORT or EXPORT.',
);
}
if (containerTypeId) {
throw new BadRequestException(
'Lashing is bulk-only — it cannot be scoped to a container type.',
); );
} }
return; return;
@@ -309,22 +330,27 @@ export class RatesService {
// container type — both are sold per lane (and per container type). // container type — both are sold per lane (and per container type).
const isSurcharge = trigger !== 'ALWAYS'; const isSurcharge = trigger !== 'ALWAYS';
const cargoKind = const cargoKind =
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'LASHING' trigger === 'CUSTOMS_CLEARANCE'
? ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? null) ? ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? null)
: null; : null;
const containerTypeId = const containerTypeId =
trigger === 'WITH_RETURN' || trigger === 'WITH_RETURN' ||
((trigger === 'CUSTOMS_CLEARANCE' || trigger === 'LASHING') && (trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'CONTAINER')
cargoKind === 'CONTAINER')
? (dto.containerTypeId ?? null) ? (dto.containerTypeId ?? null)
: isSurcharge : isSurcharge
? null ? null
: (dto.containerTypeId ?? null); : (dto.containerTypeId ?? null);
const cargoTypeId = isSurcharge ? null : (dto.cargoTypeId ?? null); const cargoTypeId =
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') ||
trigger === 'LASHING'
? (dto.cargoTypeId ?? null)
: isSurcharge
? null
: (dto.cargoTypeId ?? null);
// Intercity never leaves Ethiopia, so it has no trade direction to store — // Intercity never leaves Ethiopia, so it has no trade direction to store —
// its yard pair already says where it runs. // its yard pair already says where it runs.
const tradeDirection = const tradeDirection =
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN' trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN' || trigger === 'LASHING'
? (dto.tradeDirection ?? null) ? (dto.tradeDirection ?? null)
: isSurcharge || appliesTo === 'INTERCITY' : isSurcharge || appliesTo === 'INTERCITY'
? null ? null
@@ -456,7 +482,7 @@ export class RatesService {
// A patch that leaves the cargo kind unsaid keeps the one the rate already // A patch that leaves the cargo kind unsaid keeps the one the rate already
// has — read back off its container scope (container fees carry the type). // has — read back off its container scope (container fees carry the type).
const cargoKind = const cargoKind =
trigger !== 'CUSTOMS_CLEARANCE' && trigger !== 'LASHING' trigger !== 'CUSTOMS_CLEARANCE'
? null ? null
: ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? : ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ??
(existing.containerTypeId ? 'CONTAINER' : 'BULK')); (existing.containerTypeId ? 'CONTAINER' : 'BULK'));
@@ -464,20 +490,23 @@ export class RatesService {
const keepsContainerType = const keepsContainerType =
!isSurcharge || !isSurcharge ||
trigger === 'WITH_RETURN' || trigger === 'WITH_RETURN' ||
((trigger === 'CUSTOMS_CLEARANCE' || trigger === 'LASHING') && (trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'CONTAINER');
cargoKind === 'CONTAINER');
const containerTypeId = !keepsContainerType const containerTypeId = !keepsContainerType
? null ? null
: dto.containerTypeId !== undefined : dto.containerTypeId !== undefined
? dto.containerTypeId ? dto.containerTypeId
: existing.containerTypeId; : existing.containerTypeId;
const cargoTypeId = isSurcharge const keepsCargoType =
!isSurcharge ||
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') ||
trigger === 'LASHING';
const cargoTypeId = !keepsCargoType
? null ? null
: dto.cargoTypeId !== undefined : dto.cargoTypeId !== undefined
? dto.cargoTypeId ? dto.cargoTypeId
: existing.cargoTypeId; : existing.cargoTypeId;
const tradeDirection = const tradeDirection =
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN' trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN' || trigger === 'LASHING'
? dto.tradeDirection !== undefined ? dto.tradeDirection !== undefined
? dto.tradeDirection ? dto.tradeDirection
: existing.tradeDirection : existing.tradeDirection

View File

@@ -416,10 +416,11 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
{ appliesTo: "OTHER", trigger: "WITH_RETURN", rateType: "RETURN_SURCHARGE", rateValue: 20, rateUnit: "PER_CONTAINER" }, { appliesTo: "OTHER", trigger: "WITH_RETURN", rateType: "RETURN_SURCHARGE", rateValue: 20, rateUnit: "PER_CONTAINER" },
{ appliesTo: "OTHER", trigger: "SHIPPING_LINE", rateType: "DOUBLE_HANDLING", rateValue: 100, rateUnit: "PER_CONTAINER" }, { appliesTo: "OTHER", trigger: "SHIPPING_LINE", rateType: "DOUBLE_HANDLING", rateValue: 100, rateUnit: "PER_CONTAINER" },
{ appliesTo: "OTHER", trigger: "CONSOLIDATION", rateType: "LASHING", rateValue: 50, rateUnit: "PER_CONTAINER" }, { appliesTo: "OTHER", trigger: "CONSOLIDATION", rateType: "LASHING", rateValue: 50, rateUnit: "PER_CONTAINER" },
// Cargo-securing / lashing — fires when the cargo type has hasLashing. // Cargo-securing / lashing — bulk-only, fires when the cargo type has
// Bulk lashing seeds per ton; container lashing is per container type // hasLashing. Sold per direction; commodity-wide catch-alls seeded here,
// and is configured by the rates team (no catch-all container seed). // commodity-specific rates are configured by the rates team.
{ appliesTo: "OTHER", trigger: "LASHING", rateType: "LASHING", rateValue: 40, rateUnit: "PER_TON" }, { appliesTo: "OTHER", trigger: "LASHING", rateType: "LASHING", tradeDirection: "IMPORT", rateValue: 40, rateUnit: "PER_TON" },
{ appliesTo: "OTHER", trigger: "LASHING", rateType: "LASHING", tradeDirection: "EXPORT", rateValue: 40, rateUnit: "PER_TON" },
// ── First/last-mile road haulage (per km) — drives the mile invoices ── // ── First/last-mile road haulage (per km) — drives the mile invoices ──
{ appliesTo: "OTHER", trigger: "ALWAYS", rateType: "FIRST_MILE", rateValue: 20, rateUnit: "PER_KM" }, { appliesTo: "OTHER", trigger: "ALWAYS", rateType: "FIRST_MILE", rateValue: 20, rateUnit: "PER_KM" },
{ appliesTo: "OTHER", trigger: "ALWAYS", rateType: "LAST_MILE", rateValue: 25, rateUnit: "PER_KM" }, { appliesTo: "OTHER", trigger: "ALWAYS", rateType: "LAST_MILE", rateValue: 25, rateUnit: "PER_KM" },

View File

@@ -128,7 +128,10 @@ export function computeGlShipmentTotal(
const qty = q.bulkQuantity; const qty = q.bulkQuantity;
const rate = const rate =
rateFor( rateFor(
(i) => (i.unit === "per_ton" || i.unit === "per_item") && !i.isClearance, (i) =>
(i.unit === "per_ton" || i.unit === "per_item") &&
!i.isClearance &&
!i.conditionalOn,
) ?? items[0]; ) ?? items[0];
if (rate && qty > 0) { if (rate && qty > 0) {
lines.push({ lines.push({
@@ -165,6 +168,23 @@ export function computeGlShipmentTotal(
} }
} }
// Lashing / cargo securing — bulk-only, applies whenever the contract shows
// it (the commodity needs lashing). Per-ton scales by tonnage; per-wagon
// depends on the wagon capacity the train stocks — shown at real pricing.
const lashing = items.find((i) => i.conditionalOn === "has_lashing");
if (lashing && lashing.unit === "per_ton") {
const tons = q.bulkQuantity;
if (tons > 0) {
lines.push({
label: lashing.label,
unitPrice: lashing.unitPrice,
unit: lashing.unit,
quantity: tons,
amount: lashing.unitPrice * tons,
});
}
}
// Customs clearance service fee — billed on the booking invoice with the // Customs clearance service fee — billed on the booking invoice with the
// freight. Container fees estimate per size (per box, or per wagon: two 20ft // freight. Container fees estimate per size (per box, or per wagon: two 20ft
// share one); bulk per-ton scales by tonnage. Bulk per-wagon fees depend on // share one); bulk per-ton scales by tonnage. Bulk per-wagon fees depend on

View File

@@ -213,6 +213,7 @@ const RuleEngineFormDialog = ({
// and the legal units (container → per box/wagon, bulk → per ton/wagon). // and the legal units (container → per box/wagon, bulk → per ton/wagon).
if (name === "cargoKind") { if (name === "cargoKind") {
next.containerTypeId = ""; next.containerTypeId = "";
next.cargoTypeId = "";
next.rateUnit = ""; next.rateUnit = "";
} }
return next; return next;
@@ -339,6 +340,10 @@ const RuleEngineFormDialog = ({
value={resolveSelectValue(field, values)} value={resolveSelectValue(field, values)}
onChange={(v) => setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)} onChange={(v) => setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)}
disabled={selectOptionsLoading} disabled={selectOptionsLoading}
// Native required blocks submit while a mandatory select is empty —
// without it the form posts and the API 400s (e.g. a container
// customs/lashing rate with no container type picked).
required={field.required}
data={options data={options
.filter((opt) => opt.value !== "") .filter((opt) => opt.value !== "")
.map((opt) => ({ .map((opt) => ({

View File

@@ -172,7 +172,7 @@ const RATE_TRIGGERS = [
}, },
{ label: "Shipping line mapped", value: "SHIPPING_LINE" }, { label: "Shipping line mapped", value: "SHIPPING_LINE" },
{ label: "Penalty", value: "CONSOLIDATION" }, { label: "Penalty", value: "CONSOLIDATION" },
{ label: "Lashing (per container type / bulk)", value: "LASHING" }, { label: "Lashing (bulk, per cargo type)", value: "LASHING" },
{ label: "Cancellation", value: "CANCELLATION" }, { label: "Cancellation", value: "CANCELLATION" },
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" }, { label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
{ label: "Customs clearance service fee (billed with the booking)", value: "CUSTOMS_CLEARANCE" }, { label: "Customs clearance service fee (billed with the booking)", value: "CUSTOMS_CLEARANCE" },
@@ -229,11 +229,13 @@ const allowedRateUnits = (
case "CANCELLATION": case "CANCELLATION":
return ["FLAT", "PER_INVOICE"]; return ["FLAT", "PER_INVOICE"];
case "CUSTOMS_CLEARANCE": case "CUSTOMS_CLEARANCE":
case "LASHING":
// Per cargo kind: container fees per box/wagon, bulk per ton/wagon. // Per cargo kind: container fees per box/wagon, bulk per ton/wagon.
return cargoKind === "BULK" return cargoKind === "BULK"
? ["PER_TON", "PER_WAGON"] ? ["PER_TON", "PER_WAGON"]
: ["PER_CONTAINER", "PER_WAGON"]; : ["PER_CONTAINER", "PER_WAGON"];
case "LASHING":
// Bulk-only cargo securing — per ton or per wagon.
return ["PER_TON", "PER_WAGON"];
case "CONSOLIDATION": case "CONSOLIDATION":
case "SHIPPING_LINE": case "SHIPPING_LINE":
case "PIL_EXTRA_FEE": case "PIL_EXTRA_FEE":
@@ -722,10 +724,12 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
showIf: (v) => showIf: (v) =>
["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) || ["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) ||
(String(v.appliesTo ?? "") === "OTHER" && (String(v.appliesTo ?? "") === "OTHER" &&
["CUSTOMS_CLEARANCE", "WITH_RETURN"].includes(String(v.trigger ?? ""))), ["CUSTOMS_CLEARANCE", "WITH_RETURN", "LASHING"].includes(
String(v.trigger ?? ""),
)),
}, },
// ── Cargo kind — customs clearance and lashing are priced separately // ── Cargo kind — customs clearance is priced separately for containers
// for containers (one rate per container type) and bulk ──────────────── // (one rate per container type) and bulk ───────────────────────────────
{ {
name: "cargoKind", name: "cargoKind",
label: "Cargo kind", label: "Cargo kind",
@@ -736,8 +740,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
description: description:
"Container fees bill per box or wagon (one rate per container type); bulk fees bill per ton or wagon.", "Container fees bill per box or wagon (one rate per container type); bulk fees bill per ton or wagon.",
showIf: (v) => showIf: (v) =>
v.appliesTo === "OTHER" && v.appliesTo === "OTHER" && v.trigger === "CUSTOMS_CLEARANCE",
["CUSTOMS_CLEARANCE", "LASHING"].includes(String(v.trigger ?? "")),
// Not a stored column: a container fee carries its containerTypeId, a // Not a stored column: a container fee carries its containerTypeId, a
// bulk fee carries none. // bulk fee carries none.
getInitialValue: (record) => getInitialValue: (record) =>
@@ -752,9 +755,34 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
placeholder: "Which container type this fee covers", placeholder: "Which container type this fee covers",
showIf: (v) => showIf: (v) =>
v.appliesTo === "OTHER" && v.appliesTo === "OTHER" &&
["CUSTOMS_CLEARANCE", "LASHING"].includes(String(v.trigger ?? "")) && v.trigger === "CUSTOMS_CLEARANCE" &&
v.cargoKind === "CONTAINER", v.cargoKind === "CONTAINER",
}, },
// ── Bulk cargo type — the bulk customs fee names its commodity ────────
{
name: "cargoTypeId",
label: "Bulk cargo type",
type: "select",
required: true,
placeholder: "Which bulk commodity this fee covers",
showIf: (v) =>
v.appliesTo === "OTHER" &&
v.trigger === "CUSTOMS_CLEARANCE" &&
v.cargoKind === "BULK",
},
// ── Bulk cargo type — lashing is bulk-only; may narrow to one leaf
// commodity (specific wins over the commodity-wide catch-all) ──────────
{
name: "cargoTypeId",
label: "Bulk cargo type",
type: "select",
optional: true,
placeholder: "All lashing commodities (optional)",
description:
"Leave empty to cover every lashing commodity; a commodity-specific rate wins over the catch-all.",
showIf: (v) =>
v.appliesTo === "OTHER" && v.trigger === "LASHING",
},
// ── Cargo kind — Intercity only (import/export get it from appliesTo) ─ // ── Cargo kind — Intercity only (import/export get it from appliesTo) ─
{ {
name: "intercityKind", name: "intercityKind",

View File

@@ -19,6 +19,7 @@ import {
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isAxiosError } from "axios";
import { import {
AlertCircle, AlertCircle,
Check, Check,
@@ -218,6 +219,12 @@ export default function NewContractPage({
}, },
}); });
// 422 from generate-price = a required rate isn't configured for the chosen
// route — the customer can't fix it, so it's surfaced as a blocking modal.
const rateNotConfigured =
isAxiosError(persistAndPriceMutation.error) &&
persistAndPriceMutation.error.response?.status === 422;
const confirmMutation = useMutation({ const confirmMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
if (!priceContractId) throw new Error("No contract to confirm"); if (!priceContractId) throw new Error("No contract to confirm");
@@ -759,7 +766,7 @@ export default function NewContractPage({
<StepIndicator step={step} steps={visibleSteps} /> <StepIndicator step={step} steps={visibleSteps} />
</Box> </Box>
{persistAndPriceMutation.isError && ( {persistAndPriceMutation.isError && !rateNotConfigured && (
<Alert <Alert
color="red" color="red"
icon={<AlertCircle size={16} />} icon={<AlertCircle size={16} />}
@@ -777,6 +784,28 @@ export default function NewContractPage({
</Alert> </Alert>
)} )}
{/* Pricing not configured (422) — a rate is missing on the chosen
route, nothing the customer can fix. Block with a modal. */}
<Modal
opened={rateNotConfigured}
onClose={() => persistAndPriceMutation.reset()}
title="Contract creation unavailable"
centered
>
<Stack gap="sm">
<Text size="sm">
You can&apos;t create a contract right now pricing hasn&apos;t
been configured for the selected route yet. Please contact
support for assistance.
</Text>
<Group justify="flex-end">
<Button onClick={() => persistAndPriceMutation.reset()}>
OK
</Button>
</Group>
</Stack>
</Modal>
{/* Step 0 — Setup: operation, contract, service, currency, miles. */} {/* Step 0 — Setup: operation, contract, service, currency, miles. */}
{step === 0 && ( {step === 0 && (
<StepCard> <StepCard>

View File

@@ -111,7 +111,10 @@ export function computeShipmentTotal(
const qty = Number(values.cargoWeightTons || values.itemCount || 0); const qty = Number(values.cargoWeightTons || values.itemCount || 0);
const rate = const rate =
rateFor( rateFor(
(i) => (i.unit === "per_ton" || i.unit === "per_item") && !i.isClearance, (i) =>
(i.unit === "per_ton" || i.unit === "per_item") &&
!i.isClearance &&
!i.conditionalOn,
) ?? items[0]; ) ?? items[0];
if (rate && qty > 0) { if (rate && qty > 0) {
lines.push({ lines.push({
@@ -150,6 +153,23 @@ export function computeShipmentTotal(
} }
} }
// Lashing / cargo securing — bulk-only, applies whenever the contract shows
// it (the commodity needs lashing). Per-ton scales by tonnage; per-wagon
// depends on the wagon capacity the train stocks — shown at real pricing.
const lashing = items.find((i) => i.conditionalOn === "has_lashing");
if (lashing && lashing.unit === "per_ton") {
const tons = Number(values.cargoWeightTons || 0);
if (tons > 0) {
lines.push({
label: lashing.label,
unitPrice: lashing.unitPrice,
unit: lashing.unit,
quantity: tons,
amount: lashing.unitPrice * tons,
});
}
}
// Customs clearance service fee — billed on the booking invoice with the // Customs clearance service fee — billed on the booking invoice with the
// freight. Container fees estimate per size (per box, or per wagon: two 20ft // freight. Container fees estimate per size (per box, or per wagon: two 20ft
// share one); bulk per-ton scales by tonnage. Bulk per-wagon fees depend on // share one); bulk per-ton scales by tonnage. Bulk per-wagon fees depend on