Merge pull request #423 from Tria-plc/freight_feature/usermanagement

fix issues
This commit is contained in:
marshal
2026-07-03 16:55:26 +03:00
committed by GitHub
16 changed files with 472 additions and 120 deletions

View File

@@ -0,0 +1,126 @@
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<void> {
// ── 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, ''),
rate_unit
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 ─────────────────────
// The unit is part of the identity so a surcharge can legitimately carry two
// rows that bill different ways (e.g. reefer PER_CONTAINER + reefer PER_TON),
// while still blocking a true duplicate (same rateType + scope + unit).
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, ''),
rate_unit
)
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<void> {
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);`,
);
}
}

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -4,6 +4,13 @@ import { Rate } from '../entities/rate.entity';
export interface IRatesRepository {
findById(id: string): Promise<Rate | null>;
findLiveRates(): Promise<Rate[]>;
findByPattern(pattern: {
rateType: string;
rateUnit: string;
containerTypeId?: string | null;
cargoTypeId?: string | null;
tradeDirection?: string | null;
}): Promise<Rate | null>;
findAll(options?: FindManyOptions<Rate>): Promise<Rate[]>;
findAndCount(options?: FindManyOptions<Rate>): Promise<[Rate[], number]>;
create(data: Partial<Rate>): Promise<Rate>;

View File

@@ -7,6 +7,11 @@ export interface IWeightLimitRulesRepository {
containerTypeId: string,
tradeDirection: string,
): Promise<WeightLimitRule[]>;
findByPattern(
containerTypeId: string,
tradeDirection: string,
excludeId?: string,
): Promise<WeightLimitRule | null>;
findAll(options?: FindManyOptions<WeightLimitRule>): Promise<WeightLimitRule[]>;
findAndCount(options?: FindManyOptions<WeightLimitRule>): Promise<[WeightLimitRule[], number]>;
create(data: Partial<WeightLimitRule>): Promise<WeightLimitRule>;

View File

@@ -16,15 +16,50 @@ export class RatesRepository implements IRatesRepository {
}
findLiveRates(): Promise<Rate[]> {
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;
rateUnit: string;
containerTypeId?: string | null;
cargoTypeId?: string | null;
tradeDirection?: string | null;
}): Promise<Rate | null> {
const qb = this.repo
.createQueryBuilder('rate')
.where('rate.rate_type = :rateType', { rateType: pattern.rateType })
.andWhere('rate.rate_unit = :rateUnit', { rateUnit: pattern.rateUnit })
.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<Rate>): Promise<Rate[]> {
return this.repo.find(options);
}

View File

@@ -22,7 +22,6 @@ export class WeightLimitRulesRepository implements IWeightLimitRulesRepository {
containerTypeId: string,
tradeDirection: string,
): Promise<WeightLimitRule[]> {
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<WeightLimitRule | null> {
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<WeightLimitRule>): Promise<WeightLimitRule[]> {
return this.repo.find(options);
}

View File

@@ -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,50 @@ 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;
rateUnit: string;
containerTypeId: string | null;
cargoTypeId: string | null;
tradeDirection: string | null;
ignoreId?: string;
}): Promise<void> {
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<Rate> {
const appliesTo = dto.appliesTo as Rate['appliesTo'];
@@ -57,25 +108,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, rateUnit, 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 +164,35 @@ 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,
rateUnit: updates.rateUnit,
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;

View File

@@ -1,4 +1,4 @@
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto';
import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto';
import { WeightLimitRule } from '../entities/weight-limit-rule.entity';
@@ -30,7 +30,7 @@ export class WeightLimitRulesService {
const [data, total] = await this.repository.findAndCount({
where,
relations: { containerType: true },
order: { effectiveFrom: 'DESC' },
order: { createdAt: 'DESC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
@@ -44,26 +44,51 @@ export class WeightLimitRulesService {
return entity;
}
/**
* Reject a second rule for the same container + direction. One VGM limit per
* (container, direction) — otherwise the booking engine can't tell which
* applies.
*/
private async assertNoDuplicate(
containerTypeId: string,
tradeDirection: string,
ignoreId?: string,
): Promise<void> {
const existing = await this.repository.findByPattern(containerTypeId, tradeDirection, ignoreId);
if (existing) {
throw new ConflictException(
'A weight limit rule for this container type and trade direction already exists. Edit the existing rule instead.',
);
}
}
/** Create a new weight limit rule. */
async create(dto: CreateWeightLimitRuleDto): Promise<WeightLimitRule> {
await this.assertNoDuplicate(dto.containerTypeId, dto.tradeDirection);
return this.repository.create({
containerTypeId: dto.containerTypeId,
tradeDirection: dto.tradeDirection,
maxVgmTons: dto.maxVgmTons,
effectiveFrom: new Date(dto.effectiveFrom),
effectiveTo: dto.effectiveTo ? new Date(dto.effectiveTo) : null,
});
}
/** Update an existing weight limit rule. */
async update(id: string, dto: UpdateWeightLimitRuleDto): Promise<WeightLimitRule> {
await this.findById(id);
const existing = await this.findById(id);
const patch: Partial<WeightLimitRule> = {};
if (dto.containerTypeId !== undefined) patch.containerTypeId = dto.containerTypeId;
if (dto.tradeDirection !== undefined) patch.tradeDirection = dto.tradeDirection;
if (dto.maxVgmTons !== undefined) patch.maxVgmTons = dto.maxVgmTons;
if (dto.effectiveFrom !== undefined) patch.effectiveFrom = new Date(dto.effectiveFrom);
if (dto.effectiveTo !== undefined) patch.effectiveTo = new Date(dto.effectiveTo);
// Re-check uniqueness when the identity (container/direction) changes.
if (dto.containerTypeId !== undefined || dto.tradeDirection !== undefined) {
await this.assertNoDuplicate(
patch.containerTypeId ?? existing.containerTypeId,
patch.tradeDirection ?? existing.tradeDirection,
id,
);
}
const updated = await this.repository.update(id, patch);
if (!updated) throw new NotFoundException(`Weight limit rule ${id} not found`);
return updated;

View File

@@ -319,37 +319,11 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
const twenty = await ctRepo.findOneByOrFail({ code: "20FT" });
const forty = await ctRepo.findOneByOrFail({ code: "40FT" });
const base = new Date("2026-01-01");
const rules = [
{
containerTypeId: twenty.id,
tradeDirection: "IMPORT",
maxVgmTons: 26,
effectiveFrom: base,
isActive: true,
},
{
containerTypeId: twenty.id,
tradeDirection: "EXPORT",
maxVgmTons: 26,
effectiveFrom: base,
isActive: true,
},
{
containerTypeId: forty.id,
tradeDirection: "IMPORT",
maxVgmTons: 28,
effectiveFrom: base,
isActive: true,
},
{
containerTypeId: forty.id,
tradeDirection: "EXPORT",
maxVgmTons: 28,
effectiveFrom: base,
isActive: true,
},
{ containerTypeId: twenty.id, tradeDirection: "IMPORT", maxVgmTons: 26 },
{ containerTypeId: twenty.id, tradeDirection: "EXPORT", maxVgmTons: 26 },
{ containerTypeId: forty.id, tradeDirection: "IMPORT", maxVgmTons: 28 },
{ containerTypeId: forty.id, tradeDirection: "EXPORT", maxVgmTons: 28 },
];
for (const rule of rules) {
@@ -361,10 +335,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
});
if (existing) {
await wlRepo.update(existing.id, {
maxVgmTons: rule.maxVgmTons,
effectiveFrom: rule.effectiveFrom,
});
await wlRepo.update(existing.id, { maxVgmTons: rule.maxVgmTons });
} else {
await wlRepo.insert(rule);
}
@@ -409,7 +380,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
ctByCode: Map<string, any>,
cargoByCode: Map<string, any>,
): Promise<Rate[]> {
const effectiveFrom = new Date("2026-01-01");
const now = new Date();
// Each rate is self-describing: `appliesTo` + `trigger` decide how the
@@ -479,7 +449,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
proposedByStaffId: STAFF_USER_ID,
approvedByCeoId: CEO_USER_ID,
approvedAt: now,
effectiveFrom,
}))
.filter((d) => !existingBySignature.has(signature(d)));

View File

@@ -179,7 +179,16 @@ const RuleEngineFormDialog = ({
const formRows = useMemo(() => buildFormRows(visibleFields), [visibleFields]);
const setField = (name: string, value: unknown) => {
setValues((current) => ({ ...current, [name]: value }));
setValues((current) => {
const next = { ...current, [name]: value };
// Changing what a rate applies to (or its surcharge trigger) can invalidate
// the previously-chosen unit — reset it so the admin re-picks from the new
// allowed set instead of submitting a stale, rejected unit.
if ((name === "appliesTo" || name === "trigger") && "rateUnit" in current) {
next.rateUnit = "";
}
return next;
});
};
const handleSubmit = (event: React.FormEvent) => {
@@ -250,6 +259,9 @@ const RuleEngineFormDialog = ({
const label = <FieldLabel label={field.label} required={field.required} />;
if (field.type === "select") {
// Dynamic options (e.g. rate unit) resolve from the live form values so
// the choices track the other fields the admin has picked.
const options = field.optionsFromValues ? field.optionsFromValues(values) : (field.options ?? []);
return (
<Select
key={field.name}
@@ -261,7 +273,7 @@ const RuleEngineFormDialog = ({
value={resolveSelectValue(field, values)}
onChange={(v) => setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)}
disabled={selectOptionsLoading}
data={(field.options ?? [])
data={options
.filter((opt) => opt.value !== "")
.map((opt) => ({
label: opt.label,

View File

@@ -47,6 +47,14 @@ export interface FormFieldDef {
* showWhen and not match hideWhen.
*/
showWhen?: { field: string; equals: string[] };
/**
* Select options computed from other fields' current values. When set, the
* form resolves the option list at render time from the live form state
* instead of the static `options` list. Used for the rate unit selector,
* whose valid choices depend on `appliesTo` + `trigger`. (Named distinctly
* from the fleet config's string-based `dynamicOptions` to avoid a clash.)
*/
optionsFromValues?: (values: Record<string, unknown>) => { label: string; value: string }[];
}
export interface RuleEngineOrderConfig {
@@ -116,12 +124,54 @@ const RATE_TRIGGERS = [
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
];
const RATE_UNITS =["PER_WAGON", "PER_TON", "PER_CONTAINER", "PER_KM", "PER_INVOICE", "FLAT"].map(
(v) => ({
label: v.replace(/_/g, " "),
value: v,
}),
);
const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value });
/**
* Valid weighting units for a rate shape — mirrors the API's
* `allowedRateUnits`. The unit is driven by the *type* being billed: containers
* bill per container, bulk per ton, overweight always per excess ton, etc. Kept
* in sync with apps/edr-freight-api/.../entities/rate-unit.util.ts.
*/
const allowedRateUnits = (appliesTo: string, trigger: string): string[] => {
if (appliesTo === "OTHER") {
switch (trigger) {
case "OVERWEIGHT":
return ["PER_TON"];
case "REEFER":
case "HAZARDOUS":
case "DEMURRAGE":
return ["PER_CONTAINER", "PER_TON"];
case "CANCELLATION":
return ["FLAT", "PER_INVOICE"];
case "CONSOLIDATION":
case "SHIPPING_LINE":
case "PIL_EXTRA_FEE":
return ["PER_CONTAINER", "FLAT"];
default:
return ["FLAT", "PER_TON", "PER_CONTAINER"];
}
}
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"];
}
};
const rateUnitOptions = (values: Record<string, unknown>) => {
const appliesTo = String(values.appliesTo ?? "");
const trigger = appliesTo === "OTHER" ? String(values.trigger ?? "") : "ALWAYS";
if (!appliesTo) return [];
return allowedRateUnits(appliesTo, trigger).map(unitOption);
};
const CURRENCIES = [
{ label: "USD", value: "USD" },
@@ -324,8 +374,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
},
{ id: "tradeDirection", header: "Direction", accessorKey: "tradeDirection" },
{ id: "maxVgmTons", header: "Max VGM (t)", accessorKey: "maxVgmTons", format: "number" },
{ id: "effectiveFrom", header: "From", accessorKey: "effectiveFrom", format: "date" },
{ id: "effectiveTo", header: "To", accessorKey: "effectiveTo", format: "date" },
],
formFields: [
{
@@ -343,8 +391,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
options: TRADE_DIRECTIONS,
},
{ name: "maxVgmTons", label: "Max VGM (tons)", type: "number", required: true },
{ name: "effectiveFrom", label: "Effective from", type: "date", required: true },
{ name: "effectiveTo", label: "Effective to", type: "date" },
],
},
{
@@ -407,7 +453,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ id: "rateValue", header: "Value", accessorKey: "rateValue", format: "currency" },
{ id: "rateUnit", header: "Unit", accessorKey: "rateUnit" },
{ id: "status", header: "Status", accessorKey: "status", format: "rateStatus" },
{ id: "effectiveFrom", header: "From", accessorKey: "effectiveFrom", format: "date" },
],
formFields: [
{
@@ -457,9 +502,18 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
showWhen: { field: "appliesTo", equals: ["BULK", "INTERCITY"] },
},
{ name: "rateValue", label: "Rate value", type: "number", required: true, suffix: "USD" },
{ name: "rateUnit", label: "Rate unit", type: "select", required: true, options: RATE_UNITS },
{ name: "effectiveFrom", label: "Effective from", type: "date", required: true },
{ name: "effectiveTo", label: "Effective to", type: "date" },
// Unit choices are driven by the rate shape (appliesTo + trigger). Overweight
// is always per excess ton, so the unit field is hidden for it — the API
// forces PER_TON regardless.
{
name: "rateUnit",
label: "Rate unit",
type: "select",
required: true,
optionsFromValues: rateUnitOptions,
description: "Weighting basis — options depend on what the rate applies to.",
hideWhen: { field: "trigger", equals: ["OVERWEIGHT"] },
},
],
},
{

View File

@@ -15,8 +15,6 @@ export interface Rate {
proposedByStaffId: string;
approvedByCeoId: string | null;
approvedAt: string | null;
effectiveFrom: string;
effectiveTo: string | null;
createdAt: string;
updatedAt: string;
deletedAt: string | null;