mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +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>,
|
||||
|
||||
@@ -1,50 +1,171 @@
|
||||
import { Banknote, Receipt } from "lucide-react";
|
||||
import { Paper, Stack, Group, Text, Divider } from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
import { Banknote, Pencil, Receipt } from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
|
||||
import { SectionCard } from "./detail/SectionCard";
|
||||
import { detailStyles } from "./detail/booking-detail.styles";
|
||||
|
||||
export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
|
||||
const amount = Number(booking.totalAmount);
|
||||
const modifiers = booking.cargoModifiers ?? [];
|
||||
const qc = useQueryClient();
|
||||
const computed = Number(booking.totalAmount);
|
||||
const isAdjusted =
|
||||
booking.adjustedTotalAmount !== null &&
|
||||
booking.adjustedTotalAmount !== undefined;
|
||||
const effective = isAdjusted ? Number(booking.adjustedTotalAmount) : computed;
|
||||
|
||||
const lineItems = booking.pricingBreakdown?.lineItems ?? [];
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [amount, setAmount] = useState<number | "">(effective);
|
||||
const [reason, setReason] = useState("");
|
||||
|
||||
const adjustMutation = useMutation({
|
||||
mutationFn: (payload: { amount: number | null; reason?: string }) =>
|
||||
bookingsService.adjustPrice(booking.id, payload.amount, payload.reason),
|
||||
onSuccess: () => {
|
||||
toast.success("Price updated");
|
||||
setEditing(false);
|
||||
qc.invalidateQueries({ queryKey: ["bookings"] });
|
||||
},
|
||||
onError: () => toast.error("Could not update price"),
|
||||
});
|
||||
|
||||
const fmt = (n: number) =>
|
||||
`${booking.paymentCurrency} ${n.toLocaleString(undefined, { minimumFractionDigits: 2 })}`;
|
||||
|
||||
return (
|
||||
<SectionCard icon={Banknote} title="Pricing & payment">
|
||||
<Stack gap="md">
|
||||
<Paper radius="md" withBorder p="md" style={detailStyles.highlightCard}>
|
||||
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
|
||||
Total amount
|
||||
</Text>
|
||||
<Text
|
||||
size="xl"
|
||||
fw={700}
|
||||
c="edr-green.9"
|
||||
mt={4}
|
||||
style={{ fontVariantNumeric: "tabular-nums", letterSpacing: "-0.5px" }}
|
||||
>
|
||||
{booking.paymentCurrency}{" "}
|
||||
{amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
||||
</Text>
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
|
||||
{isAdjusted ? "Adjusted total" : "Total amount"}
|
||||
</Text>
|
||||
<Text
|
||||
size="xl"
|
||||
fw={700}
|
||||
c="edr-green.9"
|
||||
mt={4}
|
||||
style={{ fontVariantNumeric: "tabular-nums", letterSpacing: "-0.5px" }}
|
||||
>
|
||||
{fmt(effective)}
|
||||
</Text>
|
||||
{isAdjusted && (
|
||||
<Text size="xs" c="dimmed" mt={2}>
|
||||
Computed: {fmt(computed)}
|
||||
{booking.adjustmentReason ? ` · ${booking.adjustmentReason}` : ""}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
{!editing && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<Pencil size={13} />}
|
||||
onClick={() => {
|
||||
setAmount(effective);
|
||||
setEditing(true);
|
||||
}}
|
||||
>
|
||||
Adjust
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{editing && (
|
||||
<Stack gap="xs" mt="md">
|
||||
<NumberInput
|
||||
label="New total"
|
||||
value={amount}
|
||||
onChange={(v) => setAmount(v === "" ? "" : Number(v))}
|
||||
min={0}
|
||||
radius="md"
|
||||
prefix={`${booking.paymentCurrency} `}
|
||||
thousandSeparator=","
|
||||
/>
|
||||
<Textarea
|
||||
label="Reason (optional)"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.currentTarget.value)}
|
||||
autosize
|
||||
minRows={2}
|
||||
radius="md"
|
||||
/>
|
||||
<Group justify="space-between" mt={4}>
|
||||
{isAdjusted ? (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
loading={adjustMutation.isPending}
|
||||
onClick={() =>
|
||||
adjustMutation.mutate({ amount: null })
|
||||
}
|
||||
>
|
||||
Clear adjustment
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="default"
|
||||
onClick={() => setEditing(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
loading={adjustMutation.isPending}
|
||||
disabled={amount === ""}
|
||||
onClick={() =>
|
||||
adjustMutation.mutate({
|
||||
amount: Number(amount),
|
||||
reason: reason.trim() || undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Row label="Payment status" value={booking.paymentStatus} />
|
||||
{booking.pnrCode && <Row label="PNR code" value={booking.pnrCode} mono />}
|
||||
|
||||
{modifiers.length > 0 && (
|
||||
{lineItems.length > 0 && (
|
||||
<>
|
||||
<Divider color="var(--mantine-color-gray-2)" />
|
||||
<Group gap={6}>
|
||||
<Receipt size={13} color="var(--mantine-color-gray-5)" />
|
||||
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
|
||||
Surcharges applied
|
||||
Price breakdown
|
||||
</Text>
|
||||
</Group>
|
||||
<Stack gap="xs">
|
||||
{modifiers.map((m) => (
|
||||
{lineItems.map((li, i) => (
|
||||
<Group
|
||||
key={m.id}
|
||||
key={`${li.code}-${i}`}
|
||||
justify="space-between"
|
||||
px="sm"
|
||||
py={6}
|
||||
@@ -55,10 +176,10 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
|
||||
}}
|
||||
>
|
||||
<Text size="sm" c="dimmed">
|
||||
Modifier
|
||||
{li.description}
|
||||
</Text>
|
||||
<Text size="sm" fw={600} style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||
{Number(m.calculatedAmount).toLocaleString()}
|
||||
{Number(li.amount).toLocaleString()} {li.currency}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
|
||||
@@ -167,7 +167,7 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
|
||||
prefix: RULE_ENGINE_CATEGORY_BASE_PATH.configuration,
|
||||
meta: {
|
||||
title: "Configuration",
|
||||
subtitle: "Master data: cargo, containers, wagon types, services, surcharges, yards, and shipping lines",
|
||||
subtitle: "Master data: cargo, containers, wagon types, services, yards, and shipping lines",
|
||||
},
|
||||
},
|
||||
...configurationRouteMeta,
|
||||
|
||||
@@ -158,11 +158,21 @@ const RuleEngineFormDialog = ({
|
||||
|
||||
const visibleFields = useMemo(
|
||||
() =>
|
||||
fields.filter(
|
||||
(field) =>
|
||||
!field.hideWhen ||
|
||||
!field.hideWhen.equals.includes(String(values[field.hideWhen.field] ?? "")),
|
||||
),
|
||||
fields.filter((field) => {
|
||||
if (
|
||||
field.hideWhen &&
|
||||
field.hideWhen.equals.includes(String(values[field.hideWhen.field] ?? ""))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
field.showWhen &&
|
||||
!field.showWhen.equals.includes(String(values[field.showWhen.field] ?? ""))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
[fields, values],
|
||||
);
|
||||
|
||||
@@ -244,6 +254,7 @@ const RuleEngineFormDialog = ({
|
||||
<Select
|
||||
key={field.name}
|
||||
label={label}
|
||||
description={field.description}
|
||||
placeholder={
|
||||
selectOptionsLoading ? "Loading options..." : (field.placeholder ?? "Select an option")
|
||||
}
|
||||
|
||||
@@ -229,9 +229,6 @@ export const URL_CONSTANTS = {
|
||||
SERVICE_TYPES: "/service-types",
|
||||
SERVICE_TYPE_BY_ID: (id: string) => `/service-types/${id}`,
|
||||
|
||||
SURCHARGE_TYPES: "/surcharge-types",
|
||||
SURCHARGE_TYPE_BY_ID: (id: string) => `/surcharge-types/${id}`,
|
||||
|
||||
WEIGHT_LIMIT_RULES: "/weight-limit-rules",
|
||||
WEIGHT_LIMIT_RULE_BY_ID: (id: string) => `/weight-limit-rules/${id}`,
|
||||
|
||||
|
||||
@@ -96,6 +96,40 @@ export const useCargoTypeParentOptions = (excludeId?: string, enabled = true) =>
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Cargo-type options restricted to LEAF nodes (actual commodities, not parent
|
||||
* groups). A node is a leaf when no other cargo type names it as parent. Used
|
||||
* by the Rate form's "Bulk cargo type" picker.
|
||||
*/
|
||||
export const useCargoLeafOptions = (enabled = true) =>
|
||||
useQuery({
|
||||
queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions("cargo-types", { leafOnly: true }),
|
||||
queryFn: () =>
|
||||
ruleEngineService.list<RuleEngineRecord>("cargo-types", {
|
||||
page: 1,
|
||||
pageSize: CARGO_TYPE_PARENT_PAGE_SIZE,
|
||||
}),
|
||||
enabled,
|
||||
select: (result) => {
|
||||
const rows = result.data ?? [];
|
||||
const parentIds = new Set(
|
||||
rows
|
||||
.map((row) => row.parentGroupId)
|
||||
.filter((id): id is string => Boolean(id))
|
||||
.map((id) => String(id)),
|
||||
);
|
||||
return rows
|
||||
.filter((row) => row.id && !parentIds.has(String(row.id)))
|
||||
.map((row) => {
|
||||
const name = String(row.cargoTypeName ?? "").trim();
|
||||
const code = String(row.code ?? "").trim();
|
||||
const label =
|
||||
name && code ? `${name} (${code})` : name || code || String(row.id);
|
||||
return { label, value: String(row.id) };
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export function buildContainerTypeSelectOptions(
|
||||
rows: RuleEngineRecord[],
|
||||
includeNone: boolean,
|
||||
|
||||
@@ -29,6 +29,7 @@ import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import { useRuleEngineViewMode } from "@/components/ruleEngine/useRuleEngineViewMode";
|
||||
import {
|
||||
useApprovalChain,
|
||||
useCargoLeafOptions,
|
||||
useCargoTypeParentOptions,
|
||||
useContainerTypeOptions,
|
||||
useLiveRateOptions,
|
||||
@@ -144,12 +145,17 @@ const RuleEngineResourcePage = () => {
|
||||
const usesContainerTypeField = Boolean(
|
||||
config?.formFields.some((f) => f.name === "containerTypeId"),
|
||||
);
|
||||
const usesCargoTypeField = Boolean(
|
||||
config?.formFields.some((f) => f.name === "cargoTypeId"),
|
||||
);
|
||||
const usesLiveRateField = Boolean(
|
||||
config?.formFields.some((f) => f.name === "rateId"),
|
||||
);
|
||||
|
||||
const { data: cargoParentOptions, isLoading: cargoParentOptionsLoading } =
|
||||
useCargoTypeParentOptions(editingId, config?.slug === "cargo-types");
|
||||
const { data: cargoLeafOptions, isLoading: cargoLeafOptionsLoading } =
|
||||
useCargoLeafOptions(usesCargoTypeField);
|
||||
const { data: containerTypeOptions, isLoading: containerTypeOptionsLoading } =
|
||||
useContainerTypeOptions(config?.slug === "rates", usesContainerTypeField);
|
||||
const { data: liveRateOptions, isLoading: liveRateOptionsLoading } =
|
||||
@@ -173,6 +179,13 @@ const RuleEngineResourcePage = () => {
|
||||
options: containerTypeOptions ?? [],
|
||||
};
|
||||
}
|
||||
if (field.name === "cargoTypeId") {
|
||||
return {
|
||||
...field,
|
||||
type: "select" as const,
|
||||
options: cargoLeafOptions ?? [],
|
||||
};
|
||||
}
|
||||
if (field.name === "rateId") {
|
||||
return {
|
||||
...field,
|
||||
@@ -182,7 +195,7 @@ const RuleEngineResourcePage = () => {
|
||||
}
|
||||
return field;
|
||||
});
|
||||
}, [config, cargoParentOptions, containerTypeOptions, liveRateOptions]);
|
||||
}, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions]);
|
||||
|
||||
const rows = data?.data ?? [];
|
||||
const meta = data?.meta;
|
||||
@@ -316,7 +329,15 @@ const RuleEngineResourcePage = () => {
|
||||
const handleFormSubmit = (values: Record<string, unknown>) => {
|
||||
let payload = values;
|
||||
if (config.slug === "rates") {
|
||||
payload = { ...values, currency: "USD" };
|
||||
// Base-freight categories have no surcharge trigger field — the engine
|
||||
// treats them as ALWAYS. Surcharges (Applies to = Other) keep their
|
||||
// chosen trigger.
|
||||
const isSurcharge = values.appliesTo === "OTHER";
|
||||
payload = {
|
||||
...values,
|
||||
currency: "USD",
|
||||
trigger: isSurcharge ? values.trigger : "ALWAYS",
|
||||
};
|
||||
} else if (config.slug === "priority-configs") {
|
||||
// Label is required by the backend but hidden in the UI for now.
|
||||
payload = { ...values, label: String(Date.now()) };
|
||||
@@ -476,6 +497,7 @@ const RuleEngineResourcePage = () => {
|
||||
selectOptionsLoading={
|
||||
(config.slug === "cargo-types" && cargoParentOptionsLoading) ||
|
||||
(usesContainerTypeField && containerTypeOptionsLoading) ||
|
||||
(usesCargoTypeField && cargoLeafOptionsLoading) ||
|
||||
(usesLiveRateField && liveRateOptionsLoading)
|
||||
}
|
||||
positionOptions={!editing ? createPositionOptions : undefined}
|
||||
|
||||
@@ -38,6 +38,12 @@ export interface FormFieldDef {
|
||||
disabled?: boolean;
|
||||
/** Hide this field when another field currently equals one of these values. */
|
||||
hideWhen?: { field: string; equals: string[] };
|
||||
/**
|
||||
* Show this field ONLY when another field currently equals one of these
|
||||
* values (inverse of hideWhen). When both are set, the field must satisfy
|
||||
* showWhen and not match hideWhen.
|
||||
*/
|
||||
showWhen?: { field: string; equals: string[] };
|
||||
}
|
||||
|
||||
export interface RuleEngineOrderConfig {
|
||||
@@ -81,38 +87,38 @@ const APPROVAL_ROLES = [
|
||||
{ label: "CEO", value: "CEO" },
|
||||
];
|
||||
|
||||
const SURCHARGE_TRIGGERS = [
|
||||
{ label: "Hazardous cargo", value: "CARGO_FLAG_HAZARDOUS" },
|
||||
{ label: "Reefer cargo", value: "CARGO_FLAG_REEFER" },
|
||||
{ label: "VGM exceeds limit", value: "VGM_EXCEEDS_LIMIT" },
|
||||
{ label: "Shipping line mapped", value: "SHIPPING_LINE_MAPPED" },
|
||||
{ label: "Consolidation enabled", value: "CONSOLIDATION_ENABLED" },
|
||||
/**
|
||||
* Friendly, admin-facing rate categories. Choosing one drives which fields the
|
||||
* Rate form shows (see the `rates` resource below). Base-freight categories
|
||||
* carry a trade direction + container/bulk scope; OTHER is for surcharges.
|
||||
*/
|
||||
const RATE_APPLIES_TO = [
|
||||
{ label: "Bulk (base freight)", value: "BULK" },
|
||||
{ label: "Container (base freight)", value: "CONTAINER" },
|
||||
{ label: "Intercity (base freight)", value: "INTERCITY" },
|
||||
{ label: "First mile", value: "FIRST_MILE" },
|
||||
{ label: "Last mile", value: "LAST_MILE" },
|
||||
{ label: "Other (surcharge)", value: "OTHER" },
|
||||
];
|
||||
|
||||
const RATE_TYPES = [
|
||||
"CONTAINER_IMPORT",
|
||||
"CONTAINER_EXPORT",
|
||||
"BULK_IMPORT",
|
||||
"BULK_EXPORT",
|
||||
"INTERCITY_BULK",
|
||||
"INTERCITY_CONTAINER",
|
||||
"FIRST_MILE",
|
||||
"LAST_MILE",
|
||||
"DEMURRAGE",
|
||||
"LASHING",
|
||||
"DOUBLE_HANDLING",
|
||||
"CONTAINER_WITH_RETURN",
|
||||
"CANCELLATION_FEE",
|
||||
"OVERWEIGHT_PER_TON",
|
||||
"HAZARD_SURCHARGE",
|
||||
"REEFER_SURCHARGE",
|
||||
"PIL_EXTRA_FEE",
|
||||
].map((v) => ({ label: v.replace(/_/g, " "), value: v }));
|
||||
/** Surcharge triggers — only relevant when Applies to = Other. */
|
||||
const RATE_TRIGGERS = [
|
||||
{ label: "Hazardous cargo", value: "HAZARDOUS" },
|
||||
{ label: "Overweight (per excess ton)", value: "OVERWEIGHT" },
|
||||
{ label: "Reefer cargo", value: "REEFER" },
|
||||
{ label: "Shipping line mapped", value: "SHIPPING_LINE" },
|
||||
{ label: "Consolidation", value: "CONSOLIDATION" },
|
||||
{ label: "Cancellation", value: "CANCELLATION" },
|
||||
{ label: "Demurrage", value: "DEMURRAGE" },
|
||||
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
|
||||
];
|
||||
|
||||
const RATE_UNITS = ["PER_WAGON", "PER_TON", "PER_CONTAINER", "PER_KM", "FLAT"].map((v) => ({
|
||||
label: v.replace(/_/g, " "),
|
||||
value: v,
|
||||
}));
|
||||
const RATE_UNITS =["PER_WAGON", "PER_TON", "PER_CONTAINER", "PER_KM", "PER_INVOICE", "FLAT"].map(
|
||||
(v) => ({
|
||||
label: v.replace(/_/g, " "),
|
||||
value: v,
|
||||
}),
|
||||
);
|
||||
|
||||
const CURRENCIES = [
|
||||
{ label: "USD", value: "USD" },
|
||||
@@ -302,38 +308,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
{ name: "isActive", label: "Active", type: "boolean" },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "surcharge-types",
|
||||
label: "Surcharge Types",
|
||||
category: "configuration",
|
||||
subtitle: "Auto-applied surcharge definitions",
|
||||
searchPlaceholder: "Search surcharge types...",
|
||||
columns: [
|
||||
codeColumn("code"),
|
||||
{ id: "label", header: "Label", accessorKey: "label" },
|
||||
{ id: "triggerCondition", header: "Trigger", accessorKey: "triggerCondition" },
|
||||
{ id: "rateId", header: "Rate", accessorKey: "rate", format: "rateLabel" },
|
||||
activeColumn,
|
||||
],
|
||||
formFields: [
|
||||
{ name: "label", label: "Label", type: "text", required: true },
|
||||
{
|
||||
name: "triggerCondition",
|
||||
label: "Trigger condition",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: SURCHARGE_TRIGGERS,
|
||||
},
|
||||
{
|
||||
name: "rateId",
|
||||
label: "Live rate",
|
||||
type: "select",
|
||||
required: true,
|
||||
placeholder: "Select a LIVE rate",
|
||||
},
|
||||
{ name: "isActive", label: "Active", type: "boolean" },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "weight-limit-rules",
|
||||
label: "Weight Limit Rules",
|
||||
@@ -424,32 +398,64 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
slug: "rates",
|
||||
label: "Rates",
|
||||
category: "rules",
|
||||
cardTitleKey: "rateType",
|
||||
cardTitleKey: "appliesTo",
|
||||
cardSubtitleKey: "currency",
|
||||
subtitle: "Freight rates and approval workflow",
|
||||
searchPlaceholder: "Search rates by type or status...",
|
||||
columns: [
|
||||
{ id: "rateType", header: "Type", accessorKey: "rateType", format: "code" },
|
||||
{ id: "currency", header: "Currency", accessorKey: "currency" },
|
||||
{ id: "appliesTo", header: "Applies to", accessorKey: "appliesTo", format: "code" },
|
||||
{ id: "trigger", header: "Trigger", accessorKey: "trigger" },
|
||||
{ id: "rateValue", header: "Value", accessorKey: "rateValue", format: "number" },
|
||||
{ id: "rateUnit", header: "Unit", accessorKey: "rateUnit" },
|
||||
{ id: "status", header: "Status", accessorKey: "status", format: "rateStatus" },
|
||||
{ id: "effectiveFrom", header: "From", accessorKey: "effectiveFrom", format: "date" },
|
||||
],
|
||||
formFields: [
|
||||
{ name: "rateType", label: "Rate type", type: "select", required: true, options: RATE_TYPES },
|
||||
{
|
||||
name: "appliesTo",
|
||||
label: "Applies to",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: RATE_APPLIES_TO,
|
||||
description:
|
||||
"Pick what this rate is for. Bulk/Container/Intercity are base freight; Other is an auto-applied surcharge.",
|
||||
},
|
||||
// ── Surcharge trigger — only when Applies to = Other ──────────────────
|
||||
{
|
||||
name: "trigger",
|
||||
label: "Surcharge trigger",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: RATE_TRIGGERS,
|
||||
placeholder: "What makes this surcharge apply?",
|
||||
showWhen: { field: "appliesTo", equals: ["OTHER"] },
|
||||
},
|
||||
// ── Trade direction — Bulk & Container only (intercity is domestic) ───
|
||||
{
|
||||
name: "tradeDirection",
|
||||
label: "Trade direction",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"),
|
||||
showWhen: { field: "appliesTo", equals: ["BULK", "CONTAINER"] },
|
||||
},
|
||||
// ── Container type — Container & Intercity ────────────────────────────
|
||||
{
|
||||
name: "containerTypeId",
|
||||
label: "Container type",
|
||||
type: "select",
|
||||
optional: true,
|
||||
placeholder: "Select container type (optional)",
|
||||
showWhen: { field: "appliesTo", equals: ["CONTAINER", "INTERCITY"] },
|
||||
},
|
||||
// ── Bulk cargo (leaf commodity) — Bulk & Intercity ───────────────────
|
||||
{
|
||||
name: "tradeDirection",
|
||||
label: "Trade direction",
|
||||
name: "cargoTypeId",
|
||||
label: "Bulk cargo type",
|
||||
type: "select",
|
||||
options: TRADE_DIRECTIONS,
|
||||
optional: true,
|
||||
placeholder: "Select bulk commodity (optional)",
|
||||
showWhen: { field: "appliesTo", equals: ["BULK", "INTERCITY"] },
|
||||
},
|
||||
{ name: "rateValue", label: "Rate value", type: "number", required: true },
|
||||
{ name: "rateUnit", label: "Rate unit", type: "select", required: true, options: RATE_UNITS },
|
||||
|
||||
@@ -208,6 +208,13 @@ export const bookingsService = {
|
||||
staffReject: (id: string, reason: string) =>
|
||||
postBooking<BookingDetail>(B.STAFF_REJECT(id), { reason }),
|
||||
|
||||
/** Adjust a booking's total price (pass null amount to clear the adjustment). */
|
||||
adjustPrice: (id: string, amount: number | null, reason?: string) =>
|
||||
postBooking<BookingDetail>(`/bookings/${id}/adjust-price`, {
|
||||
amount,
|
||||
reason,
|
||||
}),
|
||||
|
||||
approveStep: ({ id, stepId, requiredRole }: ApproveStepPayload) =>
|
||||
postBooking<BookingDetail>(B.APPROVE_STEP(id, stepId), { requiredRole }),
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ const RESOURCE_BASE: Record<RuleEngineResourceSlug, string> = {
|
||||
"wagon-types": URL_CONSTANTS.RULE_ENGINE.WAGON_TYPES,
|
||||
"priority-configs": URL_CONSTANTS.RULE_ENGINE.PRIORITY_CONFIGS,
|
||||
"service-types": URL_CONSTANTS.RULE_ENGINE.SERVICE_TYPES,
|
||||
"surcharge-types": URL_CONSTANTS.RULE_ENGINE.SURCHARGE_TYPES,
|
||||
"weight-limit-rules": URL_CONSTANTS.RULE_ENGINE.WEIGHT_LIMIT_RULES,
|
||||
yards: URL_CONSTANTS.RULE_ENGINE.YARDS,
|
||||
"shipping-lines": URL_CONSTANTS.RULE_ENGINE.SHIPPING_LINES,
|
||||
@@ -50,8 +49,6 @@ const byIdPath = (resource: RuleEngineResourceSlug, id: string): string => {
|
||||
return URL_CONSTANTS.RULE_ENGINE.PRIORITY_CONFIG_BY_ID(id);
|
||||
case "service-types":
|
||||
return URL_CONSTANTS.RULE_ENGINE.SERVICE_TYPE_BY_ID(id);
|
||||
case "surcharge-types":
|
||||
return URL_CONSTANTS.RULE_ENGINE.SURCHARGE_TYPE_BY_ID(id);
|
||||
case "weight-limit-rules":
|
||||
return URL_CONSTANTS.RULE_ENGINE.WEIGHT_LIMIT_RULE_BY_ID(id);
|
||||
case "yards":
|
||||
|
||||
@@ -119,6 +119,20 @@ export interface BookingDetail {
|
||||
status: BookingStatus;
|
||||
scheduledDate: string;
|
||||
totalAmount: number;
|
||||
adjustedTotalAmount?: number | null;
|
||||
adjustedByStaffId?: string | null;
|
||||
adjustedAt?: string | null;
|
||||
adjustmentReason?: string | null;
|
||||
pricingBreakdown?: {
|
||||
currency: string;
|
||||
totalAmount: number;
|
||||
lineItems: Array<{
|
||||
code: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
}>;
|
||||
} | null;
|
||||
paymentStatus: string;
|
||||
paymentCurrency: string;
|
||||
contractType: string;
|
||||
|
||||
@@ -4,7 +4,6 @@ export type RuleEngineResourceSlug =
|
||||
| "wagon-types"
|
||||
| "priority-configs"
|
||||
| "service-types"
|
||||
| "surcharge-types"
|
||||
| "weight-limit-rules"
|
||||
| "yards"
|
||||
| "shipping-lines"
|
||||
|
||||
@@ -114,9 +114,16 @@ export function PaymentCard({
|
||||
booking: Freight.IBooking;
|
||||
pricing: Pricing;
|
||||
}) {
|
||||
const hasItems = priceLineItems(pricing).length > 0;
|
||||
const paid = booking.paymentStatus === "PAID";
|
||||
const total = priceTotal(pricing);
|
||||
// Customer sees the grand total only. A staff adjustment, when present,
|
||||
// overrides the computed total and is flagged with an "Adjusted by EDR" badge.
|
||||
const isAdjusted =
|
||||
booking.adjustedTotalAmount !== null &&
|
||||
booking.adjustedTotalAmount !== undefined;
|
||||
const currency = pricing?.currency ?? booking.paymentCurrency;
|
||||
const total = isAdjusted
|
||||
? `${Number(booking.adjustedTotalAmount).toLocaleString()} ${currency}`
|
||||
: priceTotal(pricing);
|
||||
|
||||
return (
|
||||
<SectionCard p={22}>
|
||||
@@ -148,31 +155,34 @@ export function PaymentCard({
|
||||
<Text fz="26px" fw={800} c="#10202F">
|
||||
{total}
|
||||
</Text>
|
||||
{isAdjusted && (
|
||||
<Box
|
||||
component="span"
|
||||
mt={6}
|
||||
style={{
|
||||
display: "inline-block",
|
||||
borderRadius: 6,
|
||||
backgroundColor: "#EAF1FB",
|
||||
padding: "3px 8px",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
color: "#2E5B96",
|
||||
}}
|
||||
>
|
||||
Adjusted by EDR
|
||||
</Box>
|
||||
)}
|
||||
{isAdjusted && booking.adjustmentReason && (
|
||||
<Text mt={6} fz="12.5px" c="#6B7C8E">
|
||||
{booking.adjustmentReason}
|
||||
</Text>
|
||||
)}
|
||||
{paid && (
|
||||
<Text mt={4} fz="12.5px" c="#9AA8B5">
|
||||
Paid · {fmtDate(booking.updatedAt)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{hasItems && (
|
||||
<>
|
||||
<Divider />
|
||||
<LineItems pricing={pricing} />
|
||||
<Group
|
||||
justify="space-between"
|
||||
mt={12}
|
||||
pt={14}
|
||||
style={{ borderTop: "1px solid #EEF2F6" }}
|
||||
>
|
||||
<Text fz="14px" fw={800} c="#10202F">
|
||||
Total
|
||||
</Text>
|
||||
<Text fz="15px" fw={800} c="#0A6F4D">
|
||||
{total}
|
||||
</Text>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
{/* <Button */}
|
||||
{/* fullWidth */}
|
||||
{/* mt={16} */}
|
||||
|
||||
@@ -57,22 +57,6 @@ import {
|
||||
|
||||
type PriceModalMode = "submit" | "draft";
|
||||
|
||||
/** Suffix for a per-unit rate, e.g. "each" for a per-container price. */
|
||||
function unitRateLabel(unit?: string): string {
|
||||
switch (unit) {
|
||||
case "PER_CONTAINER":
|
||||
return "each";
|
||||
case "PER_TON":
|
||||
return "per ton";
|
||||
case "PER_WAGON":
|
||||
return "per wagon";
|
||||
case "PER_KM":
|
||||
return "per km";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export default function NewBookingPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -706,28 +690,28 @@ export default function NewBookingPage() {
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{priceModalMode === "submit"
|
||||
? "These are the unit rates that apply to your booking. Confirm to submit for EDR staff review, or reject to discard this booking."
|
||||
: "Your booking has been saved as a draft. These are the unit rates that apply."}
|
||||
? "Review your total price below. Confirm to submit for EDR staff review, or reject to discard this booking."
|
||||
: "Your booking has been saved as a draft. Here is your estimated total price."}
|
||||
</Text>
|
||||
<Stack gap="xs">
|
||||
{pricingData.lineItems.map((item) => (
|
||||
<Group key={item.code} justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" c="dimmed">
|
||||
{item.description}
|
||||
{item.quantity && item.quantity > 1 ? (
|
||||
<Text span size="xs" c="dimmed">
|
||||
{" "}
|
||||
× {item.quantity.toLocaleString()}
|
||||
</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{(item.unitAmount ?? item.amount).toLocaleString()}{" "}
|
||||
{item.currency} {unitRateLabel(item.unit)}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
<Box
|
||||
p="lg"
|
||||
style={{
|
||||
borderRadius: 16,
|
||||
border: "1px solid var(--mantine-color-edr-border-0)",
|
||||
background:
|
||||
"linear-gradient(135deg, #F4FBF7 0%, #FFFFFF 70%)",
|
||||
}}
|
||||
>
|
||||
<Text size="xs" fw={700} tt="uppercase" c="edr-green" style={{ letterSpacing: "0.06em" }}>
|
||||
Total price
|
||||
</Text>
|
||||
<Text fw={800} fz={30} c="#10202F" mt={4}>
|
||||
{pricingData.totalAmount.toLocaleString()}{" "}
|
||||
<Text span fz={18} fw={700} c="edr-muted">
|
||||
{pricingData.currency}
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
{pricingData.warnings.length > 0 && (
|
||||
<Text size="xs" c="orange.7" p="xs" className="rounded bg-orange-50">
|
||||
{pricingData.warnings.join(", ")}
|
||||
|
||||
@@ -360,6 +360,11 @@ export interface IBooking extends BaseEntity {
|
||||
/** Null for general contracts at creation — the date is chosen per order. */
|
||||
scheduledDate: string;
|
||||
totalAmount: number;
|
||||
/** Staff-adjusted total that overrides totalAmount for the customer, if set. */
|
||||
adjustedTotalAmount?: number | null;
|
||||
adjustedByStaffId?: string | null;
|
||||
adjustedAt?: string | null;
|
||||
adjustmentReason?: string | null;
|
||||
paymentStatus: PaymentStatus;
|
||||
|
||||
shippingLineId?: string | null;
|
||||
|
||||
Reference in New Issue
Block a user