Merge branch 'freight_feature/profile' of github.com:Tria-plc/edr-platform into freight_feature/profile

This commit is contained in:
marshal
2026-06-24 02:49:24 +03:00
41 changed files with 994 additions and 709 deletions

View File

@@ -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,
@@ -252,7 +255,8 @@ export class BookingPricingService {
serviceTypeId: booking.serviceTypeId,
paymentCurrency: booking.paymentCurrency,
tradeDirection: booking.tradeDirection,
isHazardous: booking.isHazardous,
// Coerce defensively in case the stored flag is a string ("true"/"false").
isHazardous: booking.isHazardous === true || (booking.isHazardous as unknown) === 'true',
isGovernment: booking.isGovernment,
allowConsolidation,
shippingLineId: booking.shippingLineId,

View File

@@ -451,6 +451,30 @@ export class BookingTransitionService {
return this.bookingsService.findById(updated!.id);
}
/**
* Staff adjusts a booking's total price. Stores an override (with who/when/why)
* that supersedes the computed total for the customer, who sees an
* "Adjusted by EDR" badge. Passing null clears the adjustment.
*/
async adjustPrice(
bookingId: string,
amount: number | null,
staffId: string,
reason?: string,
): Promise<Booking> {
await this.bookingsService.findById(bookingId);
if (amount != null && amount < 0) {
throw new BadRequestException('Adjusted amount cannot be negative');
}
await this.bookingsRepository.update(bookingId, {
adjustedTotalAmount: amount,
adjustedByStaffId: amount == null ? null : staffId,
adjustedAt: amount == null ? null : new Date(),
adjustmentReason: amount == null ? null : (reason ?? null),
} as never);
return this.bookingsService.findById(bookingId);
}
// ── Document clearance gate (post counter-sign) ───────────────────────────
/**

View File

@@ -42,6 +42,7 @@ import { FilterBookingDto } from './dto/filter-booking.dto';
import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
import {
AdjustPriceDto,
ApproveStepDto,
CancelBookingDto,
RejectBookingDto,
@@ -455,6 +456,25 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/adjust-price')
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
@ApiOperation({
summary: 'Staff adjust booking total price (override; null clears it)',
})
async adjustPrice(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AdjustPriceDto,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.adjustPrice(
id,
dto.amount ?? null,
resolveAuthUserId(user),
dto.reason,
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/government-expedite')
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
@ApiOperation({ summary: 'Expedite government booking to PAID / ELIGIBLE for scheduling' })

View File

@@ -397,7 +397,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
async createCargoModifiers(
rows: Array<{
bookingId: string;
surchargeTypeId: string;
rateId: string;
triggerValue: number | null;
calculatedAmount: number;
rateSnapshotId: string;

View File

@@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional, IsString, MinLength } from 'class-validator';
import { IsIn, IsNumber, IsOptional, IsString, Min, MinLength } from 'class-validator';
export class RequestChangesDto {
@ApiProperty({ description: 'Staff note explaining what the customer must fix' })
@@ -44,6 +44,22 @@ export class RejectBookingDto {
reason?: string;
}
export class AdjustPriceDto {
@ApiPropertyOptional({
description:
'New total price. Omit or send null to clear a previous adjustment.',
})
@IsOptional()
@IsNumber()
@Min(0)
amount?: number | null;
@ApiPropertyOptional({ description: 'Reason for the adjustment' })
@IsOptional()
@IsString()
reason?: string;
}
export class ReviewDocumentDto {
@ApiProperty({ description: 'The document fileKey being reviewed' })
@IsString()

View File

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

View File

@@ -161,6 +161,22 @@ export class Booking extends BaseEntity {
@Column({ name: 'total_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
totalAmount!: number;
/**
* Staff-adjusted total price. When set, it overrides the computed totalAmount
* for the customer, who is shown an "Adjusted by EDR" badge.
*/
@Column({ name: 'adjusted_total_amount', type: 'numeric', precision: 14, scale: 2, nullable: true })
adjustedTotalAmount?: number | null;
@Column({ name: 'adjusted_by_staff_id', type: 'uuid', nullable: true })
adjustedByStaffId?: string | null;
@Column({ name: 'adjusted_at', type: 'timestamptz', nullable: true })
adjustedAt?: Date | null;
@Column({ name: 'adjustment_reason', type: 'text', nullable: true })
adjustmentReason?: string | null;
@Column({ name: 'payment_status', type: 'varchar', length: 20, default: 'PENDING' })
paymentStatus!: string;

View File

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

View File

@@ -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()

View File

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

View File

@@ -1,4 +0,0 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateSurchargeTypeDto } from './create-surcharge-type.dto';
export class UpdateSurchargeTypeDto extends PartialType(CreateSurchargeTypeDto) {}

