diff --git a/apps/edr-freight-api/src/migrations/1900000000000-SimplifyRatesAndWeightLimitRules.ts b/apps/edr-freight-api/src/migrations/1900000000000-SimplifyRatesAndWeightLimitRules.ts new file mode 100644 index 000000000..c03e70bbb --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1900000000000-SimplifyRatesAndWeightLimitRules.ts @@ -0,0 +1,121 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Simplify the rate + weight-limit configuration model: + * + * 1. Drop the effective_from / effective_to validity window from both + * `rates` and `weight_limit_rules`. Rates are now activated purely by + * the approval workflow (status = LIVE) and weight limits are always + * active for their container + direction. No time-travel scheduling. + * + * 2. Enforce "one rate per pattern" with partial unique indexes so the same + * configuration (e.g. FIRST_MILE for a given container type) cannot be + * duplicated. NULL scope columns are COALESCE-normalised because Postgres + * treats NULLs as distinct in a plain unique index. + * + * This migration is destructive on the date columns — existing effective_* + * values are dropped. + */ +export class SimplifyRatesAndWeightLimitRules1900000000000 implements MigrationInterface { + name = 'SimplifyRatesAndWeightLimitRules1900000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // ── 1. De-duplicate existing data so the unique indexes can be created ── + // Keep the most recently-created row per pattern, soft-delete the rest. + await queryRunner.query(` + WITH ranked AS ( + SELECT id, + row_number() OVER ( + PARTITION BY rate_type, + COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(trade_direction, '') + ORDER BY created_at DESC, id DESC + ) AS rn + FROM freight.rates + WHERE deleted_at IS NULL AND status <> 'SUPERSEDED' + ) + UPDATE freight.rates r + SET deleted_at = now() + FROM ranked + WHERE r.id = ranked.id AND ranked.rn > 1; + `); + + await queryRunner.query(` + WITH ranked AS ( + SELECT id, + row_number() OVER ( + PARTITION BY container_type_id, trade_direction + ORDER BY created_at DESC, id DESC + ) AS rn + FROM freight.weight_limit_rules + WHERE deleted_at IS NULL + ) + UPDATE freight.weight_limit_rules w + SET deleted_at = now() + FROM ranked + WHERE w.id = ranked.id AND ranked.rn > 1; + `); + + // ── 2. Drop the effective-date indexes + columns ─────────────────────── + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_rates_effective_from";`); + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_weight_limit_rules_effective_from";`); + // Indexes created by TypeORM's @Index carry generated hashed names — drop + // any index that references the effective_from column defensively. + await queryRunner.query(` + DO $$ + DECLARE idx record; + BEGIN + FOR idx IN + SELECT indexname FROM pg_indexes + WHERE schemaname = 'freight' + AND tablename IN ('rates', 'weight_limit_rules') + AND indexdef ILIKE '%effective_from%' + LOOP + EXECUTE format('DROP INDEX IF EXISTS freight.%I', idx.indexname); + END LOOP; + END $$; + `); + + await queryRunner.query(`ALTER TABLE freight.rates DROP COLUMN IF EXISTS effective_from;`); + await queryRunner.query(`ALTER TABLE freight.rates DROP COLUMN IF EXISTS effective_to;`); + await queryRunner.query(`ALTER TABLE freight.weight_limit_rules DROP COLUMN IF EXISTS effective_from;`); + await queryRunner.query(`ALTER TABLE freight.weight_limit_rules DROP COLUMN IF EXISTS effective_to;`); + + // ── 3. One-rate-per-pattern partial unique indexes ───────────────────── + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern" + ON freight.rates ( + rate_type, + COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(trade_direction, '') + ) + WHERE deleted_at IS NULL AND status <> 'SUPERSEDED'; + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_weight_limit_rules_pattern" + ON freight.weight_limit_rules (container_type_id, trade_direction) + WHERE deleted_at IS NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern";`); + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_weight_limit_rules_pattern";`); + + await queryRunner.query(`ALTER TABLE freight.rates ADD COLUMN IF NOT EXISTS effective_from date;`); + await queryRunner.query(`UPDATE freight.rates SET effective_from = COALESCE(effective_from, created_at::date);`); + await queryRunner.query(`ALTER TABLE freight.rates ALTER COLUMN effective_from SET NOT NULL;`); + await queryRunner.query(`ALTER TABLE freight.rates ADD COLUMN IF NOT EXISTS effective_to date;`); + + await queryRunner.query(`ALTER TABLE freight.weight_limit_rules ADD COLUMN IF NOT EXISTS effective_from date;`); + await queryRunner.query(`ALTER TABLE freight.weight_limit_rules ADD COLUMN IF NOT EXISTS effective_to date;`); + + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_rates_effective_from" ON freight.rates (effective_from);`); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_weight_limit_rules_effective_from" ON freight.weight_limit_rules (effective_from);`, + ); + } +} 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 995718135..9a4cd642b 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,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsDateString, IsIn, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; +import { IsIn, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; import { RATE_APPLIES_TO, RATE_TRIGGERS, @@ -51,15 +51,6 @@ export class CreateRateDto { @ApiProperty({ enum: RATE_UNITS, description: 'Unit basis for the rate' }) @IsIn([...RATE_UNITS]) rateUnit!: string; - - @ApiProperty({ description: 'Date from which this rate is effective (ISO date)', example: '2025-01-01' }) - @IsDateString() - effectiveFrom!: string; - - @ApiPropertyOptional({ description: 'Date when this rate expires. Null = currently active', example: '2025-12-31' }) - @IsOptional() - @IsDateString() - effectiveTo?: string; } export class SubmitRateForApprovalDto { diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts index 6be37214b..eea223ae3 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts @@ -1,6 +1,6 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsDateString, IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator'; +import { IsIn, IsNumber, IsUUID, Min } from 'class-validator'; const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH', 'DOMESTIC'] as const; @@ -21,13 +21,4 @@ export class CreateWeightLimitRuleDto { @Min(0) @Transform(({ value }) => Number(value)) maxVgmTons!: number; - - @ApiProperty({ description: 'Date from which this rule is active (ISO date)', example: '2024-01-01' }) - @IsDateString() - effectiveFrom!: string; - - @ApiPropertyOptional({ description: 'Date when this rule expires (ISO date). Null = currently active', example: '2025-12-31' }) - @IsOptional() - @IsDateString() - effectiveTo?: string; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts new file mode 100644 index 000000000..cef613412 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts @@ -0,0 +1,71 @@ +import type { RateAppliesTo, RateTrigger, RateUnit } from './rate.entity'; + +/** + * Which rate units make sense for a given rate shape. The weighting basis is + * driven by the *type* of thing being billed — a container leg bills per + * container, bulk freight per ton, an intercity move can be per-km, a + * cancellation is a flat/per-invoice fee, and overweight is always per excess + * ton. This keeps the rate table dynamic yet non-conflicting: the admin can + * only pick a unit the pricing engine knows how to apply. + * + * Returned lists are ordered with the most natural/default unit first. + */ +export function allowedRateUnits(input: { + appliesTo: RateAppliesTo; + trigger: RateTrigger; +}): RateUnit[] { + const { appliesTo, trigger } = input; + + // Surcharges (Applies to = Other) are governed by their trigger. + if (appliesTo === 'OTHER') { + switch (trigger) { + case 'OVERWEIGHT': + // Overweight always bills the excess tonnage — per ton, nothing else. + return ['PER_TON']; + case 'REEFER': + case 'HAZARDOUS': + // Scale with the freight shape: per container for boxes, per ton for bulk. + return ['PER_CONTAINER', 'PER_TON']; + case 'DEMURRAGE': + return ['PER_CONTAINER', 'PER_TON']; + case 'CANCELLATION': + return ['FLAT', 'PER_INVOICE']; + case 'CONSOLIDATION': + return ['PER_CONTAINER', 'FLAT']; + case 'SHIPPING_LINE': + case 'PIL_EXTRA_FEE': + return ['PER_CONTAINER', 'FLAT']; + default: + return ['FLAT', 'PER_TON', 'PER_CONTAINER']; + } + } + + // Base freight + first/last mile scale with the cargo type. + switch (appliesTo) { + case 'CONTAINER': + return ['PER_CONTAINER', 'PER_WAGON']; + case 'BULK': + return ['PER_TON', 'PER_WAGON']; + case 'INTERCITY': + return ['PER_CONTAINER', 'PER_TON', 'PER_WAGON', 'PER_KM']; + case 'FIRST_MILE': + case 'LAST_MILE': + return ['PER_CONTAINER', 'PER_TON', 'PER_KM', 'FLAT']; + default: + return ['FLAT']; + } +} + +/** The default (first / most natural) unit for a rate shape. */ +export function defaultRateUnit(input: { appliesTo: RateAppliesTo; trigger: RateTrigger }): RateUnit { + return allowedRateUnits(input)[0]; +} + +/** True when `unit` is a valid weighting basis for the given rate shape. */ +export function isRateUnitAllowed(input: { + appliesTo: RateAppliesTo; + trigger: RateTrigger; + unit: RateUnit; +}): boolean { + return allowedRateUnits(input).includes(input.unit); +} 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 b57b48cd8..50f8b3b99 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 @@ -81,7 +81,6 @@ 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 { @@ -131,10 +130,4 @@ export class Rate extends BaseEntity { @Column({ name: 'approved_at', type: 'timestamptz', nullable: true }) approvedAt?: Date | null; - - @Column({ name: 'effective_from', type: 'date' }) - effectiveFrom!: Date; - - @Column({ name: 'effective_to', type: 'date', nullable: true }) - effectiveTo?: Date | null; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/weight-limit-rule.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/weight-limit-rule.entity.ts index 39557eec9..b6b87b285 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/weight-limit-rule.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/weight-limit-rule.entity.ts @@ -5,7 +5,6 @@ import { ContainerType } from './container-type.entity'; @Entity({ schema: 'freight', name: 'weight_limit_rules' }) @Index(['containerTypeId']) @Index(['tradeDirection']) -@Index(['effectiveFrom']) export class WeightLimitRule extends BaseEntity { @Column({ name: 'container_type_id', type: 'uuid' }) containerTypeId!: string; @@ -19,10 +18,4 @@ export class WeightLimitRule extends BaseEntity { @Column({ name: 'max_vgm_tons', type: 'numeric', precision: 8, scale: 3, nullable: true }) maxVgmTons!: number; - - @Column({ name: 'effective_from', type: 'date', nullable: true }) - effectiveFrom!: Date; - - @Column({ name: 'effective_to', type: 'date', nullable: true }) - effectiveTo?: Date | null; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts index 52b991155..24b3c3626 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts @@ -4,6 +4,12 @@ import { Rate } from '../entities/rate.entity'; export interface IRatesRepository { findById(id: string): Promise; findLiveRates(): Promise; + findByPattern(pattern: { + rateType: string; + containerTypeId?: string | null; + cargoTypeId?: string | null; + tradeDirection?: string | null; + }): Promise; findAll(options?: FindManyOptions): Promise; findAndCount(options?: FindManyOptions): Promise<[Rate[], number]>; create(data: Partial): Promise; diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/weight-limit-rules.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/weight-limit-rules.repository.interface.ts index cedbd1eee..3c175df4e 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/weight-limit-rules.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/weight-limit-rules.repository.interface.ts @@ -7,6 +7,11 @@ export interface IWeightLimitRulesRepository { containerTypeId: string, tradeDirection: string, ): Promise; + findByPattern( + containerTypeId: string, + tradeDirection: string, + excludeId?: string, + ): Promise; findAll(options?: FindManyOptions): Promise; findAndCount(options?: FindManyOptions): Promise<[WeightLimitRule[], number]>; create(data: Partial): Promise; diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts index 0d49a0bf3..bbb5f2386 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts @@ -16,15 +16,48 @@ export class RatesRepository implements IRatesRepository { } findLiveRates(): Promise { - const now = new Date(); return this.repo .createQueryBuilder('rate') .where('rate.status = :status', { status: 'LIVE' }) - .andWhere('rate.effective_from <= :now', { now }) - .andWhere('(rate.effective_to IS NULL OR rate.effective_to > :now)', { now }) .getMany(); } + /** + * Find a non-superseded rate matching an identity pattern — the same tuple the + * `UQ_rates_pattern` unique index enforces. Used to reject duplicates before + * insert so the admin gets a friendly error instead of a raw constraint fault. + * NULL scope columns are matched with IS NULL, mirroring the COALESCE index. + */ + findByPattern(pattern: { + rateType: string; + containerTypeId?: string | null; + cargoTypeId?: string | null; + tradeDirection?: string | null; + }): Promise { + const qb = this.repo + .createQueryBuilder('rate') + .where('rate.rate_type = :rateType', { rateType: pattern.rateType }) + .andWhere('rate.status <> :superseded', { superseded: 'SUPERSEDED' }); + + if (pattern.containerTypeId) { + qb.andWhere('rate.container_type_id = :containerTypeId', { containerTypeId: pattern.containerTypeId }); + } else { + qb.andWhere('rate.container_type_id IS NULL'); + } + if (pattern.cargoTypeId) { + qb.andWhere('rate.cargo_type_id = :cargoTypeId', { cargoTypeId: pattern.cargoTypeId }); + } else { + qb.andWhere('rate.cargo_type_id IS NULL'); + } + if (pattern.tradeDirection) { + qb.andWhere('rate.trade_direction = :tradeDirection', { tradeDirection: pattern.tradeDirection }); + } else { + qb.andWhere('rate.trade_direction IS NULL'); + } + + return qb.getOne(); + } + findAll(options?: FindManyOptions): Promise { return this.repo.find(options); } diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/weight-limit-rules.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/weight-limit-rules.repository.ts index 0d151c561..87d2febba 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/weight-limit-rules.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/weight-limit-rules.repository.ts @@ -22,7 +22,6 @@ export class WeightLimitRulesRepository implements IWeightLimitRulesRepository { containerTypeId: string, tradeDirection: string, ): Promise { - const now = new Date(); return this.repo .createQueryBuilder('rule') .innerJoinAndSelect('rule.containerType', 'ct') @@ -31,11 +30,27 @@ export class WeightLimitRulesRepository implements IWeightLimitRulesRepository { dir: tradeDirection, both: 'BOTH', }) - .andWhere('rule.effective_from <= :now', { now }) - .andWhere('(rule.effective_to IS NULL OR rule.effective_to > :now)', { now }) .getMany(); } + /** + * Find a rule matching the (containerType, tradeDirection) identity — the + * tuple enforced by `UQ_weight_limit_rules_pattern`. Used to reject duplicates + * before insert. Optionally excludes a row by id so updates don't self-collide. + */ + findByPattern( + containerTypeId: string, + tradeDirection: string, + excludeId?: string, + ): Promise { + const qb = this.repo + .createQueryBuilder('rule') + .where('rule.container_type_id = :containerTypeId', { containerTypeId }) + .andWhere('rule.trade_direction = :tradeDirection', { tradeDirection }); + if (excludeId) qb.andWhere('rule.id <> :excludeId', { excludeId }); + return qb.getOne(); + } + findAll(options?: FindManyOptions): Promise { return this.repo.find(options); } 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 c3ab6bab0..2a717366d 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 @@ -1,8 +1,15 @@ -import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + ConflictException, + Inject, + Injectable, + NotFoundException, +} from '@nestjs/common'; 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 { allowedRateUnits, isRateUnitAllowed } from '../entities/rate-unit.util'; import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface'; @Injectable() @@ -27,7 +34,7 @@ export class RatesService { const [data, total] = await this.repository.findAndCount({ where, - order: { effectiveFrom: 'DESC' }, + order: { createdAt: 'DESC' }, skip: (page - 1) * pageSize, take: pageSize, }); @@ -46,6 +53,49 @@ export class RatesService { return entity; } + /** + * Normalise + validate the weighting unit for a rate shape. Overweight is + * always billed per excess ton, so its unit is forced to PER_TON regardless + * of what the client sent. Every other shape must pick a unit the pricing + * engine can actually apply (see `allowedRateUnits`). + */ + private resolveRateUnit( + appliesTo: Rate['appliesTo'], + trigger: Rate['trigger'], + requestedUnit: Rate['rateUnit'], + ): Rate['rateUnit'] { + // Overweight is per-ton, full stop. + if (trigger === 'OVERWEIGHT') return 'PER_TON'; + + if (!isRateUnitAllowed({ appliesTo, trigger, unit: requestedUnit })) { + const allowed = allowedRateUnits({ appliesTo, trigger }).join(', '); + throw new BadRequestException( + `Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed}.`, + ); + } + return requestedUnit; + } + + /** + * Reject a second rate with the same identity pattern (rateType + scope). With + * effective-date windows gone, two LIVE/DRAFT rates for the same pattern would + * make pricing ambiguous — so we allow exactly one per pattern. + */ + private async assertNoDuplicatePattern(pattern: { + rateType: string; + containerTypeId: string | null; + cargoTypeId: string | null; + tradeDirection: string | null; + ignoreId?: string; + }): Promise { + const existing = await this.repository.findByPattern(pattern); + if (existing && existing.id !== pattern.ignoreId) { + throw new ConflictException( + 'A rate for this exact combination already exists. Edit or delete the existing rate instead of creating a duplicate.', + ); + } + } + /** Create a rate in DRAFT status. */ async create(dto: CreateRateDto, proposedByStaffId: string): Promise { const appliesTo = dto.appliesTo as Rate['appliesTo']; @@ -57,25 +107,28 @@ export class RatesService { const cargoTypeId = isSurcharge ? null : (dto.cargoTypeId ?? null); const tradeDirection = isSurcharge ? null : (dto.tradeDirection ?? null); + const rateType = deriveRateType({ + appliesTo, + trigger, + tradeDirection, + isBulk: Boolean(cargoTypeId), + }); + const rateUnit = this.resolveRateUnit(appliesTo, trigger, dto.rateUnit as Rate['rateUnit']); + + await this.assertNoDuplicatePattern({ rateType, containerTypeId, cargoTypeId, tradeDirection }); + return this.repository.create({ appliesTo, trigger, - rateType: deriveRateType({ - appliesTo, - trigger, - tradeDirection, - isBulk: Boolean(cargoTypeId), - }), + rateType, containerTypeId, cargoTypeId, tradeDirection, currency: dto.currency ?? 'USD', rateValue: dto.rateValue, - rateUnit: dto.rateUnit as Rate['rateUnit'], + rateUnit, status: 'DRAFT', proposedByStaffId, - effectiveFrom: new Date(dto.effectiveFrom), - effectiveTo: dto.effectiveTo ? new Date(dto.effectiveTo) : undefined, }); } @@ -110,22 +163,34 @@ export class RatesService { ? dto.tradeDirection : existing.tradeDirection; - updates.containerTypeId = containerTypeId; - updates.cargoTypeId = cargoTypeId; - updates.tradeDirection = tradeDirection; + updates.containerTypeId = containerTypeId ?? null; + updates.cargoTypeId = cargoTypeId ?? null; + updates.tradeDirection = tradeDirection ?? null; // Keep the derived rateType in sync with whatever changed. - updates.rateType = deriveRateType({ + const rateType = deriveRateType({ appliesTo, trigger, tradeDirection, isBulk: Boolean(cargoTypeId), }); + updates.rateType = rateType; + + // Re-validate the unit against the (possibly changed) shape; overweight is + // forced to PER_TON. + const requestedUnit = (dto.rateUnit as Rate['rateUnit']) ?? existing.rateUnit; + updates.rateUnit = this.resolveRateUnit(appliesTo, trigger, requestedUnit); + + // Guard the pattern uniqueness for the new identity, ignoring this row. + await this.assertNoDuplicatePattern({ + rateType, + containerTypeId: updates.containerTypeId, + cargoTypeId: updates.cargoTypeId, + tradeDirection: updates.tradeDirection, + ignoreId: id, + }); 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']; - if (dto.effectiveFrom) updates.effectiveFrom = new Date(dto.effectiveFrom); - if (dto.effectiveTo) updates.effectiveTo = new Date(dto.effectiveTo); const updated = await this.repository.update(id, updates); if (!updated) throw new NotFoundException(`Rate ${id} not found`); return updated;