mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-02 15:03:39 +00:00
Merge branch 'freight_feature/profile' of github.com:Tria-plc/edr-platform into freight_feature/profile
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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) ───────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateSurchargeTypeDto } from './create-surcharge-type.dto';
|
||||
|
||||
export class UpdateSurchargeTypeDto extends PartialType(CreateSurchargeTypeDto) {}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user