mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 12:30:58 +00:00
Doublehandling
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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[];
|
||||
|
||||
|
||||
@@ -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<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 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<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. */
|
||||
async previewForInventory(inventoryId: string, billingCurrency = 'USD'): Promise<FeePreview[]> {
|
||||
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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user