From 9d81a2e1eedbfc9bc216b9a0f16183b6e85000b6 Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 23 Jun 2026 23:15:32 +0000 Subject: [PATCH] integrate global logistics staff user into seeder refactor pricing data seeder to fold surcharge types into rates update route meta subtitle to remove surcharge types enhance RuleEngineFormDialog to support conditional field visibility remove surcharge types from URL constants and related services add cargo leaf options query for bulk cargo type selection update RuleEngineResourcePage to utilize cargo leaf options modify resources configuration to remove surcharge types implement migration to fold surcharge types into rates create utility to derive legacy rate types from new rate structure --- ...20000000004-FoldSurchargeTypesIntoRates.ts | 187 +++++++++++++++ .../bookings/booking-pricing.service.ts | 23 +- .../modules/bookings/bookings.repository.ts | 2 +- .../entities/booking-cargo-modifier.entity.ts | 19 +- .../controllers/surcharge-types.controller.ts | 56 ----- .../rule-engine/dto/create-rate.dto.ts | 31 ++- .../dto/create-surcharge-type.dto.ts | 30 --- .../dto/update-surcharge-type.dto.ts | 4 - .../rule-engine/entities/rate-type.util.ts | 61 +++++ .../rule-engine/entities/rate.entity.ts | 64 ++++- .../entities/surcharge-type.entity.ts | 38 --- .../surcharge-types.repository.interface.ts | 15 -- .../surcharge-types.repository.ts | 50 ---- .../modules/rule-engine/rule-engine.module.ts | 11 - .../rule-engine/rule-engine.service.ts | 73 +++--- .../rule-engine/services/rates.service.ts | 62 ++++- .../services/surcharge-types.service.ts | 78 ------ .../src/seed/demo-freight-data.seeder.ts | 3 +- .../src/seed/edr-freight.seed.ts | 5 + .../src/seed/freight-permissions.registry.ts | 2 - .../src/seed/freight-staff-users.seeder.ts | 5 +- .../src/seed/pricing-data.seeder.ts | 223 +++--------------- .../src/components/layout/route-meta.ts | 2 +- .../ruleEngine/RuleEngineFormDialog.tsx | 21 +- .../backoffice/src/constants/URLS.ts | 3 - .../src/hooks/rule-engine/useRuleEngine.ts | 34 +++ .../ruleEngine/RuleEngineResourcePage.tsx | 26 +- .../src/pages/ruleEngine/config/resources.ts | 142 +++++------ .../services/ruleEngine/ruleEngine.service.ts | 3 - .../backoffice/src/types/rule-engine/index.ts | 1 - 30 files changed, 642 insertions(+), 632 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1820000000004-FoldSurchargeTypesIntoRates.ts delete mode 100644 apps/edr-freight-api/src/modules/rule-engine/controllers/surcharge-types.controller.ts delete mode 100644 apps/edr-freight-api/src/modules/rule-engine/dto/create-surcharge-type.dto.ts delete mode 100644 apps/edr-freight-api/src/modules/rule-engine/dto/update-surcharge-type.dto.ts create mode 100644 apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts delete mode 100644 apps/edr-freight-api/src/modules/rule-engine/entities/surcharge-type.entity.ts delete mode 100644 apps/edr-freight-api/src/modules/rule-engine/interfaces/surcharge-types.repository.interface.ts delete mode 100644 apps/edr-freight-api/src/modules/rule-engine/repositories/surcharge-types.repository.ts delete mode 100644 apps/edr-freight-api/src/modules/rule-engine/services/surcharge-types.service.ts diff --git a/apps/edr-freight-api/src/migrations/1820000000004-FoldSurchargeTypesIntoRates.ts b/apps/edr-freight-api/src/migrations/1820000000004-FoldSurchargeTypesIntoRates.ts new file mode 100644 index 000000000..d4eadf34e --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1820000000004-FoldSurchargeTypesIntoRates.ts @@ -0,0 +1,187 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Fold the `surcharge_types` table into self-describing rates. + * + * Previously a surcharge was a separate row {trigger_condition, rate_id}. Now + * each rate carries its own `applies_to` (friendly category) and `trigger` + * (ALWAYS = base freight, otherwise a surcharge condition), plus an optional + * `cargo_type_id` for bulk leaf commodities. The rule engine reads triggers + * directly off LIVE rates, so the join table is no longer needed. + * + * This migration: + * 1. adds applies_to / trigger / cargo_type_id to rates and backfills them + * from the existing rate_type matrix, + * 2. repoints booking_cargo_modifier from surcharge_type_id → rate_id + * (backfilled via surcharge_types.rate_id), + * 3. drops surcharge_types and its FK. + */ +export class FoldSurchargeTypesIntoRates1820000000004 implements MigrationInterface { + name = 'FoldSurchargeTypesIntoRates1820000000004'; + + public async up(queryRunner: QueryRunner): Promise { + // ── 1. New rate columns ──────────────────────────────────────────────── + await queryRunner.query(` + ALTER TABLE freight.rates + ADD COLUMN IF NOT EXISTS applies_to varchar(20) NOT NULL DEFAULT 'OTHER', + ADD COLUMN IF NOT EXISTS "trigger" varchar(20) NOT NULL DEFAULT 'ALWAYS', + ADD COLUMN IF NOT EXISTS cargo_type_id uuid NULL; + `); + + await queryRunner.query(` + ALTER TABLE freight.rates + ADD CONSTRAINT "FK_rates_cargo_type_id" + FOREIGN KEY (cargo_type_id) REFERENCES freight.cargo_types(id) + ON DELETE SET NULL; + `); + + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_rates_trigger" ON freight.rates ("trigger");`, + ); + + // ── 1a. Backfill applies_to from the legacy rate_type matrix ──────────── + await queryRunner.query(` + UPDATE freight.rates SET applies_to = CASE + WHEN rate_type IN ('CONTAINER_IMPORT','CONTAINER_EXPORT','CONTAINER_WITH_RETURN') THEN 'CONTAINER' + WHEN rate_type IN ('BULK_IMPORT','BULK_EXPORT') THEN 'BULK' + WHEN rate_type IN ('INTERCITY_CONTAINER','INTERCITY_BULK') THEN 'INTERCITY' + WHEN rate_type = 'FIRST_MILE' THEN 'FIRST_MILE' + WHEN rate_type = 'LAST_MILE' THEN 'LAST_MILE' + ELSE 'OTHER' + END; + `); + + // ── 1b. Backfill trigger from the legacy rate_type matrix ─────────────── + await queryRunner.query(` + UPDATE freight.rates SET "trigger" = CASE + WHEN rate_type = 'HAZARD_SURCHARGE' THEN 'HAZARDOUS' + WHEN rate_type = 'REEFER_SURCHARGE' THEN 'REEFER' + WHEN rate_type = 'OVERWEIGHT_PER_TON' THEN 'OVERWEIGHT' + WHEN rate_type = 'DOUBLE_HANDLING' THEN 'SHIPPING_LINE' + WHEN rate_type = 'LASHING' THEN 'CONSOLIDATION' + WHEN rate_type = 'CANCELLATION_FEE' THEN 'CANCELLATION' + WHEN rate_type = 'DEMURRAGE' THEN 'DEMURRAGE' + WHEN rate_type = 'PIL_EXTRA_FEE' THEN 'PIL_EXTRA_FEE' + ELSE 'ALWAYS' + END; + `); + + // Align the trigger to the actual surcharge_types mapping where one exists + // (covers any rate wired as a surcharge with a non-obvious rate_type). + await queryRunner.query(` + UPDATE freight.rates r SET "trigger" = m.trig + FROM ( + SELECT st.rate_id, CASE st.trigger_condition + WHEN 'CARGO_FLAG_HAZARDOUS' THEN 'HAZARDOUS' + WHEN 'CARGO_FLAG_REEFER' THEN 'REEFER' + WHEN 'VGM_EXCEEDS_LIMIT' THEN 'OVERWEIGHT' + WHEN 'SHIPPING_LINE_MAPPED' THEN 'SHIPPING_LINE' + WHEN 'CONSOLIDATION_ENABLED' THEN 'CONSOLIDATION' + ELSE 'ALWAYS' + END AS trig + FROM freight.surcharge_types st + WHERE st.rate_id IS NOT NULL AND st.deleted_at IS NULL + ) m + WHERE r.id = m.rate_id AND m.trig <> 'ALWAYS'; + `); + + // ── 2. Repoint booking_cargo_modifier to rate_id ──────────────────────── + await queryRunner.query(` + ALTER TABLE freight.booking_cargo_modifier + ADD COLUMN IF NOT EXISTS rate_id uuid NULL; + `); + + await queryRunner.query(` + UPDATE freight.booking_cargo_modifier bcm + SET rate_id = st.rate_id + FROM freight.surcharge_types st + WHERE bcm.surcharge_type_id = st.id AND st.rate_id IS NOT NULL; + `); + + // Rows whose surcharge lost its rate can't be repointed — they reference a + // now-defunct surcharge. Remove them so the NOT NULL + FK can be enforced. + await queryRunner.query(` + DELETE FROM freight.booking_cargo_modifier WHERE rate_id IS NULL; + `); + + await queryRunner.query(` + ALTER TABLE freight.booking_cargo_modifier + ALTER COLUMN rate_id SET NOT NULL; + `); + + // Drop the old FK + column + index for surcharge_type_id. + await queryRunner.query(` + ALTER TABLE freight.booking_cargo_modifier + DROP CONSTRAINT IF EXISTS "FK_booking_cargo_modifier_surcharge_type_id"; + `); + await queryRunner.query( + `DROP INDEX IF EXISTS freight."IDX_booking_cargo_modifier_surcharge_type_id";`, + ); + await queryRunner.query(` + ALTER TABLE freight.booking_cargo_modifier + DROP COLUMN IF EXISTS surcharge_type_id; + `); + + await queryRunner.query(` + ALTER TABLE freight.booking_cargo_modifier + ADD CONSTRAINT "FK_booking_cargo_modifier_rate_id" + FOREIGN KEY (rate_id) REFERENCES freight.rates(id); + `); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_booking_cargo_modifier_rate_id" ON freight.booking_cargo_modifier (rate_id);`, + ); + + // ── 3. Drop the surcharge_types table ─────────────────────────────────── + await queryRunner.query(`DROP TABLE IF EXISTS freight.surcharge_types;`); + } + + public async down(queryRunner: QueryRunner): Promise { + // Recreate surcharge_types (structure only — data is not restored). + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.surcharge_types ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + code varchar(40) NOT NULL, + label varchar(100), + trigger_condition varchar(50), + rate_id uuid, + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ); + `); + await queryRunner.query( + `CREATE UNIQUE INDEX IF NOT EXISTS "IDX_surcharge_types_code" ON freight.surcharge_types (code);`, + ); + + // Restore booking_cargo_modifier.surcharge_type_id (nullable; not backfilled). + await queryRunner.query(` + ALTER TABLE freight.booking_cargo_modifier + DROP CONSTRAINT IF EXISTS "FK_booking_cargo_modifier_rate_id"; + `); + await queryRunner.query( + `DROP INDEX IF EXISTS freight."IDX_booking_cargo_modifier_rate_id";`, + ); + await queryRunner.query(` + ALTER TABLE freight.booking_cargo_modifier + ADD COLUMN IF NOT EXISTS surcharge_type_id uuid NULL; + `); + await queryRunner.query(` + ALTER TABLE freight.booking_cargo_modifier DROP COLUMN IF EXISTS rate_id; + `); + + // Drop the new rate columns. + await queryRunner.query( + `DROP INDEX IF EXISTS freight."IDX_rates_trigger";`, + ); + await queryRunner.query(` + ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "FK_rates_cargo_type_id"; + `); + await queryRunner.query(` + ALTER TABLE freight.rates + DROP COLUMN IF EXISTS cargo_type_id, + DROP COLUMN IF EXISTS "trigger", + DROP COLUMN IF EXISTS applies_to; + `); + } +} 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 8502aa7ad..5c9eea580 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 @@ -133,18 +133,21 @@ export class BookingPricingService { const unit = rate?.rateUnit ?? 'FLAT'; const unitUsd = rate ? Number(rate.rateValue) : usdAmount; const unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd; - // Per-unit count: explicit trigger (e.g. overweight tons) when present, - // otherwise derived from total ÷ unit price (FLAT surcharges → 1). + // Per-unit count: FLAT and PER_INVOICE are billed once (qty 1); an + // explicit trigger (e.g. overweight tons) wins when present; otherwise + // derive from total ÷ unit price. const quantity = - mod.triggerValue != null && mod.triggerValue > 0 - ? mod.triggerValue - : unitUsd > 0 - ? Math.max(1, Math.round(usdAmount / unitUsd)) - : 1; + unit === 'FLAT' || unit === 'PER_INVOICE' + ? 1 + : mod.triggerValue != null && mod.triggerValue > 0 + ? mod.triggerValue + : unitUsd > 0 + ? Math.max(1, Math.round(usdAmount / unitUsd)) + : 1; const item: PriceLineItemDto = { - code: mod.surchargeTypeCode, - description: surchargeLabel(mod.surchargeTypeCode), + code: mod.surchargeCode, + description: surchargeLabel(mod.surchargeCode), amount: convertedAmount, unitAmount, unit, @@ -193,7 +196,7 @@ export class BookingPricingService { if (!snapshotId) return null; return { bookingId, - surchargeTypeId: m.surchargeTypeId, + rateId: m.rateId, triggerValue: m.triggerValue, calculatedAmount: m.calculatedAmount, rateSnapshotId: snapshotId, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index e1bdfcc07..6cfac85fa 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -397,7 +397,7 @@ export class BookingsRepository extends BaseRepository { async createCargoModifiers( rows: Array<{ bookingId: string; - surchargeTypeId: string; + rateId: string; triggerValue: number | null; calculatedAmount: number; rateSnapshotId: string; diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-cargo-modifier.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-cargo-modifier.entity.ts index 5933abae2..ed5b05b36 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking-cargo-modifier.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-cargo-modifier.entity.ts @@ -1,12 +1,12 @@ import { BaseEntity } from '@edr/api-common'; import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; -import { SurchargeType } from '../../rule-engine/entities/surcharge-type.entity'; +import { Rate } from '../../rule-engine/entities/rate.entity'; import { Booking } from './booking.entity'; import { BookingRateSnapshot } from './booking-rate-snapshot.entity'; @Entity({ schema: 'freight', name: 'booking_cargo_modifier' }) @Index(['bookingId']) -@Index(['surchargeTypeId']) +@Index(['rateId']) export class BookingCargoModifier extends BaseEntity { @Column({ name: 'booking_id', type: 'uuid' }) bookingId!: string; @@ -15,12 +15,17 @@ export class BookingCargoModifier extends BaseEntity { @JoinColumn({ name: 'booking_id' }) booking?: Booking; - @Column({ name: 'surcharge_type_id', type: 'uuid' }) - surchargeTypeId!: string; + /** + * The trigger-based rate (hazard, reefer, overweight …) that produced this + * surcharge line. Replaces the former surcharge_type link now that rates are + * self-describing. + */ + @Column({ name: 'rate_id', type: 'uuid' }) + rateId!: string; - @ManyToOne(() => SurchargeType) - @JoinColumn({ name: 'surcharge_type_id' }) - surchargeType?: SurchargeType; + @ManyToOne(() => Rate) + @JoinColumn({ name: 'rate_id' }) + rate?: Rate; @Column({ name: 'trigger_value', type: 'numeric', precision: 14, scale: 4, nullable: true }) triggerValue?: number | null; diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/surcharge-types.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/surcharge-types.controller.ts deleted file mode 100644 index be4c3011a..000000000 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/surcharge-types.controller.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { - Body, Controller, Delete, Get, HttpCode, HttpStatus, - Param, ParseUUIDPipe, Patch, Post, Query, -} from '@nestjs/common'; -import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; -import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { CreateSurchargeTypeDto } from '../dto/create-surcharge-type.dto'; -import { UpdateSurchargeTypeDto } from '../dto/update-surcharge-type.dto'; -import { SurchargeTypesService } from '../services/surcharge-types.service'; - -@ApiTags('surcharge-types') -@Controller('surcharge-types') -@ApiBearerAuth() -export class SurchargeTypesController { - constructor(private readonly service: SurchargeTypesService) {} - - @Get() - @RuleEngineView('surcharge-types') - @ApiOperation({ summary: 'List surcharge types' }) - findAll(@Query() query: Record) { - return this.service.findAll({ - isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined, - page: query['page'] ? parseInt(query['page'], 10) : undefined, - pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined, - }); - } - - @Get(':id') - @RuleEngineView('surcharge-types') - @ApiOperation({ summary: 'Get a surcharge type by ID' }) - findOne(@Param('id', ParseUUIDPipe) id: string) { - return this.service.findById(id); - } - - @Post() - @RuleEngineManage('surcharge-types') - @ApiOperation({ summary: 'Create a surcharge type' }) - create(@Body() dto: CreateSurchargeTypeDto) { - return this.service.create(dto); - } - - @Patch(':id') - @RuleEngineManage('surcharge-types') - @ApiOperation({ summary: 'Update a surcharge type' }) - update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateSurchargeTypeDto) { - return this.service.update(id, dto); - } - - @Delete(':id') - @RuleEngineManage('surcharge-types') - @HttpCode(HttpStatus.NO_CONTENT) - @ApiOperation({ summary: 'Soft-delete a surcharge type' }) - remove(@Param('id', ParseUUIDPipe) id: string) { - return this.service.remove(id); - } -} 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 969c08876..995718135 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 @@ -1,29 +1,46 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsDateString, IsIn, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; -import { RATE_TYPES, RATE_UNITS } from '../entities/rate.entity'; +import { + RATE_APPLIES_TO, + RATE_TRIGGERS, + RATE_UNITS, +} from '../entities/rate.entity'; const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const; const CURRENCIES = ['USD'] as const; export class CreateRateDto { - @ApiProperty({ enum: RATE_TYPES, description: 'Rate type identifier' }) - @IsIn([...RATE_TYPES]) - rateType!: string; + @ApiProperty({ enum: RATE_APPLIES_TO, description: 'Friendly category the rate applies to' }) + @IsIn([...RATE_APPLIES_TO]) + appliesTo!: string; - @ApiPropertyOptional({ description: 'FK to container_types.id — null for non-container rates' }) + @ApiProperty({ + enum: RATE_TRIGGERS, + description: 'What makes this rate apply. ALWAYS = base freight; anything else is a surcharge.', + }) + @IsIn([...RATE_TRIGGERS]) + trigger!: string; + + @ApiPropertyOptional({ description: 'FK to container_types.id — set for container/intercity-container rates' }) @IsOptional() @IsUUID() containerTypeId?: string; + @ApiPropertyOptional({ description: 'FK to cargo_types.id (bulk leaf commodity) — set for bulk/intercity-bulk rates' }) + @IsOptional() + @IsUUID() + cargoTypeId?: string; + @ApiPropertyOptional({ enum: TRADE_DIRECTIONS, description: 'Trade direction. Null = direction-agnostic' }) @IsOptional() @IsIn([...TRADE_DIRECTIONS]) tradeDirection?: string; - @ApiProperty({ enum: CURRENCIES }) + @ApiPropertyOptional({ enum: CURRENCIES }) + @IsOptional() @IsIn([...CURRENCIES]) - currency!: string; + currency?: string; @ApiProperty({ description: 'Numeric rate value', minimum: 0 }) @IsNumber() diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-surcharge-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-surcharge-type.dto.ts deleted file mode 100644 index 7aef9bbde..000000000 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-surcharge-type.dto.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsBoolean, IsIn, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator'; - -const TRIGGER_CONDITIONS = [ - 'CARGO_FLAG_HAZARDOUS', - 'CARGO_FLAG_REEFER', - 'VGM_EXCEEDS_LIMIT', - 'SHIPPING_LINE_MAPPED', - 'CONSOLIDATION_ENABLED', -] as const; - -export class CreateSurchargeTypeDto { - @ApiProperty({ description: 'Human-readable label', maxLength: 100 }) - @IsString() - @MaxLength(100) - label!: string; - - @ApiProperty({ enum: TRIGGER_CONDITIONS, description: 'Condition that auto-fires this surcharge' }) - @IsIn([...TRIGGER_CONDITIONS]) - triggerCondition!: string; - - @ApiProperty({ description: 'FK to rates.id — the LIVE rate used to price this surcharge' }) - @IsUUID() - rateId!: string; - - @ApiPropertyOptional({ default: true }) - @IsOptional() - @IsBoolean() - isActive?: boolean; -} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/update-surcharge-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/update-surcharge-type.dto.ts deleted file mode 100644 index cb9be80eb..000000000 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/update-surcharge-type.dto.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { PartialType } from '@nestjs/mapped-types'; -import { CreateSurchargeTypeDto } from './create-surcharge-type.dto'; - -export class UpdateSurchargeTypeDto extends PartialType(CreateSurchargeTypeDto) {} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts new file mode 100644 index 000000000..579f7da9d --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts @@ -0,0 +1,61 @@ +import type { RateAppliesTo, RateTrigger, RateType } from './rate.entity'; + +/** + * Derive the legacy `rateType` string from the friendly form fields. + * + * `rateType` is still the key the pricing engine uses to look up base rail + * freight (CONTAINER_IMPORT, BULK_EXPORT, …) and what gets snapshotted on a + * booking. The configuration UI no longer asks for it directly — the admin + * picks `appliesTo` + `tradeDirection` (+ `trigger` for surcharges) and we map + * that to the canonical rateType here so both layers stay in agreement. + */ +export function deriveRateType(input: { + appliesTo: RateAppliesTo; + trigger: RateTrigger; + tradeDirection?: string | null; + /** Whether a bulk cargo (vs a container) was selected — disambiguates intercity. */ + isBulk?: boolean; +}): RateType { + const { appliesTo, trigger, tradeDirection, isBulk } = input; + + // Surcharges (trigger ≠ ALWAYS) map to their dedicated rateType. + if (trigger !== 'ALWAYS') { + switch (trigger) { + case 'HAZARDOUS': + return 'HAZARD_SURCHARGE'; + case 'REEFER': + return 'REEFER_SURCHARGE'; + case 'OVERWEIGHT': + return 'OVERWEIGHT_PER_TON'; + case 'SHIPPING_LINE': + return 'DOUBLE_HANDLING'; + case 'CONSOLIDATION': + return 'LASHING'; + case 'CANCELLATION': + return 'CANCELLATION_FEE'; + case 'DEMURRAGE': + return 'DEMURRAGE'; + case 'PIL_EXTRA_FEE': + return 'PIL_EXTRA_FEE'; + } + } + + // Base freight (trigger = ALWAYS) maps by category + direction. + const isExport = tradeDirection === 'EXPORT'; + switch (appliesTo) { + case 'CONTAINER': + return isExport ? 'CONTAINER_EXPORT' : 'CONTAINER_IMPORT'; + case 'BULK': + return isExport ? 'BULK_EXPORT' : 'BULK_IMPORT'; + case 'INTERCITY': + // Intercity has no trade direction; container vs bulk decided by which + // scope field was filled (cargoTypeId → bulk, containerTypeId → container). + return isBulk ? 'INTERCITY_BULK' : 'INTERCITY_CONTAINER'; + case 'FIRST_MILE': + return 'FIRST_MILE'; + case 'LAST_MILE': + return 'LAST_MILE'; + default: + return 'CANCELLATION_FEE'; + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts index 33030e95f..b57b48cd8 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts @@ -1,5 +1,6 @@ import { BaseEntity } from '@edr/api-common'; import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { CargoType } from './cargo-type.entity'; import { ContainerType } from './container-type.entity'; export const RATE_TYPES = [ @@ -27,18 +28,72 @@ export type RateType = typeof RATE_TYPES[number]; export const RATE_STATUSES = ['DRAFT', 'PENDING_APPROVAL', 'LIVE', 'SUPERSEDED'] as const; export type RateStatus = typeof RATE_STATUSES[number]; -export const RATE_UNITS = ['PER_WAGON', 'PER_TON', 'PER_CONTAINER', 'PER_KM', 'FLAT'] as const; +export const RATE_UNITS = [ + 'PER_WAGON', + 'PER_TON', + 'PER_CONTAINER', + 'PER_KM', + 'PER_INVOICE', + 'FLAT', +] as const; export type RateUnit = typeof RATE_UNITS[number]; +/** + * Friendly, admin-facing category that determines how the rate is used in + * pricing and which fields the rate form shows. Replaces the cryptic + * `rateType` matrix for the configuration UI (rateType is still persisted and + * derived from `appliesTo` + `tradeDirection` + `trigger` for base-freight + * lookup and snapshots). + * + * - BULK / CONTAINER / INTERCITY : base rail freight (trigger = ALWAYS) + * - FIRST_MILE / LAST_MILE : pickup / delivery legs + * - OTHER : trigger-based surcharges (hazard, reefer …) + */ +export const RATE_APPLIES_TO = [ + 'BULK', + 'CONTAINER', + 'INTERCITY', + 'FIRST_MILE', + 'LAST_MILE', + 'OTHER', +] as const; +export type RateAppliesTo = typeof RATE_APPLIES_TO[number]; + +/** + * What makes a rate apply to a booking. `ALWAYS` is base freight (matched by + * direction + container/bulk scope). Everything else is a surcharge that the + * rule engine adds on top, additively, when the booking matches the trigger — + * so hazard stacks on container/bulk with each line's own unit. + */ +export const RATE_TRIGGERS = [ + 'ALWAYS', + 'HAZARDOUS', + 'OVERWEIGHT', + 'REEFER', + 'SHIPPING_LINE', + 'CONSOLIDATION', + 'CANCELLATION', + 'DEMURRAGE', + 'PIL_EXTRA_FEE', +] as const; +export type RateTrigger = typeof RATE_TRIGGERS[number]; + @Entity({ schema: 'freight', name: 'rates' }) @Index(['rateType']) @Index(['status']) @Index(['effectiveFrom']) @Index(['containerTypeId']) +@Index(['trigger']) export class Rate extends BaseEntity { @Column({ name: 'rate_type', type: 'varchar', length: 50 }) rateType!: RateType; + @Column({ name: 'applies_to', type: 'varchar', length: 20, default: 'OTHER' }) + appliesTo!: RateAppliesTo; + + @Column({ name: 'trigger', type: 'varchar', length: 20, default: 'ALWAYS' }) + trigger!: RateTrigger; + @Column({ name: 'container_type_id', type: 'uuid', nullable: true }) containerTypeId?: string | null; @@ -46,6 +101,13 @@ export class Rate extends BaseEntity { @JoinColumn({ name: 'container_type_id' }) containerType?: ContainerType | null; + @Column({ name: 'cargo_type_id', type: 'uuid', nullable: true }) + cargoTypeId?: string | null; + + @ManyToOne(() => CargoType, { nullable: true, eager: false }) + @JoinColumn({ name: 'cargo_type_id' }) + cargoType?: CargoType | null; + @Column({ name: 'trade_direction', type: 'varchar', length: 10, nullable: true }) tradeDirection?: string | null; diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/surcharge-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/surcharge-type.entity.ts deleted file mode 100644 index 2b9934a1e..000000000 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/surcharge-type.entity.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; -import { Rate } from './rate.entity'; - -const TRIGGER_CONDITIONS = [ - 'CARGO_FLAG_HAZARDOUS', - 'CARGO_FLAG_REEFER', - 'VGM_EXCEEDS_LIMIT', - 'SHIPPING_LINE_MAPPED', - 'CONSOLIDATION_ENABLED', -] as const; - -export type TriggerCondition = typeof TRIGGER_CONDITIONS[number]; - -@Entity({ schema: 'freight', name: 'surcharge_types' }) -@Index(['code']) -@Index(['isActive']) -@Index(['rateId']) -export class SurchargeType extends BaseEntity { - @Column({ name: 'code', type: 'varchar', length: 40, unique: true }) - code!: string; - - @Column({ name: 'label', type: 'varchar', length: 100, nullable: true }) - label!: string; - - @Column({ name: 'trigger_condition', type: 'varchar', length: 50, nullable: true }) - triggerCondition!: TriggerCondition; - - @Column({ name: 'rate_id', type: 'uuid', nullable: true }) - rateId!: string; - - @ManyToOne(() => Rate, { eager: false }) - @JoinColumn({ name: 'rate_id' }) - rate?: Rate; - - @Column({ name: 'is_active', type: 'boolean', default: true }) - isActive!: boolean; -} diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/surcharge-types.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/surcharge-types.repository.interface.ts deleted file mode 100644 index a6931aaf2..000000000 --- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/surcharge-types.repository.interface.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { FindManyOptions } from 'typeorm'; -import { SurchargeType } from '../entities/surcharge-type.entity'; - -export interface ISurchargeTypesRepository { - findById(id: string): Promise; - findByCode(code: string): Promise; - findAllActiveWithRate(): Promise; - findAll(options?: FindManyOptions): Promise; - findAndCount(options?: FindManyOptions): Promise<[SurchargeType[], number]>; - create(data: Partial): Promise; - update(id: string, data: Partial): Promise; - softDelete(id: string): Promise; -} - -export const SURCHARGE_TYPES_REPOSITORY = Symbol('SURCHARGE_TYPES_REPOSITORY'); diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/surcharge-types.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/surcharge-types.repository.ts deleted file mode 100644 index 7b44e73e1..000000000 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/surcharge-types.repository.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { DataSource, FindManyOptions, Repository } from 'typeorm'; -import { SurchargeType } from '../entities/surcharge-type.entity'; -import { ISurchargeTypesRepository } from '../interfaces/surcharge-types.repository.interface'; - -@Injectable() -export class SurchargeTypesRepository implements ISurchargeTypesRepository { - private readonly repo: Repository; - - constructor(private readonly dataSource: DataSource) { - this.repo = this.dataSource.getRepository(SurchargeType); - } - - findById(id: string): Promise { - return this.repo.findOne({ where: { id } }); - } - - findByCode(code: string): Promise { - return this.repo.findOne({ where: { code } }); - } - - findAllActiveWithRate(): Promise { - return this.repo.find({ - where: { isActive: true }, - relations: { rate: true }, - }); - } - - findAll(options?: FindManyOptions): Promise { - return this.repo.find(options); - } - - findAndCount(options?: FindManyOptions): Promise<[SurchargeType[], number]> { - return this.repo.findAndCount(options); - } - - async create(data: Partial): Promise { - const entity = this.repo.create(data); - return this.repo.save(entity); - } - - async update(id: string, data: Partial): Promise { - await this.repo.update(id, data); - return this.findById(id); - } - - async softDelete(id: string): Promise { - await this.repo.softDelete(id); - } -} diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts index 49f1c446c..34dc9f982 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts @@ -8,7 +8,6 @@ import { PriorityConfigsController } from './controllers/priority-configs.contro import { RatesController } from './controllers/rates.controller'; import { ServiceTypesController } from './controllers/service-types.controller'; import { ShippingLinesController } from './controllers/shipping-lines.controller'; -import { SurchargeTypesController } from './controllers/surcharge-types.controller'; import { WeightLimitRulesController } from './controllers/weight-limit-rules.controller'; import { YardsController } from './controllers/yards.controller'; @@ -19,7 +18,6 @@ import { PriorityConfig } from './entities/priority-config.entity'; import { Rate } from './entities/rate.entity'; import { ServiceType } from './entities/service-type.entity'; import { ShippingLine } from './entities/shipping-line.entity'; -import { SurchargeType } from './entities/surcharge-type.entity'; import { WeightLimitRule } from './entities/weight-limit-rule.entity'; import { Yard } from './entities/yard.entity'; @@ -30,7 +28,6 @@ import { PRIORITY_CONFIGS_REPOSITORY } from './interfaces/priority-configs.repos import { RATES_REPOSITORY } from './interfaces/rates.repository.interface'; import { SERVICE_TYPES_REPOSITORY } from './interfaces/service-types.repository.interface'; import { SHIPPING_LINES_REPOSITORY } from './interfaces/shipping-lines.repository.interface'; -import { SURCHARGE_TYPES_REPOSITORY } from './interfaces/surcharge-types.repository.interface'; import { WEIGHT_LIMIT_RULES_REPOSITORY } from './interfaces/weight-limit-rules.repository.interface'; import { YARDS_REPOSITORY } from './interfaces/yards.repository.interface'; @@ -41,7 +38,6 @@ import { PriorityConfigsRepository } from './repositories/priority-configs.repos import { RatesRepository } from './repositories/rates.repository'; import { ServiceTypesRepository } from './repositories/service-types.repository'; import { ShippingLinesRepository } from './repositories/shipping-lines.repository'; -import { SurchargeTypesRepository } from './repositories/surcharge-types.repository'; import { WeightLimitRulesRepository } from './repositories/weight-limit-rules.repository'; import { YardsRepository } from './repositories/yards.repository'; @@ -53,7 +49,6 @@ import { PriorityConfigsService } from './services/priority-configs.service'; import { RatesService } from './services/rates.service'; import { ServiceTypesService } from './services/service-types.service'; import { ShippingLinesService } from './services/shipping-lines.service'; -import { SurchargeTypesService } from './services/surcharge-types.service'; import { WeightLimitRulesService } from './services/weight-limit-rules.service'; import { YardsService } from './services/yards.service'; @@ -71,7 +66,6 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. CargoType, ContainerType, PriorityConfig, - SurchargeType, ServiceType, WeightLimitRule, Yard, @@ -88,7 +82,6 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. CargoTypesController, ContainerTypesController, PriorityConfigsController, - SurchargeTypesController, ServiceTypesController, WeightLimitRulesController, YardsController, @@ -103,8 +96,6 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. { provide: CONTAINER_TYPES_REPOSITORY, useExisting: ContainerTypesRepository }, PriorityConfigsRepository, { provide: PRIORITY_CONFIGS_REPOSITORY, useExisting: PriorityConfigsRepository }, - SurchargeTypesRepository, - { provide: SURCHARGE_TYPES_REPOSITORY, useExisting: SurchargeTypesRepository }, ServiceTypesRepository, { provide: SERVICE_TYPES_REPOSITORY, useExisting: ServiceTypesRepository }, WeightLimitRulesRepository, @@ -120,7 +111,6 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. CargoTypesService, ContainerTypesService, PriorityConfigsService, - SurchargeTypesService, ServiceTypesService, WeightLimitRulesService, YardsService, @@ -135,7 +125,6 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. CargoTypesService, ServiceTypesService, ContainerTypesService, - SurchargeTypesService, WeightLimitRulesService, PriorityConfigsService, YardsService, 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 9884ee854..2390c3f8e 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 @@ -1,8 +1,8 @@ -import { Inject, Injectable, Logger, BadRequestException } from '@nestjs/common'; +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 { TriggerCondition } from './entities/surcharge-type.entity'; +import { Rate, RateTrigger } from './entities/rate.entity'; import { ICargoTypesRepository, CARGO_TYPES_REPOSITORY, @@ -19,10 +19,6 @@ import { IPriorityConfigsRepository, PRIORITY_CONFIGS_REPOSITORY, } from './interfaces/priority-configs.repository.interface'; -import { - ISurchargeTypesRepository, - SURCHARGE_TYPES_REPOSITORY, -} from './interfaces/surcharge-types.repository.interface'; import { IRatesRepository, RATES_REPOSITORY, @@ -63,11 +59,12 @@ export interface BookingEvaluationInput { } export interface AppliedCargoModifier { - surchargeTypeId: string; - surchargeTypeCode: string; + /** 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; - rateId: string; currency: string; } @@ -89,8 +86,6 @@ export interface RuleEvaluationResult { @Injectable() export class RuleEngineService { - private readonly logger = new Logger(RuleEngineService.name); - constructor( @Inject(CARGO_TYPES_REPOSITORY) private readonly cargoTypesRepo: ICargoTypesRepository, @@ -100,8 +95,6 @@ export class RuleEngineService { private readonly weightLimitRulesRepo: IWeightLimitRulesRepository, @Inject(PRIORITY_CONFIGS_REPOSITORY) private readonly priorityConfigsRepo: IPriorityConfigsRepository, - @Inject(SURCHARGE_TYPES_REPOSITORY) - private readonly surchargeTypesRepo: ISurchargeTypesRepository, @Inject(RATES_REPOSITORY) private readonly ratesRepo: IRatesRepository, @Inject(APPROVAL_RULES_REPOSITORY) @@ -205,20 +198,14 @@ export class RuleEngineService { const hasReefer = input.containers.some((c) => c.isReefer); const hasOverweight = containerWeightResults.some((r) => r.isOverweight); - const surchargeTypes = await this.surchargeTypesRepo.findAllActiveWithRate(); + // 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. const liveRates = await this.ratesRepo.findLiveRates(); - const rateById = new Map(liveRates.map((r) => [r.id, r])); + const surchargeRates = liveRates.filter((r) => r.trigger && r.trigger !== 'ALWAYS'); - // TEMP diagnostic — trace the surcharge trigger state so we can confirm - // whether a "Hazardous" line is firing for a non-hazardous booking. - this.logger.debug( - `surcharge eval: isHazardous=${input.isHazardous} (type ${typeof input.isHazardous}) ` + - `hasReefer=${hasReefer} hasOverweight=${hasOverweight} ` + - `shippingLineMapped=${shippingLineMapped}`, - ); - - for (const st of surchargeTypes) { - const triggered = this.matchesTrigger(st.triggerCondition, { + for (const rate of surchargeRates) { + const triggered = this.matchesTrigger(rate.trigger, { isHazardous: input.isHazardous, hasReefer, hasOverweight, @@ -227,20 +214,16 @@ export class RuleEngineService { }); if (!triggered) continue; - const rate = st.rate ?? rateById.get(st.rateId); - if (!rate) continue; - let triggerValue: number | null = null; let calculatedAmount = Number(rate.rateValue); - if (st.triggerCondition === 'VGM_EXCEEDS_LIMIT') { + // Per-ton surcharges (typically OVERWEIGHT) bill against the excess tons. + if (rate.rateUnit === 'PER_TON' && rate.trigger === 'OVERWEIGHT') { triggerValue = containerWeightResults.reduce( (sum, r) => sum + (r.overweightExcessTons ?? 0), 0, ); - if (rate.rateUnit === 'PER_TON') { - calculatedAmount = triggerValue * Number(rate.rateValue); - } + calculatedAmount = triggerValue * Number(rate.rateValue); } // Safety guard: never include a surcharge with a non-positive amount (a @@ -249,11 +232,10 @@ export class RuleEngineService { if (!(calculatedAmount > 0)) continue; appliedModifiers.push({ - surchargeTypeId: st.id, - surchargeTypeCode: st.code, + rateId: rate.id, + surchargeCode: this.surchargeCode(rate), triggerValue, calculatedAmount, - rateId: rate.id, currency: rate.currency, }); } @@ -388,7 +370,7 @@ export class RuleEngineService { } private matchesTrigger( - condition: TriggerCondition, + trigger: RateTrigger, state: { isHazardous: boolean; hasReefer: boolean; @@ -400,19 +382,26 @@ export class RuleEngineService { // 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 (condition) { - case 'CARGO_FLAG_HAZARDOUS': + switch (trigger) { + case 'HAZARDOUS': return truthy(state.isHazardous); - case 'CARGO_FLAG_REEFER': + case 'REEFER': return truthy(state.hasReefer); - case 'VGM_EXCEEDS_LIMIT': + case 'OVERWEIGHT': return truthy(state.hasOverweight); - case 'SHIPPING_LINE_MAPPED': + case 'SHIPPING_LINE': return truthy(state.shippingLineMapped); - case 'CONSOLIDATION_ENABLED': + case 'CONSOLIDATION': return truthy(state.allowConsolidation); + // 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; + } } 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 291d1959d..c3ab6bab0 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 @@ -2,6 +2,7 @@ import { BadRequestException, Inject, Injectable, NotFoundException } from '@nes import { CreateRateDto } from '../dto/create-rate.dto'; import { UpdateRateDto } from '../dto/update-rate.dto'; import { Rate } from '../entities/rate.entity'; +import { deriveRateType } from '../entities/rate-type.util'; import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface'; @Injectable() @@ -47,10 +48,27 @@ export class RatesService { /** Create a rate in DRAFT status. */ async create(dto: CreateRateDto, proposedByStaffId: string): Promise { + const appliesTo = dto.appliesTo as Rate['appliesTo']; + const trigger = dto.trigger as Rate['trigger']; + // Surcharges (trigger ≠ ALWAYS) carry no direction/scope — clear them so + // the engine never accidentally narrows a surcharge by container/direction. + const isSurcharge = trigger !== 'ALWAYS'; + const containerTypeId = isSurcharge ? null : (dto.containerTypeId ?? null); + const cargoTypeId = isSurcharge ? null : (dto.cargoTypeId ?? null); + const tradeDirection = isSurcharge ? null : (dto.tradeDirection ?? null); + return this.repository.create({ - rateType: dto.rateType as Rate['rateType'], - containerTypeId: dto.containerTypeId, - tradeDirection: dto.tradeDirection, + appliesTo, + trigger, + rateType: deriveRateType({ + appliesTo, + trigger, + tradeDirection, + isBulk: Boolean(cargoTypeId), + }), + containerTypeId, + cargoTypeId, + tradeDirection, currency: dto.currency ?? 'USD', rateValue: dto.rateValue, rateUnit: dto.rateUnit as Rate['rateUnit'], @@ -68,9 +86,41 @@ export class RatesService { throw new BadRequestException('Only DRAFT rates can be updated'); } const updates: Partial = {}; - if (dto.rateType) updates.rateType = dto.rateType as Rate['rateType']; - if (dto.containerTypeId !== undefined) updates.containerTypeId = dto.containerTypeId; - if (dto.tradeDirection !== undefined) updates.tradeDirection = dto.tradeDirection; + + const appliesTo = (dto.appliesTo as Rate['appliesTo']) ?? existing.appliesTo; + const trigger = (dto.trigger as Rate['trigger']) ?? existing.trigger; + const isSurcharge = trigger !== 'ALWAYS'; + + if (dto.appliesTo) updates.appliesTo = appliesTo; + if (dto.trigger) updates.trigger = trigger; + + const containerTypeId = isSurcharge + ? null + : dto.containerTypeId !== undefined + ? dto.containerTypeId + : existing.containerTypeId; + const cargoTypeId = isSurcharge + ? null + : dto.cargoTypeId !== undefined + ? dto.cargoTypeId + : existing.cargoTypeId; + const tradeDirection = isSurcharge + ? null + : dto.tradeDirection !== undefined + ? dto.tradeDirection + : existing.tradeDirection; + + updates.containerTypeId = containerTypeId; + updates.cargoTypeId = cargoTypeId; + updates.tradeDirection = tradeDirection; + // Keep the derived rateType in sync with whatever changed. + updates.rateType = deriveRateType({ + appliesTo, + trigger, + tradeDirection, + isBulk: Boolean(cargoTypeId), + }); + updates.currency = dto.currency ?? existing.currency ?? 'USD'; if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue; if (dto.rateUnit) updates.rateUnit = dto.rateUnit as Rate['rateUnit']; diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/surcharge-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/surcharge-types.service.ts deleted file mode 100644 index 387e26ba9..000000000 --- a/apps/edr-freight-api/src/modules/rule-engine/services/surcharge-types.service.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; -import { generateCode } from '../../../common/utils/generate-code.util'; -import { CreateSurchargeTypeDto } from '../dto/create-surcharge-type.dto'; -import { UpdateSurchargeTypeDto } from '../dto/update-surcharge-type.dto'; -import { SurchargeType } from '../entities/surcharge-type.entity'; -import { - ISurchargeTypesRepository, - SURCHARGE_TYPES_REPOSITORY, -} from '../interfaces/surcharge-types.repository.interface'; - -@Injectable() -export class SurchargeTypesService { - constructor( - @Inject(SURCHARGE_TYPES_REPOSITORY) - private readonly repository: ISurchargeTypesRepository, - ) {} - - /** List surcharge types with pagination. */ - async findAll(filter: { - isActive?: boolean; - page?: number; - pageSize?: number; - }): Promise<{ data: SurchargeType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> { - const page = filter.page ?? 1; - const pageSize = filter.pageSize ?? 20; - const where: Record = {}; - if (filter.isActive !== undefined) where.isActive = filter.isActive; - - const [data, total] = await this.repository.findAndCount({ - where, - relations: { rate: true }, - order: { label: 'ASC' }, - skip: (page - 1) * pageSize, - take: pageSize, - }); - return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } }; - } - - /** Get a single surcharge type by ID. */ - async findById(id: string): Promise { - const entity = await this.repository.findById(id); - if (!entity) throw new NotFoundException(`Surcharge type ${id} not found`); - return entity; - } - - /** Create a new surcharge type. */ - async create(dto: CreateSurchargeTypeDto): Promise { - const code = generateCode(dto.label); - const existing = await this.repository.findByCode(code); - if (existing) throw new ConflictException(`Surcharge type with label "${dto.label}" conflicts with existing code "${code}"`); - return this.repository.create({ - code, - label: dto.label, - triggerCondition: dto.triggerCondition as SurchargeType['triggerCondition'], - rateId: dto.rateId, - isActive: dto.isActive ?? true, - }); - } - - /** Update an existing surcharge type. */ - async update(id: string, dto: UpdateSurchargeTypeDto): Promise { - await this.findById(id); - const patch: Partial = {}; - if (dto.label !== undefined) patch.label = dto.label; - if (dto.triggerCondition !== undefined) patch.triggerCondition = dto.triggerCondition as SurchargeType['triggerCondition']; - if (dto.rateId !== undefined) patch.rateId = dto.rateId; - if (dto.isActive !== undefined) patch.isActive = dto.isActive; - const updated = await this.repository.update(id, patch); - if (!updated) throw new NotFoundException(`Surcharge type ${id} not found`); - return updated; - } - - /** Soft-delete a surcharge type. */ - async remove(id: string): Promise { - await this.findById(id); - await this.repository.softDelete(id); - } -} diff --git a/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts b/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts index b391d3f9e..5936e7c93 100644 --- a/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts @@ -20,12 +20,13 @@ import { DEFAULT_APPROVAL_RULE_ROWS } from '../modules/rule-engine/approval-rule const EDR_ORG_KEY = 'edr_freight'; const MIN_WAGONS_PER_TYPE = 100; -/** The four demo staff users, each mapped to a seeded freight role. */ +/** The demo staff users, each mapped to a seeded freight role. */ const DEMO_STAFF_USERS = [ { email: 'marketing@edr.local', username: 'marketing', roleKey: 'edr_marketing' }, { email: 'operations@edr.local', username: 'operations', roleKey: 'edr_operations_officer' }, { email: 'director@edr.local', username: 'director', roleKey: 'edr_director' }, { email: 'ceo@edr.local', username: 'ceo', roleKey: 'edr_ceo' }, + { email: 'gl@edr.local', username: 'gl', roleKey: 'edr_global_logistics' }, ] as const; /** diff --git a/apps/edr-freight-api/src/seed/edr-freight.seed.ts b/apps/edr-freight-api/src/seed/edr-freight.seed.ts index a88ee8f89..9c0e62001 100644 --- a/apps/edr-freight-api/src/seed/edr-freight.seed.ts +++ b/apps/edr-freight-api/src/seed/edr-freight.seed.ts @@ -237,6 +237,11 @@ export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [ name: { en: "EDR Marketing" }, permissionKeys: [...ROLE_PERMISSION_PRESETS.marketing], }, + { + key: "edr_global_logistics", + name: { en: "EDR Global Logistics" }, + permissionKeys: [...ROLE_PERMISSION_PRESETS.globalLogistics], + }, { key: "edr_org_manager", name: { en: "EDR Org Manager" }, diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index b7027a487..669ea2153 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -15,7 +15,6 @@ export const RULE_ENGINE_RESOURCE_SLUGS = [ 'yards', 'shipping-lines', 'weight-limit-rules', - 'surcharge-types', 'priority-configs', 'rates', 'approval-rules', @@ -70,7 +69,6 @@ const RULE_ENGINE_PERMISSION_IDS: Record [ct.code, ct])); - const rates = await this.seedRates(rRepo, ctByCode); - const ratesByType = new Map(); - for (const r of rates) { - const key = `${r.rateType}|${r.currency}|${r.containerTypeId ?? ""}`; - if (!ratesByType.has(key)) ratesByType.set(key, []); - ratesByType.get(key)!.push(r); - } + // Clear booking cargo modifiers up front — they reference rate snapshots + // that get recomputed when bookings are repriced. + await manager.getRepository(BookingCargoModifier).createQueryBuilder().delete().execute(); - await this.seedSurchargeTypes(manager, ratesByType); + const cargoTypesForRates = await manager.getRepository(CargoType).find(); + const cargoForRatesByCode = new Map(cargoTypesForRates.map((c) => [c.code, c])); + await this.seedRates(rRepo, ctByCode, cargoForRatesByCode); const yards = await yRepo.find(); const yardByCode = new Map(yards.map((y) => [y.code, y])); @@ -416,135 +413,43 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { private async seedRates( rRepo: any, ctByCode: Map, + cargoByCode: Map, ): Promise { const effectiveFrom = new Date("2026-01-01"); const now = new Date(); - // await rRepo.createQueryBuilder().delete().execute(); + // Each rate is self-describing: `appliesTo` + `trigger` decide how the + // engine uses it. trigger=ALWAYS → base freight; anything else → a + // surcharge that stacks additively when the booking matches. const rateData = [ - { - rateType: "CONTAINER_IMPORT", - containerTypeId: ctByCode.get("20FT")!.id, - currency: "USD", - rateValue: 800, - rateUnit: "PER_CONTAINER", - }, - { - rateType: "CONTAINER_IMPORT", - containerTypeId: ctByCode.get("40FT")!.id, - currency: "USD", - rateValue: 1200, - rateUnit: "PER_CONTAINER", - }, - { - rateType: "CONTAINER_EXPORT", - containerTypeId: ctByCode.get("20FT")!.id, - currency: "USD", - rateValue: 600, - rateUnit: "PER_CONTAINER", - }, - { - rateType: "CONTAINER_EXPORT", - containerTypeId: ctByCode.get("40FT")!.id, - currency: "USD", - rateValue: 900, - rateUnit: "PER_CONTAINER", - }, - { - rateType: "CONTAINER_IMPORT", - containerTypeId: null, - currency: "USD", - rateValue: 1000, - rateUnit: "PER_CONTAINER", - }, - { - rateType: "CONTAINER_EXPORT", - containerTypeId: null, - currency: "USD", - rateValue: 750, - rateUnit: "PER_CONTAINER", - }, - { - rateType: "INTERCITY_CONTAINER", - containerTypeId: ctByCode.get("20FT")!.id, - currency: "USD", - rateValue: 350, - rateUnit: "PER_CONTAINER", - }, - { - rateType: "INTERCITY_CONTAINER", - containerTypeId: ctByCode.get("40FT")!.id, - currency: "USD", - rateValue: 550, - rateUnit: "PER_CONTAINER", - }, - { - rateType: "INTERCITY_CONTAINER", - containerTypeId: null, - currency: "USD", - rateValue: 400, - rateUnit: "PER_CONTAINER", - }, - { - rateType: "INTERCITY_BULK", - containerTypeId: null, - currency: "USD", - rateValue: 35, - rateUnit: "PER_TON", - }, - { - rateType: "BULK_IMPORT", - containerTypeId: null, - currency: "USD", - rateValue: 50, - rateUnit: "PER_TON", - }, - { - rateType: "BULK_EXPORT", - containerTypeId: null, - currency: "USD", - rateValue: 40, - rateUnit: "PER_TON", - }, - { - rateType: "OVERWEIGHT_PER_TON", - containerTypeId: null, - currency: "USD", - rateValue: 25, - rateUnit: "PER_TON", - }, - { - rateType: "HAZARD_SURCHARGE", - containerTypeId: null, - currency: "USD", - rateValue: 150, - rateUnit: "FLAT", - }, - { - rateType: "REEFER_SURCHARGE", - containerTypeId: null, - currency: "USD", - rateValue: 200, - rateUnit: "FLAT", - }, - { - rateType: "DOUBLE_HANDLING", - containerTypeId: null, - currency: "USD", - rateValue: 100, - rateUnit: "PER_CONTAINER", - }, - { - rateType: "LASHING", - containerTypeId: null, - currency: "USD", - rateValue: 50, - rateUnit: "PER_CONTAINER", - }, + // ── Container base freight ────────────────────────────────────────── + { appliesTo: "CONTAINER", trigger: "ALWAYS", rateType: "CONTAINER_IMPORT", tradeDirection: "IMPORT", containerTypeId: ctByCode.get("20FT")!.id, rateValue: 800, rateUnit: "PER_CONTAINER" }, + { appliesTo: "CONTAINER", trigger: "ALWAYS", rateType: "CONTAINER_IMPORT", tradeDirection: "IMPORT", containerTypeId: ctByCode.get("40FT")!.id, rateValue: 1200, rateUnit: "PER_CONTAINER" }, + { appliesTo: "CONTAINER", trigger: "ALWAYS", rateType: "CONTAINER_EXPORT", tradeDirection: "EXPORT", containerTypeId: ctByCode.get("20FT")!.id, rateValue: 600, rateUnit: "PER_CONTAINER" }, + { appliesTo: "CONTAINER", trigger: "ALWAYS", rateType: "CONTAINER_EXPORT", tradeDirection: "EXPORT", containerTypeId: ctByCode.get("40FT")!.id, rateValue: 900, rateUnit: "PER_CONTAINER" }, + { appliesTo: "CONTAINER", trigger: "ALWAYS", rateType: "CONTAINER_IMPORT", tradeDirection: "IMPORT", containerTypeId: null, rateValue: 1000, rateUnit: "PER_CONTAINER" }, + { appliesTo: "CONTAINER", trigger: "ALWAYS", rateType: "CONTAINER_EXPORT", tradeDirection: "EXPORT", containerTypeId: null, rateValue: 750, rateUnit: "PER_CONTAINER" }, + // ── Intercity base freight ────────────────────────────────────────── + { appliesTo: "INTERCITY", trigger: "ALWAYS", rateType: "INTERCITY_CONTAINER", containerTypeId: ctByCode.get("20FT")!.id, rateValue: 350, rateUnit: "PER_CONTAINER" }, + { appliesTo: "INTERCITY", trigger: "ALWAYS", rateType: "INTERCITY_CONTAINER", containerTypeId: ctByCode.get("40FT")!.id, rateValue: 550, rateUnit: "PER_CONTAINER" }, + { appliesTo: "INTERCITY", trigger: "ALWAYS", rateType: "INTERCITY_CONTAINER", containerTypeId: null, rateValue: 400, rateUnit: "PER_CONTAINER" }, + { appliesTo: "INTERCITY", trigger: "ALWAYS", rateType: "INTERCITY_BULK", rateValue: 35, rateUnit: "PER_TON" }, + // ── Bulk base freight (by leaf cargo type where known) ────────────── + { appliesTo: "BULK", trigger: "ALWAYS", rateType: "BULK_IMPORT", tradeDirection: "IMPORT", cargoTypeId: cargoByCode.get("GRAIN")?.id ?? null, rateValue: 50, rateUnit: "PER_TON" }, + { appliesTo: "BULK", trigger: "ALWAYS", rateType: "BULK_EXPORT", tradeDirection: "EXPORT", cargoTypeId: cargoByCode.get("GRAIN")?.id ?? null, rateValue: 40, rateUnit: "PER_TON" }, + { appliesTo: "BULK", trigger: "ALWAYS", rateType: "BULK_IMPORT", tradeDirection: "IMPORT", cargoTypeId: null, rateValue: 50, rateUnit: "PER_TON" }, + { appliesTo: "BULK", trigger: "ALWAYS", rateType: "BULK_EXPORT", tradeDirection: "EXPORT", cargoTypeId: null, rateValue: 40, rateUnit: "PER_TON" }, + // ── Surcharges (trigger-based) ────────────────────────────────────── + { appliesTo: "OTHER", trigger: "OVERWEIGHT", rateType: "OVERWEIGHT_PER_TON", rateValue: 25, rateUnit: "PER_TON" }, + { appliesTo: "OTHER", trigger: "HAZARDOUS", rateType: "HAZARD_SURCHARGE", rateValue: 150, rateUnit: "FLAT" }, + { appliesTo: "OTHER", trigger: "REEFER", rateType: "REEFER_SURCHARGE", rateValue: 200, rateUnit: "FLAT" }, + { appliesTo: "OTHER", trigger: "SHIPPING_LINE", rateType: "DOUBLE_HANDLING", rateValue: 100, rateUnit: "PER_CONTAINER" }, + { appliesTo: "OTHER", trigger: "CONSOLIDATION", rateType: "LASHING", rateValue: 50, rateUnit: "PER_CONTAINER" }, ]; const entities = rateData.map((d) => rRepo.create({ + currency: "USD", ...d, status: "LIVE", proposedByStaffId: STAFF_USER_ID, @@ -556,66 +461,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { return rRepo.save(entities); } - private async seedSurchargeTypes( - manager: any, - ratesByType: Map, - ): Promise { - const surRepo = manager.getRepository(SurchargeType); - const bcmRepo = manager.getRepository(BookingCargoModifier); - await bcmRepo.createQueryBuilder().delete().execute(); - const findRate = (rateType: string, currency: string) => { - const key = `${rateType}|${currency}|`; - const rates = ratesByType.get(key); - return rates?.[0]; - }; - - const hazardRateUsd = findRate("HAZARD_SURCHARGE", "USD"); - const reeferRateUsd = findRate("REEFER_SURCHARGE", "USD"); - const overweightRateUsd = findRate("OVERWEIGHT_PER_TON", "USD"); - const shipLineRateUsd = findRate("DOUBLE_HANDLING", "USD"); - const consolidRateUsd = findRate("LASHING", "USD"); - - await surRepo.createQueryBuilder().delete().execute(); - await surRepo.save([ - surRepo.create({ - code: "HAZARDOUS_CARGO", - label: "Hazardous Cargo", - triggerCondition: "CARGO_FLAG_HAZARDOUS", - rateId: hazardRateUsd?.id, - isActive: true, - }), - surRepo.create({ - code: "REEFER_CARGO", - label: "Reefer Cargo", - triggerCondition: "CARGO_FLAG_REEFER", - rateId: reeferRateUsd?.id, - isActive: true, - }), - surRepo.create({ - code: "OVERWEIGHT_CARGO", - label: "Overweight Cargo", - triggerCondition: "VGM_EXCEEDS_LIMIT", - rateId: overweightRateUsd?.id, - isActive: true, - }), - surRepo.create({ - code: "SHIPPING_LINE_FEE", - label: "Shipping Line Fee", - triggerCondition: "SHIPPING_LINE_MAPPED", - rateId: shipLineRateUsd?.id, - isActive: true, - }), - surRepo.create({ - code: "CONSOLIDATION_FEE", - label: "Consolidation Fee", - triggerCondition: "CONSOLIDATION_ENABLED", - rateId: consolidRateUsd?.id, - isActive: true, - }), - ]); - this.logger.log("Seeded surcharge types"); - } - private async seedDraftBookings( ctByCode: Map, yardByCode: Map, diff --git a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts index f3133cc70..cb1172def 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts +++ b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts @@ -167,7 +167,7 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [ prefix: RULE_ENGINE_CATEGORY_BASE_PATH.configuration, meta: { title: "Configuration", - subtitle: "Master data: cargo, containers, wagon types, services, surcharges, yards, and shipping lines", + subtitle: "Master data: cargo, containers, wagon types, services, yards, and shipping lines", }, }, ...configurationRouteMeta, 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 373336b06..f60ac3045 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx @@ -158,11 +158,21 @@ const RuleEngineFormDialog = ({ const visibleFields = useMemo( () => - fields.filter( - (field) => - !field.hideWhen || - !field.hideWhen.equals.includes(String(values[field.hideWhen.field] ?? "")), - ), + fields.filter((field) => { + if ( + field.hideWhen && + field.hideWhen.equals.includes(String(values[field.hideWhen.field] ?? "")) + ) { + return false; + } + if ( + field.showWhen && + !field.showWhen.equals.includes(String(values[field.showWhen.field] ?? "")) + ) { + return false; + } + return true; + }), [fields, values], ); @@ -244,6 +254,7 @@ const RuleEngineFormDialog = ({