mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 04:08:11 +00:00
Merge branch 'freight_feature/profile' of github.com:Tria-plc/edr-platform into freight_feature/profile
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Staff price adjustment: an optional override of a booking's computed total,
|
||||
* with who/when/why. When set, the customer sees the adjusted total + a badge.
|
||||
*/
|
||||
export class AddPriceAdjustment1820000000003 implements MigrationInterface {
|
||||
name = 'AddPriceAdjustment1820000000003';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS adjusted_total_amount numeric(14,2);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS adjusted_by_staff_id uuid;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS adjusted_at timestamptz;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS adjustment_reason text;`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS adjustment_reason;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS adjusted_at;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS adjusted_by_staff_id;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS adjusted_total_amount;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Fold the `surcharge_types` table into self-describing rates.
|
||||
*
|
||||
* Previously a surcharge was a separate row {trigger_condition, rate_id}. Now
|
||||
* each rate carries its own `applies_to` (friendly category) and `trigger`
|
||||
* (ALWAYS = base freight, otherwise a surcharge condition), plus an optional
|
||||
* `cargo_type_id` for bulk leaf commodities. The rule engine reads triggers
|
||||
* directly off LIVE rates, so the join table is no longer needed.
|
||||
*
|
||||
* This migration:
|
||||
* 1. adds applies_to / trigger / cargo_type_id to rates and backfills them
|
||||
* from the existing rate_type matrix,
|
||||
* 2. repoints booking_cargo_modifier from surcharge_type_id → rate_id
|
||||
* (backfilled via surcharge_types.rate_id),
|
||||
* 3. drops surcharge_types and its FK.
|
||||
*/
|
||||
export class FoldSurchargeTypesIntoRates1820000000004 implements MigrationInterface {
|
||||
name = 'FoldSurchargeTypesIntoRates1820000000004';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// ── 1. New rate columns ────────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates
|
||||
ADD COLUMN IF NOT EXISTS applies_to varchar(20) NOT NULL DEFAULT 'OTHER',
|
||||
ADD COLUMN IF NOT EXISTS "trigger" varchar(20) NOT NULL DEFAULT 'ALWAYS',
|
||||
ADD COLUMN IF NOT EXISTS cargo_type_id uuid NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates
|
||||
ADD CONSTRAINT "FK_rates_cargo_type_id"
|
||||
FOREIGN KEY (cargo_type_id) REFERENCES freight.cargo_types(id)
|
||||
ON DELETE SET NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_rates_trigger" ON freight.rates ("trigger");`,
|
||||
);
|
||||
|
||||
// ── 1a. Backfill applies_to from the legacy rate_type matrix ────────────
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.rates SET applies_to = CASE
|
||||
WHEN rate_type IN ('CONTAINER_IMPORT','CONTAINER_EXPORT','CONTAINER_WITH_RETURN') THEN 'CONTAINER'
|
||||
WHEN rate_type IN ('BULK_IMPORT','BULK_EXPORT') THEN 'BULK'
|
||||
WHEN rate_type IN ('INTERCITY_CONTAINER','INTERCITY_BULK') THEN 'INTERCITY'
|
||||
WHEN rate_type = 'FIRST_MILE' THEN 'FIRST_MILE'
|
||||
WHEN rate_type = 'LAST_MILE' THEN 'LAST_MILE'
|
||||
ELSE 'OTHER'
|
||||
END;
|
||||
`);
|
||||
|
||||
// ── 1b. Backfill trigger from the legacy rate_type matrix ───────────────
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.rates SET "trigger" = CASE
|
||||
WHEN rate_type = 'HAZARD_SURCHARGE' THEN 'HAZARDOUS'
|
||||
WHEN rate_type = 'REEFER_SURCHARGE' THEN 'REEFER'
|
||||
WHEN rate_type = 'OVERWEIGHT_PER_TON' THEN 'OVERWEIGHT'
|
||||
WHEN rate_type = 'DOUBLE_HANDLING' THEN 'SHIPPING_LINE'
|
||||
WHEN rate_type = 'LASHING' THEN 'CONSOLIDATION'
|
||||
WHEN rate_type = 'CANCELLATION_FEE' THEN 'CANCELLATION'
|
||||
WHEN rate_type = 'DEMURRAGE' THEN 'DEMURRAGE'
|
||||
WHEN rate_type = 'PIL_EXTRA_FEE' THEN 'PIL_EXTRA_FEE'
|
||||
ELSE 'ALWAYS'
|
||||
END;
|
||||
`);
|
||||
|
||||
// Align the trigger to the actual surcharge_types mapping where one exists
|
||||
// (covers any rate wired as a surcharge with a non-obvious rate_type).
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.rates r SET "trigger" = m.trig
|
||||
FROM (
|
||||
SELECT st.rate_id, CASE st.trigger_condition
|
||||
WHEN 'CARGO_FLAG_HAZARDOUS' THEN 'HAZARDOUS'
|
||||
WHEN 'CARGO_FLAG_REEFER' THEN 'REEFER'
|
||||
WHEN 'VGM_EXCEEDS_LIMIT' THEN 'OVERWEIGHT'
|
||||
WHEN 'SHIPPING_LINE_MAPPED' THEN 'SHIPPING_LINE'
|
||||
WHEN 'CONSOLIDATION_ENABLED' THEN 'CONSOLIDATION'
|
||||
ELSE 'ALWAYS'
|
||||
END AS trig
|
||||
FROM freight.surcharge_types st
|
||||
WHERE st.rate_id IS NOT NULL AND st.deleted_at IS NULL
|
||||
) m
|
||||
WHERE r.id = m.rate_id AND m.trig <> 'ALWAYS';
|
||||
`);
|
||||
|
||||
// ── 2. Repoint booking_cargo_modifier to rate_id ────────────────────────
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_cargo_modifier
|
||||
ADD COLUMN IF NOT EXISTS rate_id uuid NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.booking_cargo_modifier bcm
|
||||
SET rate_id = st.rate_id
|
||||
FROM freight.surcharge_types st
|
||||
WHERE bcm.surcharge_type_id = st.id AND st.rate_id IS NOT NULL;
|
||||
`);
|
||||
|
||||
// Rows whose surcharge lost its rate can't be repointed — they reference a
|
||||
// now-defunct surcharge. Remove them so the NOT NULL + FK can be enforced.
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.booking_cargo_modifier WHERE rate_id IS NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_cargo_modifier
|
||||
ALTER COLUMN rate_id SET NOT NULL;
|
||||
`);
|
||||
|
||||
// Drop the old FK + column + index for surcharge_type_id.
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_cargo_modifier
|
||||
DROP CONSTRAINT IF EXISTS "FK_booking_cargo_modifier_surcharge_type_id";
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight."IDX_booking_cargo_modifier_surcharge_type_id";`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_cargo_modifier
|
||||
DROP COLUMN IF EXISTS surcharge_type_id;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_cargo_modifier
|
||||
ADD CONSTRAINT "FK_booking_cargo_modifier_rate_id"
|
||||
FOREIGN KEY (rate_id) REFERENCES freight.rates(id);
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_booking_cargo_modifier_rate_id" ON freight.booking_cargo_modifier (rate_id);`,
|
||||
);
|
||||
|
||||
// ── 3. Drop the surcharge_types table ───────────────────────────────────
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.surcharge_types;`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// Recreate surcharge_types (structure only — data is not restored).
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.surcharge_types (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code varchar(40) NOT NULL,
|
||||
label varchar(100),
|
||||
trigger_condition varchar(50),
|
||||
rate_id uuid,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_surcharge_types_code" ON freight.surcharge_types (code);`,
|
||||
);
|
||||
|
||||
// Restore booking_cargo_modifier.surcharge_type_id (nullable; not backfilled).
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_cargo_modifier
|
||||
DROP CONSTRAINT IF EXISTS "FK_booking_cargo_modifier_rate_id";
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight."IDX_booking_cargo_modifier_rate_id";`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_cargo_modifier
|
||||
ADD COLUMN IF NOT EXISTS surcharge_type_id uuid NULL;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_cargo_modifier DROP COLUMN IF EXISTS rate_id;
|
||||
`);
|
||||
|
||||
// Drop the new rate columns.
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight."IDX_rates_trigger";`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "FK_rates_cargo_type_id";
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates
|
||||
DROP COLUMN IF EXISTS cargo_type_id,
|
||||
DROP COLUMN IF EXISTS "trigger",
|
||||
DROP COLUMN IF EXISTS applies_to;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -20,12 +20,13 @@ import { DEFAULT_APPROVAL_RULE_ROWS } from '../modules/rule-engine/approval-rule
|
||||
const EDR_ORG_KEY = 'edr_freight';
|
||||
const MIN_WAGONS_PER_TYPE = 100;
|
||||
|
||||
/** The four demo staff users, each mapped to a seeded freight role. */
|
||||
/** The demo staff users, each mapped to a seeded freight role. */
|
||||
const DEMO_STAFF_USERS = [
|
||||
{ email: 'marketing@edr.local', username: 'marketing', roleKey: 'edr_marketing' },
|
||||
{ email: 'operations@edr.local', username: 'operations', roleKey: 'edr_operations_officer' },
|
||||
{ email: 'director@edr.local', username: 'director', roleKey: 'edr_director' },
|
||||
{ email: 'ceo@edr.local', username: 'ceo', roleKey: 'edr_ceo' },
|
||||
{ email: 'gl@edr.local', username: 'gl', roleKey: 'edr_global_logistics' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -237,6 +237,11 @@ export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [
|
||||
name: { en: "EDR Marketing" },
|
||||
permissionKeys: [...ROLE_PERMISSION_PRESETS.marketing],
|
||||
},
|
||||
{
|
||||
key: "edr_global_logistics",
|
||||
name: { en: "EDR Global Logistics" },
|
||||
permissionKeys: [...ROLE_PERMISSION_PRESETS.globalLogistics],
|
||||
},
|
||||
{
|
||||
key: "edr_org_manager",
|
||||
name: { en: "EDR Org Manager" },
|
||||
|
||||
@@ -15,7 +15,6 @@ export const RULE_ENGINE_RESOURCE_SLUGS = [
|
||||
'yards',
|
||||
'shipping-lines',
|
||||
'weight-limit-rules',
|
||||
'surcharge-types',
|
||||
'priority-configs',
|
||||
'rates',
|
||||
'approval-rules',
|
||||
@@ -70,7 +69,6 @@ const RULE_ENGINE_PERMISSION_IDS: Record<RuleEngineResourceSlug, { view: string;
|
||||
yards: { view: 'b2000001-0001-4000-8000-000000000007', manage: 'b2000001-0001-4000-8000-000000000008' },
|
||||
'shipping-lines': { view: 'b2000001-0001-4000-8000-000000000009', manage: 'b2000001-0001-4000-8000-00000000000a' },
|
||||
'weight-limit-rules': { view: 'b2000001-0001-4000-8000-00000000000b', manage: 'b2000001-0001-4000-8000-00000000000c' },
|
||||
'surcharge-types': { view: 'b2000001-0001-4000-8000-00000000000d', manage: 'b2000001-0001-4000-8000-00000000000e' },
|
||||
'priority-configs': { view: 'b2000001-0001-4000-8000-00000000000f', manage: 'b2000001-0001-4000-8000-000000000010' },
|
||||
rates: { view: 'b2000001-0001-4000-8000-000000000011', manage: 'b2000001-0001-4000-8000-000000000012' },
|
||||
'approval-rules': { view: 'b2000001-0001-4000-8000-000000000013', manage: 'b2000001-0001-4000-8000-000000000014' },
|
||||
|
||||
@@ -18,6 +18,7 @@ const STAFF_USERS = [
|
||||
{ email: 'linestaff@edr.local', username: 'linestaff', roleKey: 'edr_line_staff' },
|
||||
{ email: 'director@edr.local', username: 'director', roleKey: 'edr_director' },
|
||||
{ email: 'ceo@edr.local', username: 'ceo', roleKey: 'edr_ceo' },
|
||||
{ email: 'gl@edr.local', username: 'gl', roleKey: 'edr_global_logistics' },
|
||||
] as const;
|
||||
|
||||
@Injectable()
|
||||
@@ -122,6 +123,8 @@ export class FreightStaffUsersSeeder {
|
||||
}
|
||||
});
|
||||
|
||||
this.logger.log('Ensured freight staff users (linestaff@, director@, ceo@)');
|
||||
this.logger.log(
|
||||
'Ensured freight staff users (linestaff@, director@, ceo@, gl@)',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { PriorityConfig } from "../modules/rule-engine/entities/priority-config.
|
||||
import { Rate } from "../modules/rule-engine/entities/rate.entity";
|
||||
import { ServiceType } from "../modules/rule-engine/entities/service-type.entity";
|
||||
import { ShippingLine } from "../modules/rule-engine/entities/shipping-line.entity";
|
||||
import { SurchargeType } from "../modules/rule-engine/entities/surcharge-type.entity";
|
||||
import { WeightLimitRule } from "../modules/rule-engine/entities/weight-limit-rule.entity";
|
||||
import { Route } from "../modules/routes/entities/route.entity";
|
||||
import { RouteMilestone } from "../modules/routes/entities/route-milestone.entity";
|
||||
@@ -40,15 +39,13 @@ export class PricingDataSeeder {
|
||||
const containerTypes = await ctRepo.find();
|
||||
const ctByCode = new Map(containerTypes.map((ct) => [ct.code, ct]));
|
||||
|
||||
const rates = await this.seedRates(rRepo, ctByCode);
|
||||
const ratesByType = new Map<string, Rate[]>();
|
||||
for (const r of rates) {
|
||||
const key = `${r.rateType}|${r.currency}|${r.containerTypeId ?? ""}`;
|
||||
if (!ratesByType.has(key)) ratesByType.set(key, []);
|
||||
ratesByType.get(key)!.push(r);
|
||||
}
|
||||
// Clear booking cargo modifiers up front — they reference rate snapshots
|
||||
// that get recomputed when bookings are repriced.
|
||||
await manager.getRepository(BookingCargoModifier).createQueryBuilder().delete().execute();
|
||||
|
||||
await this.seedSurchargeTypes(manager, ratesByType);
|
||||
const cargoTypesForRates = await manager.getRepository(CargoType).find();
|
||||
const cargoForRatesByCode = new Map(cargoTypesForRates.map((c) => [c.code, c]));
|
||||
await this.seedRates(rRepo, ctByCode, cargoForRatesByCode);
|
||||
|
||||
const yards = await yRepo.find();
|
||||
const yardByCode = new Map(yards.map((y) => [y.code, y]));
|
||||
@@ -422,135 +419,43 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
||||
private async seedRates(
|
||||
rRepo: any,
|
||||
ctByCode: Map<string, any>,
|
||||
cargoByCode: Map<string, any>,
|
||||
): Promise<Rate[]> {
|
||||
const effectiveFrom = new Date("2026-01-01");
|
||||
const now = new Date();
|
||||
// await rRepo.createQueryBuilder().delete().execute();
|
||||
|
||||
// Each rate is self-describing: `appliesTo` + `trigger` decide how the
|
||||
// engine uses it. trigger=ALWAYS → base freight; anything else → a
|
||||
// surcharge that stacks additively when the booking matches.
|
||||
const rateData = [
|
||||
{
|
||||
rateType: "CONTAINER_IMPORT",
|
||||
containerTypeId: ctByCode.get("20FT")!.id,
|
||||
currency: "USD",
|
||||
rateValue: 800,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "CONTAINER_IMPORT",
|
||||
containerTypeId: ctByCode.get("40FT")!.id,
|
||||
currency: "USD",
|
||||
rateValue: 1200,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "CONTAINER_EXPORT",
|
||||
containerTypeId: ctByCode.get("20FT")!.id,
|
||||
currency: "USD",
|
||||
rateValue: 600,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "CONTAINER_EXPORT",
|
||||
containerTypeId: ctByCode.get("40FT")!.id,
|
||||
currency: "USD",
|
||||
rateValue: 900,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "CONTAINER_IMPORT",
|
||||
containerTypeId: null,
|
||||
currency: "USD",
|
||||
rateValue: 1000,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "CONTAINER_EXPORT",
|
||||
containerTypeId: null,
|
||||
currency: "USD",
|
||||
rateValue: 750,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "INTERCITY_CONTAINER",
|
||||
containerTypeId: ctByCode.get("20FT")!.id,
|
||||
currency: "USD",
|
||||
rateValue: 350,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "INTERCITY_CONTAINER",
|
||||
containerTypeId: ctByCode.get("40FT")!.id,
|
||||
currency: "USD",
|
||||
rateValue: 550,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "INTERCITY_CONTAINER",
|
||||
containerTypeId: null,
|
||||
currency: "USD",
|
||||
rateValue: 400,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "INTERCITY_BULK",
|
||||
containerTypeId: null,
|
||||
currency: "USD",
|
||||
rateValue: 35,
|
||||
rateUnit: "PER_TON",
|
||||
},
|
||||
{
|
||||
rateType: "BULK_IMPORT",
|
||||
containerTypeId: null,
|
||||
currency: "USD",
|
||||
rateValue: 50,
|
||||
rateUnit: "PER_TON",
|
||||
},
|
||||
{
|
||||
rateType: "BULK_EXPORT",
|
||||
containerTypeId: null,
|
||||
currency: "USD",
|
||||
rateValue: 40,
|
||||
rateUnit: "PER_TON",
|
||||
},
|
||||
{
|
||||
rateType: "OVERWEIGHT_PER_TON",
|
||||
containerTypeId: null,
|
||||
currency: "USD",
|
||||
rateValue: 25,
|
||||
rateUnit: "PER_TON",
|
||||
},
|
||||
{
|
||||
rateType: "HAZARD_SURCHARGE",
|
||||
containerTypeId: null,
|
||||
currency: "USD",
|
||||
rateValue: 150,
|
||||
rateUnit: "FLAT",
|
||||
},
|
||||
{
|
||||
rateType: "REEFER_SURCHARGE",
|
||||
containerTypeId: null,
|
||||
currency: "USD",
|
||||
rateValue: 200,
|
||||
rateUnit: "FLAT",
|
||||
},
|
||||
{
|
||||
rateType: "DOUBLE_HANDLING",
|
||||
containerTypeId: null,
|
||||
currency: "USD",
|
||||
rateValue: 100,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
{
|
||||
rateType: "LASHING",
|
||||
containerTypeId: null,
|
||||
currency: "USD",
|
||||
rateValue: 50,
|
||||
rateUnit: "PER_CONTAINER",
|
||||
},
|
||||
// ── Container base freight ──────────────────────────────────────────
|
||||
{ appliesTo: "CONTAINER", trigger: "ALWAYS", rateType: "CONTAINER_IMPORT", tradeDirection: "IMPORT", containerTypeId: ctByCode.get("20FT")!.id, rateValue: 800, rateUnit: "PER_CONTAINER" },
|
||||
{ appliesTo: "CONTAINER", trigger: "ALWAYS", rateType: "CONTAINER_IMPORT", tradeDirection: "IMPORT", containerTypeId: ctByCode.get("40FT")!.id, rateValue: 1200, rateUnit: "PER_CONTAINER" },
|
||||
{ appliesTo: "CONTAINER", trigger: "ALWAYS", rateType: "CONTAINER_EXPORT", tradeDirection: "EXPORT", containerTypeId: ctByCode.get("20FT")!.id, rateValue: 600, rateUnit: "PER_CONTAINER" },
|
||||
{ appliesTo: "CONTAINER", trigger: "ALWAYS", rateType: "CONTAINER_EXPORT", tradeDirection: "EXPORT", containerTypeId: ctByCode.get("40FT")!.id, rateValue: 900, rateUnit: "PER_CONTAINER" },
|
||||
{ appliesTo: "CONTAINER", trigger: "ALWAYS", rateType: "CONTAINER_IMPORT", tradeDirection: "IMPORT", containerTypeId: null, rateValue: 1000, rateUnit: "PER_CONTAINER" },
|
||||
{ appliesTo: "CONTAINER", trigger: "ALWAYS", rateType: "CONTAINER_EXPORT", tradeDirection: "EXPORT", containerTypeId: null, rateValue: 750, rateUnit: "PER_CONTAINER" },
|
||||
// ── Intercity base freight ──────────────────────────────────────────
|
||||
{ appliesTo: "INTERCITY", trigger: "ALWAYS", rateType: "INTERCITY_CONTAINER", containerTypeId: ctByCode.get("20FT")!.id, rateValue: 350, rateUnit: "PER_CONTAINER" },
|
||||
{ appliesTo: "INTERCITY", trigger: "ALWAYS", rateType: "INTERCITY_CONTAINER", containerTypeId: ctByCode.get("40FT")!.id, rateValue: 550, rateUnit: "PER_CONTAINER" },
|
||||
{ appliesTo: "INTERCITY", trigger: "ALWAYS", rateType: "INTERCITY_CONTAINER", containerTypeId: null, rateValue: 400, rateUnit: "PER_CONTAINER" },
|
||||
{ appliesTo: "INTERCITY", trigger: "ALWAYS", rateType: "INTERCITY_BULK", rateValue: 35, rateUnit: "PER_TON" },
|
||||
// ── Bulk base freight (by leaf cargo type where known) ──────────────
|
||||
{ appliesTo: "BULK", trigger: "ALWAYS", rateType: "BULK_IMPORT", tradeDirection: "IMPORT", cargoTypeId: cargoByCode.get("GRAIN")?.id ?? null, rateValue: 50, rateUnit: "PER_TON" },
|
||||
{ appliesTo: "BULK", trigger: "ALWAYS", rateType: "BULK_EXPORT", tradeDirection: "EXPORT", cargoTypeId: cargoByCode.get("GRAIN")?.id ?? null, rateValue: 40, rateUnit: "PER_TON" },
|
||||
{ appliesTo: "BULK", trigger: "ALWAYS", rateType: "BULK_IMPORT", tradeDirection: "IMPORT", cargoTypeId: null, rateValue: 50, rateUnit: "PER_TON" },
|
||||
{ appliesTo: "BULK", trigger: "ALWAYS", rateType: "BULK_EXPORT", tradeDirection: "EXPORT", cargoTypeId: null, rateValue: 40, rateUnit: "PER_TON" },
|
||||
// ── Surcharges (trigger-based) ──────────────────────────────────────
|
||||
{ appliesTo: "OTHER", trigger: "OVERWEIGHT", rateType: "OVERWEIGHT_PER_TON", rateValue: 25, rateUnit: "PER_TON" },
|
||||
{ appliesTo: "OTHER", trigger: "HAZARDOUS", rateType: "HAZARD_SURCHARGE", rateValue: 150, rateUnit: "FLAT" },
|
||||
{ appliesTo: "OTHER", trigger: "REEFER", rateType: "REEFER_SURCHARGE", rateValue: 200, rateUnit: "FLAT" },
|
||||
{ appliesTo: "OTHER", trigger: "SHIPPING_LINE", rateType: "DOUBLE_HANDLING", rateValue: 100, rateUnit: "PER_CONTAINER" },
|
||||
{ appliesTo: "OTHER", trigger: "CONSOLIDATION", rateType: "LASHING", rateValue: 50, rateUnit: "PER_CONTAINER" },
|
||||
];
|
||||
|
||||
const entities = rateData.map((d) =>
|
||||
rRepo.create({
|
||||
currency: "USD",
|
||||
...d,
|
||||
status: "LIVE",
|
||||
proposedByStaffId: STAFF_USER_ID,
|
||||
@@ -562,66 +467,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
||||
return rRepo.save(entities);
|
||||
}
|
||||
|
||||
private async seedSurchargeTypes(
|
||||
manager: any,
|
||||
ratesByType: Map<string, Rate[]>,
|
||||
): Promise<void> {
|
||||
const surRepo = manager.getRepository(SurchargeType);
|
||||
const bcmRepo = manager.getRepository(BookingCargoModifier);
|
||||
await bcmRepo.createQueryBuilder().delete().execute();
|
||||
const findRate = (rateType: string, currency: string) => {
|
||||
const key = `${rateType}|${currency}|`;
|
||||
const rates = ratesByType.get(key);
|
||||
return rates?.[0];
|
||||
};
|
||||
|
||||
const hazardRateUsd = findRate("HAZARD_SURCHARGE", "USD");
|
||||
const reeferRateUsd = findRate("REEFER_SURCHARGE", "USD");
|
||||
const overweightRateUsd = findRate("OVERWEIGHT_PER_TON", "USD");
|
||||
const shipLineRateUsd = findRate("DOUBLE_HANDLING", "USD");
|
||||
const consolidRateUsd = findRate("LASHING", "USD");
|
||||
|
||||
await surRepo.createQueryBuilder().delete().execute();
|
||||
await surRepo.save([
|
||||
surRepo.create({
|
||||
code: "HAZARDOUS_CARGO",
|
||||
label: "Hazardous Cargo",
|
||||
triggerCondition: "CARGO_FLAG_HAZARDOUS",
|
||||
rateId: hazardRateUsd?.id,
|
||||
isActive: true,
|
||||
}),
|
||||
surRepo.create({
|
||||
code: "REEFER_CARGO",
|
||||
label: "Reefer Cargo",
|
||||
triggerCondition: "CARGO_FLAG_REEFER",
|
||||
rateId: reeferRateUsd?.id,
|
||||
isActive: true,
|
||||
}),
|
||||
surRepo.create({
|
||||
code: "OVERWEIGHT_CARGO",
|
||||
label: "Overweight Cargo",
|
||||
triggerCondition: "VGM_EXCEEDS_LIMIT",
|
||||
rateId: overweightRateUsd?.id,
|
||||
isActive: true,
|
||||
}),
|
||||
surRepo.create({
|
||||
code: "SHIPPING_LINE_FEE",
|
||||
label: "Shipping Line Fee",
|
||||
triggerCondition: "SHIPPING_LINE_MAPPED",
|
||||
rateId: shipLineRateUsd?.id,
|
||||
isActive: true,
|
||||
}),
|
||||
surRepo.create({
|
||||
code: "CONSOLIDATION_FEE",
|
||||
label: "Consolidation Fee",
|
||||
triggerCondition: "CONSOLIDATION_ENABLED",
|
||||
rateId: consolidRateUsd?.id,
|
||||
isActive: true,
|
||||
}),
|
||||
]);
|
||||
this.logger.log("Seeded surcharge types");
|
||||
}
|
||||
|
||||
private async seedDraftBookings(
|
||||
ctByCode: Map<string, any>,
|
||||
yardByCode: Map<string, any>,
|
||||
|
||||
Reference in New Issue
Block a user