mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 17:45:42 +00:00
Doublehandling
This commit is contained in:
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.warehouse_fee_rules DROP COLUMN IF EXISTS basis`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
|||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { IsArray, IsEnum, IsInt, IsNumber, IsOptional, IsString, IsUUID, Matches, Min, ValidateNested } from 'class-validator';
|
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 {
|
export class FeeRuleTierDto {
|
||||||
@ApiProperty({ example: 4 })
|
@ApiProperty({ example: 4 })
|
||||||
@@ -82,11 +82,19 @@ export class CreateFeeRuleDto {
|
|||||||
@Min(0)
|
@Min(0)
|
||||||
freeDays!: number;
|
freeDays!: number;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty({ description: 'Day-based fees: rate/day. Double handling: flat rate per basis unit.' })
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
ratePerDay!: number;
|
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] })
|
@ApiPropertyOptional({ type: [FeeRuleTierDto] })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
|
|||||||
@@ -1,9 +1,23 @@
|
|||||||
import { BaseEntity } from '@edr/api-common';
|
import { BaseEntity } from '@edr/api-common';
|
||||||
import { Column, Entity, Index } from 'typeorm';
|
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];
|
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 {
|
export interface WarehouseFeeTier {
|
||||||
fromDay: number;
|
fromDay: number;
|
||||||
toDay: number | null;
|
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 })
|
@Column({ name: 'rate_per_day', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
||||||
ratePerDay!: number;
|
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: () => "'[]'" })
|
@Column({ name: 'tiers', type: 'jsonb', default: () => "'[]'" })
|
||||||
tiers!: WarehouseFeeTier[];
|
tiers!: WarehouseFeeTier[];
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ExchangeService } from '@edr/api-common';
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto';
|
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';
|
import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository';
|
||||||
|
|
||||||
interface ItemAttributes {
|
interface ItemAttributes {
|
||||||
@@ -16,6 +16,8 @@ interface ItemAttributes {
|
|||||||
containerTypeCode: string | null;
|
containerTypeCode: string | null;
|
||||||
inventoryQuantity: number;
|
inventoryQuantity: number;
|
||||||
bookingContainerCount: 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;
|
facilityId: string | null;
|
||||||
warehouseId: string | null;
|
warehouseId: string | null;
|
||||||
yardId: string | null;
|
yardId: string | null;
|
||||||
@@ -24,6 +26,8 @@ interface ItemAttributes {
|
|||||||
|
|
||||||
export interface FeePreview {
|
export interface FeePreview {
|
||||||
ruleType: FeeRuleType;
|
ruleType: FeeRuleType;
|
||||||
|
/** Double-handling charge basis (PER_CONTAINER | PER_TON | PER_MACHINERY); null otherwise. */
|
||||||
|
basis: FeeRuleBasis | null;
|
||||||
ruleId: string | null;
|
ruleId: string | null;
|
||||||
ruleName: string | null;
|
ruleName: string | null;
|
||||||
freeDays: number;
|
freeDays: number;
|
||||||
@@ -132,7 +136,8 @@ export class WarehouseFeeService {
|
|||||||
b.trade_direction AS "tradeDirection",
|
b.trade_direction AS "tradeDirection",
|
||||||
COALESCE(cgt.code, booking_cgt.code) AS "cargoTypeCode",
|
COALESCE(cgt.code, booking_cgt.code) AS "cargoTypeCode",
|
||||||
COALESCE(ctt.code, booking_ctt.code) AS "containerTypeCode",
|
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
|
FROM freight.warehouse_inventory inv
|
||||||
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
|
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
|
||||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
|
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
|
||||||
@@ -283,6 +288,10 @@ export class WarehouseFeeService {
|
|||||||
now: Date,
|
now: Date,
|
||||||
billingCurrency: string,
|
billingCurrency: string,
|
||||||
): Promise<FeePreview> {
|
): Promise<FeePreview> {
|
||||||
|
// 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 start = item.arrivedAt ? new Date(item.arrivedAt) : null;
|
||||||
const endDate = item.gateClearedAt ?? item.releaseDate ?? now;
|
const endDate = item.gateClearedAt ?? item.releaseDate ?? now;
|
||||||
const endIsOpen = !item.gateClearedAt && !item.releaseDate;
|
const endIsOpen = !item.gateClearedAt && !item.releaseDate;
|
||||||
@@ -321,6 +330,7 @@ export class WarehouseFeeService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
ruleType,
|
ruleType,
|
||||||
|
basis: null,
|
||||||
ruleId: rule?.id ?? null,
|
ruleId: rule?.id ?? null,
|
||||||
ruleName: rule?.name ?? null,
|
ruleName: rule?.name ?? null,
|
||||||
freeDays,
|
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<FeePreview> {
|
||||||
|
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. */
|
/** Preview demurrage + storage fees for an inventory item using the most specific active rules. */
|
||||||
async previewForInventory(inventoryId: string, billingCurrency = 'USD'): Promise<FeePreview[]> {
|
async previewForInventory(inventoryId: string, billingCurrency = 'USD'): Promise<FeePreview[]> {
|
||||||
const item = await this.loadItem(inventoryId);
|
const item = await this.loadItem(inventoryId);
|
||||||
const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } });
|
const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } });
|
||||||
const now = new Date();
|
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(
|
return Promise.all(
|
||||||
byType.map((type) =>
|
byType.map((type) =>
|
||||||
this.compute(
|
this.compute(
|
||||||
|
|||||||
@@ -182,25 +182,38 @@ export class WarehouseInvoiceService {
|
|||||||
const items = previews
|
const items = previews
|
||||||
.filter((p) => p.amount > 0)
|
.filter((p) => p.amount > 0)
|
||||||
.map((p) => {
|
.map((p) => {
|
||||||
const feeType: WarehouseFeeType =
|
const days = `${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)`;
|
||||||
p.ruleType === "STORAGE_FEE"
|
const tierSuffix = p.tiers.length ? " using tiered tariff" : ` after ${p.freeDays} free`;
|
||||||
? "STORAGE_FEE"
|
let feeType: WarehouseFeeType;
|
||||||
: isContainer
|
let description: string;
|
||||||
? "CONTAINER_DEMURRAGE"
|
switch (p.ruleType) {
|
||||||
: "BULK_DEMURRAGE";
|
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 {
|
return {
|
||||||
feeRuleId: p.ruleId,
|
feeRuleId: p.ruleId,
|
||||||
feeType,
|
feeType,
|
||||||
description:
|
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`
|
|
||||||
}`,
|
|
||||||
quantity: p.billableUnits,
|
quantity: p.billableUnits,
|
||||||
unitRate: p.ratePerDay,
|
unitRate: p.ratePerDay,
|
||||||
amount: p.amount,
|
amount: p.amount,
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export const WAREHOUSE_FEE_TYPES = [
|
|||||||
'BULK_DEMURRAGE',
|
'BULK_DEMURRAGE',
|
||||||
'STORAGE_FEE',
|
'STORAGE_FEE',
|
||||||
'HANDLING_FEE',
|
'HANDLING_FEE',
|
||||||
|
'DOUBLE_HANDLING',
|
||||||
|
'TRUCK_DETENTION',
|
||||||
] as const;
|
] as const;
|
||||||
export type WarehouseFeeType = (typeof WAREHOUSE_FEE_TYPES)[number];
|
export type WarehouseFeeType = (typeof WAREHOUSE_FEE_TYPES)[number];
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ interface FeePreviewModalProps {
|
|||||||
const LABELS: Record<string, { label: string; color: string }> = {
|
const LABELS: Record<string, { label: string; color: string }> = {
|
||||||
DEMURRAGE_FEE: { label: 'Demurrage', color: 'orange' },
|
DEMURRAGE_FEE: { label: 'Demurrage', color: 'orange' },
|
||||||
STORAGE_FEE: { label: 'Storage', color: 'teal' },
|
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) {
|
function fmtDate(iso: string | null) {
|
||||||
|
|||||||
@@ -32,7 +32,21 @@ import {
|
|||||||
useFeeRules,
|
useFeeRules,
|
||||||
} from '@/hooks/useWarehouses';
|
} from '@/hooks/useWarehouses';
|
||||||
import { api } from '@/services/api';
|
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<FeeRuleType, string> = {
|
||||||
|
STORAGE_FEE: 'teal',
|
||||||
|
DEMURRAGE_FEE: 'orange',
|
||||||
|
DOUBLE_HANDLING_FEE: 'grape',
|
||||||
|
TRUCK_DETENTION_FEE: 'blue',
|
||||||
|
};
|
||||||
import { extractErrorMessage, lettersOnly } from '@/components/warehouses/options';
|
import { extractErrorMessage, lettersOnly } from '@/components/warehouses/options';
|
||||||
|
|
||||||
const FREIGHT = [
|
const FREIGHT = [
|
||||||
@@ -353,6 +367,7 @@ function FeeRules() {
|
|||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
ruleType: 'DEMURRAGE_FEE' as FeeRuleType,
|
ruleType: 'DEMURRAGE_FEE' as FeeRuleType,
|
||||||
|
basis: 'PER_CONTAINER' as FeeRuleBasis,
|
||||||
freightType: '',
|
freightType: '',
|
||||||
tradeDirection: '',
|
tradeDirection: '',
|
||||||
cargoTypeCode: '',
|
cargoTypeCode: '',
|
||||||
@@ -367,11 +382,15 @@ function FeeRules() {
|
|||||||
const containerTypeOptions = codeOptions(containerTypes);
|
const containerTypeOptions = codeOptions(containerTypes);
|
||||||
const isBulkRule = form.freightType === 'BULK';
|
const isBulkRule = form.freightType === 'BULK';
|
||||||
const isContainerRule = form.freightType === 'CONTAINER';
|
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 = () =>
|
const resetForm = () =>
|
||||||
setForm({
|
setForm({
|
||||||
name: '',
|
name: '',
|
||||||
ruleType: 'DEMURRAGE_FEE',
|
ruleType: 'DEMURRAGE_FEE',
|
||||||
|
basis: 'PER_CONTAINER',
|
||||||
freightType: '',
|
freightType: '',
|
||||||
tradeDirection: '',
|
tradeDirection: '',
|
||||||
cargoTypeCode: '',
|
cargoTypeCode: '',
|
||||||
@@ -440,10 +459,12 @@ function FeeRules() {
|
|||||||
tradeDirection: clean(form.tradeDirection) ?? null,
|
tradeDirection: clean(form.tradeDirection) ?? null,
|
||||||
cargoTypeCode: isBulkRule ? (clean(form.cargoTypeCode) ?? null) : null,
|
cargoTypeCode: isBulkRule ? (clean(form.cargoTypeCode) ?? null) : null,
|
||||||
containerType: isContainerRule ? (clean(form.containerType) ?? 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,
|
ratePerDay: form.ratePerDay,
|
||||||
currency: form.currency || 'USD',
|
currency: form.currency || 'USD',
|
||||||
...(tiers.length ? { tiers } : {}),
|
...(isDoubleHandling ? { basis: form.basis } : {}),
|
||||||
|
...(!isDoubleHandling && tiers.length ? { tiers } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -514,8 +535,8 @@ function FeeRules() {
|
|||||||
{rules.map((rule) => (
|
{rules.map((rule) => (
|
||||||
<Table.Tr key={rule.id}>
|
<Table.Tr key={rule.id}>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Badge color={rule.ruleType === 'DEMURRAGE_FEE' ? 'orange' : 'teal'} variant="light">
|
<Badge color={RULE_TYPE_COLOR[rule.ruleType] ?? 'gray'} variant="light">
|
||||||
{rule.ruleType === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage'}
|
{FEE_RULE_TYPE_LABELS[rule.ruleType] ?? rule.ruleType}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>{rule.name}</Table.Td>
|
<Table.Td>{rule.name}</Table.Td>
|
||||||
@@ -577,7 +598,7 @@ function FeeRules() {
|
|||||||
label="Rule type"
|
label="Rule type"
|
||||||
data={FEE_RULE_TYPES.map((type) => ({
|
data={FEE_RULE_TYPES.map((type) => ({
|
||||||
value: type,
|
value: type,
|
||||||
label: type === 'DEMURRAGE_FEE' ? 'Demurrage' : 'Storage',
|
label: FEE_RULE_TYPE_LABELS[type],
|
||||||
}))}
|
}))}
|
||||||
value={form.ruleType}
|
value={form.ruleType}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
@@ -637,14 +658,26 @@ function FeeRules() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Group grow>
|
<Group grow>
|
||||||
|
{isDoubleHandling ? (
|
||||||
|
<Select
|
||||||
|
label="Basis"
|
||||||
|
data={FEE_RULE_BASES.map((b) => ({ value: b, label: FEE_RULE_BASIS_LABELS[b] }))}
|
||||||
|
value={form.basis}
|
||||||
|
onChange={(value) =>
|
||||||
|
setForm((f) => ({ ...f, basis: selectValue(value, 'PER_CONTAINER') as FeeRuleBasis }))
|
||||||
|
}
|
||||||
|
allowDeselect={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<NumberInput
|
||||||
|
label="Free days"
|
||||||
|
min={0}
|
||||||
|
value={form.freeDays}
|
||||||
|
onChange={(value) => setForm((f) => ({ ...f, freeDays: numberValue(value) }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label="Free days"
|
label={isDoubleHandling ? 'Rate / unit' : 'Rate / day'}
|
||||||
min={0}
|
|
||||||
value={form.freeDays}
|
|
||||||
onChange={(value) => setForm((f) => ({ ...f, freeDays: numberValue(value) }))}
|
|
||||||
/>
|
|
||||||
<NumberInput
|
|
||||||
label="Rate / day"
|
|
||||||
min={0}
|
min={0}
|
||||||
value={form.ratePerDay}
|
value={form.ratePerDay}
|
||||||
onChange={(value) => setForm((f) => ({ ...f, ratePerDay: numberValue(value) }))}
|
onChange={(value) => setForm((f) => ({ ...f, ratePerDay: numberValue(value) }))}
|
||||||
@@ -657,6 +690,13 @@ function FeeRules() {
|
|||||||
allowDeselect={false}
|
allowDeselect={false}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
{isDoubleHandling && (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Flat charge — the rate is multiplied by the selected basis (
|
||||||
|
{FEE_RULE_BASIS_LABELS[form.basis]}). No free days or progressive tiers.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{!isDoubleHandling && (
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text size="sm" fw={600}>
|
<Text size="sm" fw={600}>
|
||||||
@@ -702,6 +742,7 @@ function FeeRules() {
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
)}
|
||||||
<Group justify="flex-end" mt="sm">
|
<Group justify="flex-end" mt="sm">
|
||||||
<Button variant="default" onClick={() => setOpen(false)}>
|
<Button variant="default" onClick={() => setOpen(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -737,13 +737,38 @@ export interface AllocationRule {
|
|||||||
}
|
}
|
||||||
export type SaveAllocationRulePayload = Omit<AllocationRule, 'id'>;
|
export type SaveAllocationRulePayload = Omit<AllocationRule, 'id'>;
|
||||||
|
|
||||||
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];
|
export type FeeRuleType = (typeof FEE_RULE_TYPES)[number];
|
||||||
|
|
||||||
|
/** Human labels for each fee rule type (dropdowns, badges). */
|
||||||
|
export const FEE_RULE_TYPE_LABELS: Record<FeeRuleType, string> = {
|
||||||
|
STORAGE_FEE: 'Storage',
|
||||||
|
DEMURRAGE_FEE: 'Demurrage',
|
||||||
|
DOUBLE_HANDLING_FEE: 'Double Handling',
|
||||||
|
TRUCK_DETENTION_FEE: 'Truck Detention Cost',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Charge basis for a Double Handling rule (flat rate × the chosen quantity). */
|
||||||
|
export const FEE_RULE_BASES = ['PER_CONTAINER', 'PER_TON', 'PER_ITEM'] as const;
|
||||||
|
export type FeeRuleBasis = (typeof FEE_RULE_BASES)[number];
|
||||||
|
|
||||||
|
export const FEE_RULE_BASIS_LABELS: Record<FeeRuleBasis, string> = {
|
||||||
|
PER_CONTAINER: 'Per Container',
|
||||||
|
PER_TON: 'Per Ton',
|
||||||
|
PER_ITEM: 'Per Item',
|
||||||
|
};
|
||||||
|
|
||||||
export interface FeeRule {
|
export interface FeeRule {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
ruleType: FeeRuleType;
|
ruleType: FeeRuleType;
|
||||||
|
/** Double-handling charge basis; null for day-based fee types. */
|
||||||
|
basis?: FeeRuleBasis | null;
|
||||||
priority: number;
|
priority: number;
|
||||||
freightType?: string | null;
|
freightType?: string | null;
|
||||||
tradeDirection?: string | null;
|
tradeDirection?: string | null;
|
||||||
@@ -776,6 +801,7 @@ export interface FeePreviewTier extends FeeRuleTier {
|
|||||||
|
|
||||||
export interface FeePreview {
|
export interface FeePreview {
|
||||||
ruleType: FeeRuleType;
|
ruleType: FeeRuleType;
|
||||||
|
basis?: FeeRuleBasis | null;
|
||||||
ruleId: string | null;
|
ruleId: string | null;
|
||||||
ruleName: string | null;
|
ruleName: string | null;
|
||||||
freeDays: number;
|
freeDays: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user