diff --git a/apps/edr-freight-api/src/migrations/2890000000000-LashingBulkOnlyPerDirection.ts b/apps/edr-freight-api/src/migrations/2890000000000-LashingBulkOnlyPerDirection.ts new file mode 100644 index 000000000..8904c6194 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2890000000000-LashingBulkOnlyPerDirection.ts @@ -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 { + 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 { + // Retired rates stay retired — re-enter per-direction bulk rates instead. + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts index 14ea7334d..667abe842 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts @@ -379,6 +379,25 @@ describe('BookingPricingService — customs clearance fee billed on the booking 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 () => { const service = makeService({ liveRates: [{ ...bulkFeePerTon, rateUnit: 'PER_WAGON', rateValue: 50 } as Rate], diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index 401561ccb..89825e100 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -1028,8 +1028,15 @@ export class BookingPricingService { // 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. + // 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 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) { blocked.push(missingRateMessage('bulk cargo')); return { lineItems, usedRates, blocked }; diff --git a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts index 8a10862b2..149643441 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts @@ -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 // contract like hazard/reefer. Billed at booking per WITH_RETURN container. if ( @@ -295,18 +325,26 @@ export class ContractPricingService { }); } } else { - // Bulk fee = the route's type-less customs rate (per ton / per wagon). - const rate = onLeg.find((r) => !r.containerTypeId); + // Bulk fee — the rate scoped to the contract's commodity wins; a + // 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) { 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({ code: 'CUSTOMS_CLEARANCE', - label: 'Customs clearance service (bulk)', + label: `Customs clearance service (${scope?.cargoType?.cargoTypeName ?? 'bulk'})`, unit: toContractUnit(rate.rateUnit), unitPrice: convert(Number(rate.rateValue)), + cargoTypeCode: scope?.cargoType?.code ?? null, isClearance: true, }); } diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts index 65332ac42..4f9d06870 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts @@ -51,7 +51,7 @@ export class CreateRateDto { @ApiPropertyOptional({ enum: CARGO_KINDS, 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() @IsIn([...CARGO_KINDS]) diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts index ee7bc44b1..3519a0c06 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts @@ -13,7 +13,7 @@ import type { RateAppliesTo, RateTrigger, RateUnit } from './rate.entity'; export function allowedRateUnits(input: { appliesTo: RateAppliesTo; 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; }): RateUnit[] { const { appliesTo, trigger } = input; @@ -37,12 +37,14 @@ export function allowedRateUnits(input: { case 'CANCELLATION': return ['FLAT', 'PER_INVOICE']; case 'CUSTOMS_CLEARANCE': - case 'LASHING': // Sold per cargo kind: container fees bill per box or per wagon, bulk // fees per ton or per wagon. Billed on the booking invoice. return input.cargoKind === 'BULK' ? ['PER_TON', 'PER_WAGON'] : ['PER_CONTAINER', 'PER_WAGON']; + case 'LASHING': + // Bulk-only cargo securing — per ton or per wagon. + return ['PER_TON', 'PER_WAGON']; case 'CONSOLIDATION': return ['PER_CONTAINER', 'FLAT']; case 'SHIPPING_LINE': diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts index 472efbf55..a736d4b05 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts @@ -308,70 +308,43 @@ describe('RuleEngineService — empty-container return per route + container typ }); }); -describe('RuleEngineService — lashing per cargo kind', () => { - const lashing20: Rate = { - id: 'rate-lash-20', +describe('RuleEngineService — lashing (bulk-only, per direction + commodity)', () => { + const lashingBulkImport: Rate = { + id: 'rate-lash-bulk', rateType: 'LASHING', trigger: 'LASHING', - rateValue: 30, - rateUnit: 'PER_CONTAINER', + rateValue: 2, + rateUnit: 'PER_TON', currency: 'USD', status: 'LIVE', - containerTypeId: 'ct-20', + containerTypeId: null, cargoTypeId: null, - tradeDirection: null, + tradeDirection: 'IMPORT', originYardId: null, destinationYardId: null, } as Rate; - const lashingBulk: Rate = { - ...lashing20, - id: 'rate-lash-bulk', - rateValue: 2, - rateUnit: 'PER_TON', - containerTypeId: null, - } as Rate; - const buildService = (rates: Rate[]): RuleEngineService => new RuleEngineService( - { findById: jest.fn().mockResolvedValue(null) } as never, - { findById: jest.fn().mockResolvedValue(null) } as never, { - findActiveByContainerTypeId: jest + findById: jest .fn() - .mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons: null }]), + .mockResolvedValue({ hasLashing: true, 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(rates) } as never, { findById: jest.fn().mockResolvedValue(null) } as never, {} as never, ); - const containerInput = (overrides: Partial = {}): 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 => ({ serviceTypeId: 'svc-1', paymentCurrency: 'USD', tradeDirection: 'IMPORT', isHazardous: false, - hasLashing: true, + cargoTypeId: 'cargo-sugar', totalWagons: 0, bulkTons: 100, bulkWagons: 3, @@ -382,53 +355,70 @@ describe('RuleEngineService — lashing per cargo kind', () => { const lashingMods = (result: Awaited>) => result.appliedModifiers.filter((m) => m.surchargeCode === 'LASHING'); - it('bills each container line at its own container type rate', async () => { - const result = await buildService([lashing20]).evaluate(containerInput()); + it('bulk lashing bills per ton on the direction-matched rate', async () => { + const result = await buildService([lashingBulkImport]).evaluate(bulkInput()); const mods = lashingMods(result); 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].calculatedAmount).toBe(200); 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 () => { const result = await buildService([ - { ...lashingBulk, rateUnit: 'PER_WAGON', rateValue: 25 } as Rate, + { ...lashingBulkImport, rateUnit: 'PER_WAGON', rateValue: 25 } as Rate, ]).evaluate(bulkInput()); const mods = lashingMods(result); expect(mods[0].triggerValue).toBe(3); expect(mods[0].calculatedAmount).toBe(75); }); - it('no lashing charge when the cargo does not need lashing', async () => { - const result = await buildService([lashing20]).evaluate( - containerInput({ hasLashing: false }), + it('the commodity-scoped rate wins over the commodity-wide catch-all', async () => { + const result = await buildService([ + 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); }); + + 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); + }); }); diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index a1621c4f6..3ad97bb53 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -581,66 +581,50 @@ export class RuleEngineService { } /** - * Cargo securing / lashing — sold per cargo kind, like the customs clearance - * fee. Container bookings bill each container line at its own container - * type's lashing rate (PER_CONTAINER × boxes, or PER_WAGON × the wagons the - * line occupies); bulk bookings bill the type-less rate (PER_TON × tonnage, - * or PER_WAGON × the wagons the bulk occupies). An unconfigured rate simply - * bills nothing — same leniency as hazard/reefer. + * Cargo securing / lashing — BULK only, sold per trade direction, optionally + * narrowed to one leaf commodity (the commodity-scoped rate wins over the + * commodity-wide catch-all). Bills PER_TON × tonnage or PER_WAGON × the + * wagons the bulk occupies. Container bookings never incur lashing, and an + * unconfigured rate simply bills nothing — same leniency as hazard/reefer. */ private lashingCharges( input: BookingEvaluationInput, liveRates: Rate[], ): AppliedCargoModifier[] { const modifiers: AppliedCargoModifier[] = []; - const lashingRates = liveRates.filter( - (r) => r.trigger === 'LASHING' && r.currency === 'USD', + if (input.containers.length > 0) return modifiers; // bulk-only service + + const onDirection = liveRates.filter( + (r) => + r.trigger === 'LASHING' && + r.currency === 'USD' && + !r.containerTypeId && + r.tradeDirection === input.tradeDirection, ); - if (lashingRates.length === 0) return modifiers; - - const push = (rate: Rate, billedQty: number): void => { - const rateValue = Number(rate.rateValue); - 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); + const rate = + (input.cargoTypeId + ? onDirection.find((r) => r.cargoTypeId === input.cargoTypeId) + : undefined) ?? onDirection.find((r) => !r.cargoTypeId); if (!rate) return modifiers; + const billedQty = rate.rateUnit === 'PER_TON' ? Math.max(0, Number(input.bulkTons ?? 0)) : rate.rateUnit === 'PER_WAGON' ? Math.max(0, Number(input.bulkWagons ?? 0)) : 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; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index f1417020c..700d6366c 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -194,14 +194,8 @@ export class RatesService { }): void { const { appliesTo, trigger, tradeDirection, intercityKind, cargoKind } = input; const { containerTypeId, cargoTypeId } = input; - if (trigger === 'CUSTOMS_CLEARANCE' || trigger === 'LASHING') { - const label = trigger === 'CUSTOMS_CLEARANCE' ? 'customs clearance' : 'lashing'; - // Customs is sold per direction; lashing applies both ways. - if ( - trigger === 'CUSTOMS_CLEARANCE' && - tradeDirection !== 'IMPORT' && - tradeDirection !== 'EXPORT' - ) { + if (trigger === 'CUSTOMS_CLEARANCE') { + if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') { throw new BadRequestException( '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. if (cargoKind !== 'CONTAINER' && cargoKind !== 'BULK') { 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) { 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) { 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; @@ -309,22 +330,27 @@ export class RatesService { // container type — both are sold per lane (and per container type). const isSurcharge = trigger !== 'ALWAYS'; const cargoKind = - trigger === 'CUSTOMS_CLEARANCE' || trigger === 'LASHING' + trigger === 'CUSTOMS_CLEARANCE' ? ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? null) : null; const containerTypeId = trigger === 'WITH_RETURN' || - ((trigger === 'CUSTOMS_CLEARANCE' || trigger === 'LASHING') && - cargoKind === 'CONTAINER') + (trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'CONTAINER') ? (dto.containerTypeId ?? null) : isSurcharge ? 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 — // its yard pair already says where it runs. const tradeDirection = - trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN' + trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN' || trigger === 'LASHING' ? (dto.tradeDirection ?? null) : isSurcharge || appliesTo === 'INTERCITY' ? null @@ -456,7 +482,7 @@ export class RatesService { // 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). const cargoKind = - trigger !== 'CUSTOMS_CLEARANCE' && trigger !== 'LASHING' + trigger !== 'CUSTOMS_CLEARANCE' ? null : ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? (existing.containerTypeId ? 'CONTAINER' : 'BULK')); @@ -464,20 +490,23 @@ export class RatesService { const keepsContainerType = !isSurcharge || trigger === 'WITH_RETURN' || - ((trigger === 'CUSTOMS_CLEARANCE' || trigger === 'LASHING') && - cargoKind === 'CONTAINER'); + (trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'CONTAINER'); const containerTypeId = !keepsContainerType ? null : dto.containerTypeId !== undefined ? dto.containerTypeId : existing.containerTypeId; - const cargoTypeId = isSurcharge + const keepsCargoType = + !isSurcharge || + (trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') || + trigger === 'LASHING'; + const cargoTypeId = !keepsCargoType ? null : dto.cargoTypeId !== undefined ? dto.cargoTypeId : existing.cargoTypeId; const tradeDirection = - trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN' + trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN' || trigger === 'LASHING' ? dto.tradeDirection !== undefined ? dto.tradeDirection : existing.tradeDirection diff --git a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts index d1ff3d31c..7bbf2b0cb 100644 --- a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts @@ -416,10 +416,11 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { { 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: "CONSOLIDATION", rateType: "LASHING", rateValue: 50, rateUnit: "PER_CONTAINER" }, - // Cargo-securing / lashing — fires when the cargo type has hasLashing. - // Bulk lashing seeds per ton; container lashing is per container type - // and is configured by the rates team (no catch-all container seed). - { appliesTo: "OTHER", trigger: "LASHING", rateType: "LASHING", rateValue: 40, rateUnit: "PER_TON" }, + // Cargo-securing / lashing — bulk-only, fires when the cargo type has + // hasLashing. Sold per direction; commodity-wide catch-alls seeded here, + // commodity-specific rates are configured by the rates team. + { 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 ── { appliesTo: "OTHER", trigger: "ALWAYS", rateType: "FIRST_MILE", rateValue: 20, rateUnit: "PER_KM" }, { appliesTo: "OTHER", trigger: "ALWAYS", rateType: "LAST_MILE", rateValue: 25, rateUnit: "PER_KM" }, diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/total.ts b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/total.ts index 3d1226dd6..2b84d9483 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/total.ts +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/total.ts @@ -128,7 +128,10 @@ export function computeGlShipmentTotal( const qty = q.bulkQuantity; const rate = 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]; if (rate && qty > 0) { 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 // 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 diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx index 36c925d98..cd56ad79d 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx @@ -213,6 +213,7 @@ const RuleEngineFormDialog = ({ // and the legal units (container → per box/wagon, bulk → per ton/wagon). if (name === "cargoKind") { next.containerTypeId = ""; + next.cargoTypeId = ""; next.rateUnit = ""; } return next; @@ -339,6 +340,10 @@ const RuleEngineFormDialog = ({ value={resolveSelectValue(field, values)} onChange={(v) => setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)} 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 .filter((opt) => opt.value !== "") .map((opt) => ({ diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index 7521f7ac9..44c28a9e9 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -172,7 +172,7 @@ const RATE_TRIGGERS = [ }, { label: "Shipping line mapped", value: "SHIPPING_LINE" }, { 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: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" }, { label: "Customs clearance service fee (billed with the booking)", value: "CUSTOMS_CLEARANCE" }, @@ -229,11 +229,13 @@ const allowedRateUnits = ( case "CANCELLATION": return ["FLAT", "PER_INVOICE"]; case "CUSTOMS_CLEARANCE": - case "LASHING": // Per cargo kind: container fees per box/wagon, bulk per ton/wagon. return cargoKind === "BULK" ? ["PER_TON", "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 "SHIPPING_LINE": case "PIL_EXTRA_FEE": @@ -722,10 +724,12 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ showIf: (v) => ["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) || (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 - // for containers (one rate per container type) and bulk ──────────────── + // ── Cargo kind — customs clearance is priced separately for containers + // (one rate per container type) and bulk ─────────────────────────────── { name: "cargoKind", label: "Cargo kind", @@ -736,8 +740,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ description: "Container fees bill per box or wagon (one rate per container type); bulk fees bill per ton or wagon.", showIf: (v) => - v.appliesTo === "OTHER" && - ["CUSTOMS_CLEARANCE", "LASHING"].includes(String(v.trigger ?? "")), + v.appliesTo === "OTHER" && v.trigger === "CUSTOMS_CLEARANCE", // Not a stored column: a container fee carries its containerTypeId, a // bulk fee carries none. getInitialValue: (record) => @@ -752,9 +755,34 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ placeholder: "Which container type this fee covers", showIf: (v) => v.appliesTo === "OTHER" && - ["CUSTOMS_CLEARANCE", "LASHING"].includes(String(v.trigger ?? "")) && + v.trigger === "CUSTOMS_CLEARANCE" && 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) ─ { name: "intercityKind", diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx index 21ad39d14..81e3a1806 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx @@ -19,6 +19,7 @@ import { Title, } from "@mantine/core"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { isAxiosError } from "axios"; import { AlertCircle, 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({ mutationFn: async () => { if (!priceContractId) throw new Error("No contract to confirm"); @@ -759,7 +766,7 @@ export default function NewContractPage({ - {persistAndPriceMutation.isError && ( + {persistAndPriceMutation.isError && !rateNotConfigured && ( } @@ -777,6 +784,28 @@ export default function NewContractPage({ )} + {/* Pricing not configured (422) — a rate is missing on the chosen + route, nothing the customer can fix. Block with a modal. */} + persistAndPriceMutation.reset()} + title="Contract creation unavailable" + centered + > + + + You can't create a contract right now — pricing hasn't + been configured for the selected route yet. Please contact + support for assistance. + + + + + + + {/* Step 0 — Setup: operation, contract, service, currency, miles. */} {step === 0 && ( diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.ts index 841d7ddf1..b4055bb1c 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.ts @@ -111,7 +111,10 @@ export function computeShipmentTotal( const qty = Number(values.cargoWeightTons || values.itemCount || 0); const rate = 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]; if (rate && qty > 0) { 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 // 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