mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
363 lines
14 KiB
TypeScript
363 lines
14 KiB
TypeScript
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
|
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 { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository';
|
|
|
|
interface ItemAttributes {
|
|
arrivedAt: Date | null;
|
|
gateClearedAt: Date | null;
|
|
releaseDate: Date | null;
|
|
freightType: string | null;
|
|
tradeDirection: string | null;
|
|
cargoTypeCode: string | null;
|
|
containerTypeCode: string | null;
|
|
inventoryQuantity: number;
|
|
bookingContainerCount: number;
|
|
facilityId: string | null;
|
|
warehouseId: string | null;
|
|
yardId: string | null;
|
|
zoneId: string | null;
|
|
}
|
|
|
|
export interface FeePreview {
|
|
ruleType: FeeRuleType;
|
|
ruleId: string | null;
|
|
ruleName: string | null;
|
|
freeDays: number;
|
|
ratePerDay: number;
|
|
currency: string;
|
|
ruleCurrency: string | null;
|
|
billingCurrency: string;
|
|
startDate: string | null;
|
|
endDate: string;
|
|
endIsOpen: boolean; // true when still accruing (no release/gate-clear yet)
|
|
elapsedDays: number;
|
|
chargeableDays: number;
|
|
containerCount: number;
|
|
billableUnits: number;
|
|
amount: number;
|
|
tiers: Array<{
|
|
fromDay: number;
|
|
toDay: number | null;
|
|
appliedFromDay: number;
|
|
appliedToDay: number;
|
|
days: number;
|
|
ratePerDay: number;
|
|
amount: number;
|
|
}>;
|
|
}
|
|
|
|
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
|
|
@Injectable()
|
|
export class WarehouseFeeService {
|
|
constructor(
|
|
private readonly dataSource: DataSource,
|
|
private readonly feeRuleRepository: WarehouseFeeRuleRepository,
|
|
private readonly exchangeService: ExchangeService,
|
|
) {}
|
|
|
|
// ── Rule CRUD ──────────────────────────────────────────────────────────────
|
|
listRules(): Promise<WarehouseFeeRule[]> {
|
|
return this.feeRuleRepository.findAll({ order: { ruleType: 'ASC', priority: 'ASC' } });
|
|
}
|
|
|
|
createRule(dto: CreateFeeRuleDto): Promise<WarehouseFeeRule> {
|
|
return this.feeRuleRepository.create({
|
|
isActive: true,
|
|
priority: 100,
|
|
currency: 'USD',
|
|
...this.normalizeRuleInput(dto),
|
|
});
|
|
}
|
|
|
|
async updateRule(id: string, dto: UpdateFeeRuleDto): Promise<WarehouseFeeRule> {
|
|
const updated = await this.feeRuleRepository.update(id, this.normalizeRuleInput(dto));
|
|
if (!updated) throw new NotFoundException(`Fee rule ${id} not found`);
|
|
return updated;
|
|
}
|
|
|
|
deleteRule(id: string): Promise<void> {
|
|
return this.feeRuleRepository.softDelete(id);
|
|
}
|
|
|
|
private normalizeRuleInput<T extends CreateFeeRuleDto | UpdateFeeRuleDto>(dto: T): T {
|
|
if (dto.tiers === undefined) return dto;
|
|
const tiers = (dto.tiers ?? [])
|
|
.map((tier) => ({
|
|
fromDay: Number(tier.fromDay),
|
|
toDay: tier.toDay == null ? null : Number(tier.toDay),
|
|
ratePerDay: Number(tier.ratePerDay),
|
|
}))
|
|
.filter((tier) => tier.fromDay > 0 || tier.toDay != null || tier.ratePerDay > 0);
|
|
|
|
for (const tier of tiers) {
|
|
if (!Number.isInteger(tier.fromDay) || tier.fromDay < 1) {
|
|
throw new BadRequestException('Fee tier from day must be a positive whole number.');
|
|
}
|
|
if (tier.toDay != null && (!Number.isInteger(tier.toDay) || tier.toDay < tier.fromDay)) {
|
|
throw new BadRequestException('Fee tier to day must be empty or greater than/equal to from day.');
|
|
}
|
|
if (!Number.isFinite(tier.ratePerDay) || tier.ratePerDay < 0) {
|
|
throw new BadRequestException('Fee tier rate per day must be zero or greater.');
|
|
}
|
|
}
|
|
|
|
const sorted = [...tiers].sort((a, b) => a.fromDay - b.fromDay || (a.toDay ?? Infinity) - (b.toDay ?? Infinity));
|
|
for (let i = 1; i < sorted.length; i += 1) {
|
|
const prev = sorted[i - 1];
|
|
const current = sorted[i];
|
|
if (prev.toDay == null || current.fromDay <= prev.toDay) {
|
|
throw new BadRequestException('Fee tiers cannot overlap. Use separate from/to day ranges.');
|
|
}
|
|
}
|
|
|
|
return { ...dto, tiers: sorted } as T;
|
|
}
|
|
|
|
private async loadItem(inventoryId: string): Promise<ItemAttributes> {
|
|
const [row] = await this.dataSource.query(
|
|
`SELECT inv.arrived_at AS "arrivedAt",
|
|
inv.gate_cleared_at AS "gateClearedAt",
|
|
inv.release_date AS "releaseDate",
|
|
inv.quantity AS "inventoryQuantity",
|
|
inv.warehouse_id AS "warehouseId",
|
|
inv.yard_id AS "yardId",
|
|
inv.zone_id AS "zoneId",
|
|
w.facility_id AS "facilityId",
|
|
b.freight_type AS "freightType",
|
|
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"
|
|
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
|
|
LEFT JOIN freight.cargoes cg ON cg.id = inv.cargo_id
|
|
LEFT JOIN freight.cargo_types cgt ON cgt.id = cg.cargo_type_id
|
|
LEFT JOIN freight.cargo_types booking_cgt ON booking_cgt.id = b.cargo_type_id
|
|
LEFT JOIN freight.containers ct ON ct.id = inv.container_id
|
|
LEFT JOIN freight.container_types ctt ON ctt.id = ct.container_type_id
|
|
LEFT JOIN LATERAL (
|
|
SELECT bc.container_type_id
|
|
FROM freight.booking_container bc
|
|
WHERE bc.booking_id = inv.booking_id
|
|
AND bc.deleted_at IS NULL
|
|
AND bc.container_type_id IS NOT NULL
|
|
ORDER BY bc.created_at ASC
|
|
LIMIT 1
|
|
) booking_container_type ON true
|
|
LEFT JOIN freight.container_types booking_ctt ON booking_ctt.id = booking_container_type.container_type_id
|
|
LEFT JOIN LATERAL (
|
|
SELECT COALESCE(SUM(bc.quantity), 0)::int AS container_count
|
|
FROM freight.booking_container bc
|
|
WHERE bc.booking_id = inv.booking_id
|
|
AND bc.deleted_at IS NULL
|
|
) container_lines ON true
|
|
WHERE inv.id = $1 AND inv.deleted_at IS NULL`,
|
|
[inventoryId],
|
|
);
|
|
if (!row) throw new NotFoundException(`Inventory item ${inventoryId} not found`);
|
|
return row;
|
|
}
|
|
|
|
private matchScore(rule: WarehouseFeeRule, item: ItemAttributes): number | null {
|
|
// Returns specificity score (#matched non-null scope fields), or null if any constraint fails.
|
|
let score = 0;
|
|
const normalized = (value: string | null | undefined) => value?.trim().toUpperCase() || null;
|
|
const check = (
|
|
ruleVal: string | null | undefined,
|
|
itemVal: string | null,
|
|
opts: { allowBoth?: boolean } = {},
|
|
) => {
|
|
const ruleCode = normalized(ruleVal);
|
|
if (ruleCode == null || ruleCode === 'ANY' || ruleCode === 'ALL') return true;
|
|
if (opts.allowBoth && ruleCode === 'BOTH') {
|
|
score += 1;
|
|
return true;
|
|
}
|
|
if (ruleCode === normalized(itemVal)) {
|
|
score += 1;
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
if (!check(rule.freightType, item.freightType)) return null;
|
|
if (!check(rule.tradeDirection, item.tradeDirection, { allowBoth: true })) return null;
|
|
if (!check(rule.cargoTypeCode, item.cargoTypeCode)) return null;
|
|
if (!check(rule.containerType, item.containerTypeCode)) return null;
|
|
if (!check(rule.facilityId, item.facilityId)) return null;
|
|
if (!check(rule.warehouseId, item.warehouseId)) return null;
|
|
if (!check(rule.yardId, item.yardId)) return null;
|
|
if (!check(rule.zoneId, item.zoneId)) return null;
|
|
return score;
|
|
}
|
|
|
|
private bestRule(rules: WarehouseFeeRule[], item: ItemAttributes): WarehouseFeeRule | null {
|
|
let best: WarehouseFeeRule | null = null;
|
|
let bestScore = -1;
|
|
for (const rule of rules) {
|
|
const score = this.matchScore(rule, item);
|
|
if (score == null) continue;
|
|
if (score > bestScore || (score === bestScore && best && rule.priority < best.priority)) {
|
|
best = rule;
|
|
bestScore = score;
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
private normalizeCurrency(currency?: string | null): 'ETB' | 'USD' {
|
|
return currency === 'ETB' ? 'ETB' : 'USD';
|
|
}
|
|
|
|
private async convertAmount(amount: number, fromCurrency: string, toCurrency: string): Promise<number> {
|
|
const from = this.normalizeCurrency(fromCurrency);
|
|
const to = this.normalizeCurrency(toCurrency);
|
|
if (from === to) return Math.round(amount * 100) / 100;
|
|
const rate = await this.exchangeService.getRate(from, to);
|
|
return Math.round(amount * rate * 100) / 100;
|
|
}
|
|
|
|
private calculateTieredAmount(
|
|
tiers: WarehouseFeeTier[] | null | undefined,
|
|
elapsedDays: number,
|
|
containerCount: number,
|
|
): {
|
|
sourceAmount: number;
|
|
billableUnits: number;
|
|
chargeableDays: number;
|
|
weightedRatePerDay: number;
|
|
tiers: FeePreview['tiers'];
|
|
} {
|
|
const sourceTiers = (tiers ?? [])
|
|
.map((tier) => ({
|
|
fromDay: Number(tier.fromDay),
|
|
toDay: tier.toDay == null ? null : Number(tier.toDay),
|
|
ratePerDay: Number(tier.ratePerDay),
|
|
}))
|
|
.filter((tier) => Number.isFinite(tier.fromDay) && tier.fromDay > 0 && Number.isFinite(tier.ratePerDay))
|
|
.sort((a, b) => a.fromDay - b.fromDay);
|
|
|
|
let sourceAmount = 0;
|
|
let tierDays = 0;
|
|
const appliedTiers: FeePreview['tiers'] = [];
|
|
|
|
for (const tier of sourceTiers) {
|
|
if (elapsedDays < tier.fromDay) continue;
|
|
const appliedFromDay = tier.fromDay;
|
|
const appliedToDay = Math.min(elapsedDays, tier.toDay ?? elapsedDays);
|
|
const days = Math.max(0, appliedToDay - appliedFromDay + 1);
|
|
if (days <= 0) continue;
|
|
|
|
const amount = Math.round(days * containerCount * tier.ratePerDay * 100) / 100;
|
|
sourceAmount += amount;
|
|
tierDays += days;
|
|
appliedTiers.push({
|
|
fromDay: tier.fromDay,
|
|
toDay: tier.toDay,
|
|
appliedFromDay,
|
|
appliedToDay,
|
|
days,
|
|
ratePerDay: tier.ratePerDay,
|
|
amount,
|
|
});
|
|
}
|
|
|
|
return {
|
|
sourceAmount: Math.round(sourceAmount * 100) / 100,
|
|
billableUnits: tierDays * containerCount,
|
|
chargeableDays: tierDays,
|
|
weightedRatePerDay: tierDays > 0 ? Math.round((sourceAmount / tierDays / containerCount) * 100) / 100 : 0,
|
|
tiers: appliedTiers,
|
|
};
|
|
}
|
|
|
|
private async compute(
|
|
ruleType: FeeRuleType,
|
|
rule: WarehouseFeeRule | null,
|
|
item: ItemAttributes,
|
|
now: Date,
|
|
billingCurrency: string,
|
|
): Promise<FeePreview> {
|
|
const start = item.arrivedAt ? new Date(item.arrivedAt) : null;
|
|
const endDate = item.gateClearedAt ?? item.releaseDate ?? now;
|
|
const endIsOpen = !item.gateClearedAt && !item.releaseDate;
|
|
const freeDays = rule?.freeDays ?? 0;
|
|
const ratePerDay = 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;
|
|
|
|
const elapsedDays = start
|
|
? Math.max(0, Math.ceil((new Date(endDate).getTime() - start.getTime()) / MS_PER_DAY))
|
|
: 0;
|
|
const tiered = this.calculateTieredAmount(rule?.tiers, elapsedDays, containerCount);
|
|
const hasTiers = Boolean(rule?.tiers?.length);
|
|
const chargeableDays = hasTiers ? tiered.chargeableDays : Math.max(0, elapsedDays - freeDays);
|
|
const billableUnits = hasTiers ? tiered.billableUnits : chargeableDays * containerCount;
|
|
const sourceAmount = hasTiers ? tiered.sourceAmount : Math.round(billableUnits * ratePerDay * 100) / 100;
|
|
const amount = ruleCurrency ? await this.convertAmount(sourceAmount, ruleCurrency, targetCurrency) : 0;
|
|
const sourceRatePerDay = hasTiers ? tiered.weightedRatePerDay : ratePerDay;
|
|
const convertedRatePerDay = ruleCurrency
|
|
? await this.convertAmount(sourceRatePerDay, ruleCurrency, targetCurrency)
|
|
: 0;
|
|
const convertedTiers = ruleCurrency
|
|
? await Promise.all(
|
|
tiered.tiers.map(async (tier) => ({
|
|
...tier,
|
|
ratePerDay: await this.convertAmount(tier.ratePerDay, ruleCurrency, targetCurrency),
|
|
amount: await this.convertAmount(tier.amount, ruleCurrency, targetCurrency),
|
|
})),
|
|
)
|
|
: [];
|
|
|
|
return {
|
|
ruleType,
|
|
ruleId: rule?.id ?? null,
|
|
ruleName: rule?.name ?? null,
|
|
freeDays,
|
|
ratePerDay: convertedRatePerDay,
|
|
currency: targetCurrency,
|
|
ruleCurrency,
|
|
billingCurrency: targetCurrency,
|
|
startDate: start ? start.toISOString() : null,
|
|
endDate: new Date(endDate).toISOString(),
|
|
endIsOpen,
|
|
elapsedDays,
|
|
chargeableDays,
|
|
containerCount,
|
|
billableUnits,
|
|
amount,
|
|
tiers: hasTiers ? convertedTiers : [],
|
|
};
|
|
}
|
|
|
|
/** 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'];
|
|
return Promise.all(
|
|
byType.map((type) =>
|
|
this.compute(
|
|
type,
|
|
this.bestRule(rules.filter((r) => r.ruleType === type), item),
|
|
item,
|
|
now,
|
|
billingCurrency,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|