mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
fix issues
This commit is contained in:
@@ -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<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, '')
|
||||
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<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);`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,12 @@ import { Rate } from '../entities/rate.entity';
|
||||
export interface IRatesRepository {
|
||||
findById(id: string): Promise<Rate | null>;
|
||||
findLiveRates(): Promise<Rate[]>;
|
||||
findByPattern(pattern: {
|
||||
rateType: 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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -16,15 +16,48 @@ 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;
|
||||
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.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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<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 +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;
|
||||
|
||||
Reference in New Issue
Block a user