View File

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

View File

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

View File

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

View File

@@ -1,15 +0,0 @@
import { FindManyOptions } from 'typeorm';
import { SurchargeType } from '../entities/surcharge-type.entity';
export interface ISurchargeTypesRepository {
findById(id: string): Promise<SurchargeType | null>;
findByCode(code: string): Promise<SurchargeType | null>;
findAllActiveWithRate(): Promise<SurchargeType[]>;
findAll(options?: FindManyOptions<SurchargeType>): Promise<SurchargeType[]>;
findAndCount(options?: FindManyOptions<SurchargeType>): Promise<[SurchargeType[], number]>;
create(data: Partial<SurchargeType>): Promise<SurchargeType>;
update(id: string, data: Partial<SurchargeType>): Promise<SurchargeType | null>;
softDelete(id: string): Promise<void>;
}
export const SURCHARGE_TYPES_REPOSITORY = Symbol('SURCHARGE_TYPES_REPOSITORY');

View File

@@ -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<SurchargeType>;
constructor(private readonly dataSource: DataSource) {
this.repo = this.dataSource.getRepository(SurchargeType);
}
findById(id: string): Promise<SurchargeType | null> {
return this.repo.findOne({ where: { id } });
}
findByCode(code: string): Promise<SurchargeType | null> {
return this.repo.findOne({ where: { code } });
}
findAllActiveWithRate(): Promise<SurchargeType[]> {
return this.repo.find({
where: { isActive: true },
relations: { rate: true },
});
}
findAll(options?: FindManyOptions<SurchargeType>): Promise<SurchargeType[]> {
return this.repo.find(options);
}
findAndCount(options?: FindManyOptions<SurchargeType>): Promise<[SurchargeType[], number]> {
return this.repo.findAndCount(options);
}
async create(data: Partial<SurchargeType>): Promise<SurchargeType> {
const entity = this.repo.create(data);
return this.repo.save(entity);
}
async update(id: string, data: Partial<SurchargeType>): Promise<SurchargeType | null> {
await this.repo.update(id, data);
return this.findById(id);
}
async softDelete(id: string): Promise<void> {
await this.repo.softDelete(id);
}
}

View File

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

View File

@@ -2,7 +2,7 @@ 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;
}
@@ -98,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)
@@ -203,12 +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');
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,
@@ -217,28 +214,28 @@ 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
// zero-rate or zero-trigger line would otherwise show as a confusing
// "free" surcharge on the breakdown).
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,
});
}
@@ -373,7 +370,7 @@ export class RuleEngineService {
}
private matchesTrigger(
condition: TriggerCondition,
trigger: RateTrigger,
state: {
isHazardous: boolean;
hasReefer: boolean;
@@ -382,19 +379,29 @@ export class RuleEngineService {
allowConsolidation: boolean;
},
): boolean {
switch (condition) {
case 'CARGO_FLAG_HAZARDOUS':
return state.isHazardous;
case 'CARGO_FLAG_REEFER':
return state.hasReefer;
case 'VGM_EXCEEDS_LIMIT':
return state.hasOverweight;
case 'SHIPPING_LINE_MAPPED':
return state.shippingLineMapped;
case 'CONSOLIDATION_ENABLED':
return state.allowConsolidation;
// 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 (trigger) {
case 'HAZARDOUS':
return truthy(state.isHazardous);
case 'REEFER':
return truthy(state.hasReefer);
case 'OVERWEIGHT':
return truthy(state.hasOverweight);
case 'SHIPPING_LINE':
return truthy(state.shippingLineMapped);
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;
}
}

View File

@@ -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<Rate> {
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<Rate> = {};
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'];

View File

@@ -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<string, unknown> = {};
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<SurchargeType> {
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<SurchargeType> {
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<SurchargeType> {
await this.findById(id);
const patch: Partial<SurchargeType> = {};
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<void> {
await this.findById(id);
await this.repository.softDelete(id);
}
}