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 = ({