From 24fa6ab83ba9e07aed0be555597e3c1f1c8a1857 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Tue, 7 Jul 2026 06:25:58 +0000 Subject: [PATCH] Doublehandling --- ...0000-AddDoubleHandlingBasisAndMachinery.ts | 25 +++++++ .../modules/warehouses/dto/fee-rule.dto.ts | 12 +++- .../entities/warehouse-fee-rule.entity.ts | 22 +++++- .../warehouses/warehouse-fee.service.ts | 72 ++++++++++++++++++- .../warehouses/warehouse-invoice.service.ts | 45 +++++++----- .../warehouses/warehouse-invoice.types.ts | 2 + .../components/warehouses/FeePreviewModal.tsx | 2 + .../pages/warehouses/WarehouseRulesPage.tsx | 67 +++++++++++++---- .../backoffice/src/types/warehouse.ts | 28 +++++++- 9 files changed, 239 insertions(+), 36 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1990000000000-AddDoubleHandlingBasisAndMachinery.ts diff --git a/apps/edr-freight-api/src/migrations/1990000000000-AddDoubleHandlingBasisAndMachinery.ts b/apps/edr-freight-api/src/migrations/1990000000000-AddDoubleHandlingBasisAndMachinery.ts new file mode 100644 index 000000000..be4ed060a --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1990000000000-AddDoubleHandlingBasisAndMachinery.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Double-handling fee support. warehouse_fee_rules.basis: how a + * DOUBLE_HANDLING_FEE rule is charged — PER_CONTAINER | PER_TON | PER_ITEM + * (null for the day-based fee types). The PER_TON / PER_ITEM quantity comes from + * the booking's cargo total (cargo_total_weight_vgm, expressed in the cargo's + * unit of measure), so no new booking column is needed. + */ +export class AddDoubleHandlingBasisAndMachinery1990000000000 implements MigrationInterface { + name = 'AddDoubleHandlingBasisAndMachinery1990000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.warehouse_fee_rules ADD COLUMN IF NOT EXISTS basis varchar(20)`, + ); + // machinery_units is not used (PER_ITEM reads cargo_total_weight_vgm); drop it + // if a prior version of this migration added it. + await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS machinery_units`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE freight.warehouse_fee_rules DROP COLUMN IF EXISTS basis`); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts index 22e9f9491..1213b1177 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts @@ -2,7 +2,7 @@ import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsArray, IsEnum, IsInt, IsNumber, IsOptional, IsString, IsUUID, Matches, Min, ValidateNested } from 'class-validator'; -import { FEE_RULE_TYPES, FeeRuleType } from '../entities/warehouse-fee-rule.entity'; +import { FEE_RULE_BASES, FEE_RULE_TYPES, FeeRuleBasis, FeeRuleType } from '../entities/warehouse-fee-rule.entity'; export class FeeRuleTierDto { @ApiProperty({ example: 4 }) @@ -82,11 +82,19 @@ export class CreateFeeRuleDto { @Min(0) freeDays!: number; - @ApiProperty() + @ApiProperty({ description: 'Day-based fees: rate/day. Double handling: flat rate per basis unit.' }) @IsNumber() @Min(0) ratePerDay!: number; + @ApiPropertyOptional({ + enum: FEE_RULE_BASES, + description: 'Double-handling charge basis: PER_CONTAINER | PER_TON | PER_ITEM.', + }) + @IsOptional() + @IsEnum(FEE_RULE_BASES) + basis?: FeeRuleBasis; + @ApiPropertyOptional({ type: [FeeRuleTierDto] }) @IsOptional() @IsArray() diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts index 38dffd235..a8bc7bf23 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts @@ -1,9 +1,23 @@ import { BaseEntity } from '@edr/api-common'; import { Column, Entity, Index } from 'typeorm'; -export const FEE_RULE_TYPES = ['STORAGE_FEE', 'DEMURRAGE_FEE'] as const; +export const FEE_RULE_TYPES = [ + 'STORAGE_FEE', + 'DEMURRAGE_FEE', + 'DOUBLE_HANDLING_FEE', + 'TRUCK_DETENTION_FEE', +] as const; export type FeeRuleType = (typeof FEE_RULE_TYPES)[number]; +/** + * Charge basis for a DOUBLE_HANDLING_FEE rule (flat rate × the chosen quantity): + * - PER_CONTAINER: booking container count + * - PER_TON: cargo total in tonnes (bulk cargo) + * - PER_ITEM: cargo total item count (break-bulk cargo, e.g. machinery) + */ +export const FEE_RULE_BASES = ['PER_CONTAINER', 'PER_TON', 'PER_ITEM'] as const; +export type FeeRuleBasis = (typeof FEE_RULE_BASES)[number]; + export interface WarehouseFeeTier { fromDay: number; toDay: number | null; @@ -60,6 +74,12 @@ export class WarehouseFeeRule extends BaseEntity { @Column({ name: 'rate_per_day', type: 'numeric', precision: 14, scale: 2, default: 0 }) ratePerDay!: number; + // Double-handling only: PER_CONTAINER | PER_TON | PER_MACHINERY. The flat rate + // (rate_per_day, reused as rate-per-unit) is multiplied by the basis quantity; + // free days and tiers do not apply. Null for the day-based fee types. + @Column({ name: 'basis', type: 'varchar', length: 20, nullable: true }) + basis?: FeeRuleBasis | null; + @Column({ name: 'tiers', type: 'jsonb', default: () => "'[]'" }) tiers!: WarehouseFeeTier[]; diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts index cfc7fbf6c..16eca7849 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts @@ -3,7 +3,7 @@ import { ExchangeService } from '@edr/api-common'; import { DataSource } from 'typeorm'; import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto'; -import { FeeRuleType, WarehouseFeeRule, WarehouseFeeTier } from './entities/warehouse-fee-rule.entity'; +import { FeeRuleBasis, FeeRuleType, WarehouseFeeRule, WarehouseFeeTier } from './entities/warehouse-fee-rule.entity'; import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository'; interface ItemAttributes { @@ -16,6 +16,8 @@ interface ItemAttributes { containerTypeCode: string | null; inventoryQuantity: number; bookingContainerCount: number; + /** Booking cargo total in the cargo's unit of measure: tonnes (PER_TON) or item count (PER_ITEM). */ + cargoQuantity: number; facilityId: string | null; warehouseId: string | null; yardId: string | null; @@ -24,6 +26,8 @@ interface ItemAttributes { export interface FeePreview { ruleType: FeeRuleType; + /** Double-handling charge basis (PER_CONTAINER | PER_TON | PER_MACHINERY); null otherwise. */ + basis: FeeRuleBasis | null; ruleId: string | null; ruleName: string | null; freeDays: number; @@ -132,7 +136,8 @@ export class WarehouseFeeService { b.trade_direction AS "tradeDirection", COALESCE(cgt.code, booking_cgt.code) AS "cargoTypeCode", COALESCE(ctt.code, booking_ctt.code) AS "containerTypeCode", - COALESCE(container_lines.container_count, 0) AS "bookingContainerCount" + COALESCE(container_lines.container_count, 0) AS "bookingContainerCount", + COALESCE(b.cargo_total_weight_vgm, 0) AS "cargoQuantity" FROM freight.warehouse_inventory inv LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id LEFT JOIN freight.bookings b ON b.id = inv.booking_id @@ -283,6 +288,10 @@ export class WarehouseFeeService { now: Date, billingCurrency: string, ): Promise { + // Double handling is a flat charge (rate × basis quantity), not day-based. + if (ruleType === 'DOUBLE_HANDLING_FEE') { + return this.computeDoubleHandling(rule, item, now, billingCurrency); + } const start = item.arrivedAt ? new Date(item.arrivedAt) : null; const endDate = item.gateClearedAt ?? item.releaseDate ?? now; const endIsOpen = !item.gateClearedAt && !item.releaseDate; @@ -321,6 +330,7 @@ export class WarehouseFeeService { return { ruleType, + basis: null, ruleId: rule?.id ?? null, ruleName: rule?.name ?? null, freeDays, @@ -340,13 +350,69 @@ export class WarehouseFeeService { }; } + /** + * Double handling — a flat one-time charge, not time-based. Amount = rate × + * the basis quantity: PER_CONTAINER (booking container count), or PER_TON / + * PER_ITEM (the booking cargo total in the cargo's unit of measure — tonnes + * for bulk, item count for break-bulk). No free days, no elapsed days, no tiers. + */ + private async computeDoubleHandling( + rule: WarehouseFeeRule | null, + item: ItemAttributes, + now: Date, + billingCurrency: string, + ): Promise { + const basis: FeeRuleBasis = rule?.basis ?? 'PER_CONTAINER'; + const rate = Number(rule?.ratePerDay ?? 0); + const ruleCurrency = rule ? this.normalizeCurrency(rule.currency) : null; + const targetCurrency = this.normalizeCurrency(billingCurrency); + const isContainer = (item.freightType ?? '').toUpperCase() === 'CONTAINER'; + const inventoryQuantity = Math.max(1, Math.round(Number(item.inventoryQuantity) || 1)); + const containerCount = isContainer + ? Math.max(1, Math.round(Number(item.bookingContainerCount) || inventoryQuantity)) + : 1; + // PER_TON (tonnes) and PER_ITEM (piece count) both read the cargo total, + // which is stored in the cargo's own unit of measure. + const cargoQuantity = Math.max(0, Number(item.cargoQuantity) || 0); + const quantity = basis === 'PER_CONTAINER' ? containerCount : cargoQuantity; + const sourceAmount = Math.round(rate * quantity * 100) / 100; + const amount = ruleCurrency ? await this.convertAmount(sourceAmount, ruleCurrency, targetCurrency) : 0; + const convertedRate = ruleCurrency ? await this.convertAmount(rate, ruleCurrency, targetCurrency) : 0; + + return { + ruleType: 'DOUBLE_HANDLING_FEE', + basis, + ruleId: rule?.id ?? null, + ruleName: rule?.name ?? null, + freeDays: 0, + ratePerDay: convertedRate, + currency: targetCurrency, + ruleCurrency, + billingCurrency: targetCurrency, + startDate: null, + endDate: now.toISOString(), + endIsOpen: false, + elapsedDays: 0, + chargeableDays: 0, + containerCount, + billableUnits: quantity, + amount, + tiers: [], + }; + } + /** Preview demurrage + storage fees for an inventory item using the most specific active rules. */ async previewForInventory(inventoryId: string, billingCurrency = 'USD'): Promise { const item = await this.loadItem(inventoryId); const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } }); const now = new Date(); - const byType: FeeRuleType[] = ['DEMURRAGE_FEE', 'STORAGE_FEE']; + const byType: FeeRuleType[] = [ + 'DEMURRAGE_FEE', + 'STORAGE_FEE', + 'DOUBLE_HANDLING_FEE', + 'TRUCK_DETENTION_FEE', + ]; return Promise.all( byType.map((type) => this.compute( diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index 45591638a..b7cd3628e 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -182,25 +182,38 @@ export class WarehouseInvoiceService { const items = previews .filter((p) => p.amount > 0) .map((p) => { - const feeType: WarehouseFeeType = - p.ruleType === "STORAGE_FEE" - ? "STORAGE_FEE" - : isContainer - ? "CONTAINER_DEMURRAGE" - : "BULK_DEMURRAGE"; + const days = `${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)`; + const tierSuffix = p.tiers.length ? " using tiered tariff" : ` after ${p.freeDays} free`; + let feeType: WarehouseFeeType; + let description: string; + switch (p.ruleType) { + case "STORAGE_FEE": + feeType = "STORAGE_FEE"; + description = `Storage fee - ${days}${tierSuffix}`; + break; + case "DOUBLE_HANDLING_FEE": { + feeType = "DOUBLE_HANDLING"; + const unit = + p.basis === "PER_TON" + ? "ton(s)" + : p.basis === "PER_ITEM" + ? "item(s)" + : "container(s)"; + description = `Double handling - ${p.billableUnits} ${unit}`; + break; + } + case "TRUCK_DETENTION_FEE": + feeType = "TRUCK_DETENTION"; + description = `Truck detention - ${days}${tierSuffix}`; + break; + default: + feeType = isContainer ? "CONTAINER_DEMURRAGE" : "BULK_DEMURRAGE"; + description = `${isContainer ? "Container" : "Bulk"} demurrage - ${days}${tierSuffix}`; + } return { feeRuleId: p.ruleId, feeType, - description: - p.ruleType === "STORAGE_FEE" - ? `Storage fee - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)${p.tiers.length - ? " using tiered tariff" - : ` after ${p.freeDays} free` - }` - : `${isContainer ? "Container" : "Bulk"} demurrage - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)${p.tiers.length - ? " using tiered tariff" - : ` after ${p.freeDays} free` - }`, + description, quantity: p.billableUnits, unitRate: p.ratePerDay, amount: p.amount, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.types.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.types.ts index e201241ba..418816beb 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.types.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.types.ts @@ -26,6 +26,8 @@ export const WAREHOUSE_FEE_TYPES = [ 'BULK_DEMURRAGE', 'STORAGE_FEE', 'HANDLING_FEE', + 'DOUBLE_HANDLING', + 'TRUCK_DETENTION', ] as const; export type WarehouseFeeType = (typeof WAREHOUSE_FEE_TYPES)[number]; diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx index 1d192e2b2..78e09a7e0 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx @@ -28,6 +28,8 @@ interface FeePreviewModalProps { const LABELS: Record = { DEMURRAGE_FEE: { label: 'Demurrage', color: 'orange' }, STORAGE_FEE: { label: 'Storage', color: 'teal' }, + DOUBLE_HANDLING_FEE: { label: 'Double Handling', color: 'grape' }, + TRUCK_DETENTION_FEE: { label: 'Truck Detention Cost', color: 'blue' }, }; function fmtDate(iso: string | null) { diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx index b5c44e4d4..458e71de6 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx @@ -32,7 +32,21 @@ import { useFeeRules, } from '@/hooks/useWarehouses'; import { api } from '@/services/api'; -import { FEE_RULE_TYPES, type FeeRuleType } from '@/types/warehouse'; +import { + FEE_RULE_BASES, + FEE_RULE_BASIS_LABELS, + FEE_RULE_TYPES, + FEE_RULE_TYPE_LABELS, + type FeeRuleBasis, + type FeeRuleType, +} from '@/types/warehouse'; + +const RULE_TYPE_COLOR: Record = { + STORAGE_FEE: 'teal', + DEMURRAGE_FEE: 'orange', + DOUBLE_HANDLING_FEE: 'grape', + TRUCK_DETENTION_FEE: 'blue', +}; import { extractErrorMessage, lettersOnly } from '@/components/warehouses/options'; const FREIGHT = [ @@ -353,6 +367,7 @@ function FeeRules() { const [form, setForm] = useState({ name: '', ruleType: 'DEMURRAGE_FEE' as FeeRuleType, + basis: 'PER_CONTAINER' as FeeRuleBasis, freightType: '', tradeDirection: '', cargoTypeCode: '', @@ -367,11 +382,15 @@ function FeeRules() { const containerTypeOptions = codeOptions(containerTypes); const isBulkRule = form.freightType === 'BULK'; const isContainerRule = form.freightType === 'CONTAINER'; + // Double handling is a flat per-unit charge (basis × rate), not day-based: + // no free days, no progressive tiers. + const isDoubleHandling = form.ruleType === 'DOUBLE_HANDLING_FEE'; const resetForm = () => setForm({ name: '', ruleType: 'DEMURRAGE_FEE', + basis: 'PER_CONTAINER', freightType: '', tradeDirection: '', cargoTypeCode: '', @@ -440,10 +459,12 @@ function FeeRules() { tradeDirection: clean(form.tradeDirection) ?? null, cargoTypeCode: isBulkRule ? (clean(form.cargoTypeCode) ?? null) : null, containerType: isContainerRule ? (clean(form.containerType) ?? null) : null, - freeDays: form.freeDays, + // Double handling: flat basis × rate — no free days, no tiers. + freeDays: isDoubleHandling ? 0 : form.freeDays, ratePerDay: form.ratePerDay, currency: form.currency || 'USD', - ...(tiers.length ? { tiers } : {}), + ...(isDoubleHandling ? { basis: form.basis } : {}), + ...(!isDoubleHandling && tiers.length ? { tiers } : {}), }; try { @@ -514,8 +535,8 @@ function FeeRules() { {rules.map((rule) => ( - - {rule.ruleType === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage'} + + {FEE_RULE_TYPE_LABELS[rule.ruleType] ?? rule.ruleType} {rule.name} @@ -577,7 +598,7 @@ function FeeRules() { label="Rule type" data={FEE_RULE_TYPES.map((type) => ({ value: type, - label: type === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage', + label: FEE_RULE_TYPE_LABELS[type], }))} value={form.ruleType} onChange={(value) => @@ -637,14 +658,26 @@ function FeeRules() { /> )} + {isDoubleHandling ? ( +