Files
edr-platform/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts
Hagernesh 01aec12ee9 feat(warehouse): Batch 5 — config-driven allocation + storage/demurrage fee rules
- Allocation rules engine: cargo/container/trade criteria -> deterministic yard/warehouse/zone by code; wired into auto-unload (fallback to default)
- Storage/demurrage fee rules: configurable freeDays + ratePerDay; most-specific match; fee preview per inventory item
- Inventory demurrage timestamps: inspectionStartedAt, inspectionCompletedAt, readyForPickupAt, releaseDate, gateClearedAt
- Migration 1790000000000 (allocation_rules + fee_rules tables + inventory date columns)
- Frontend: Allocation & Fees config page, automatic Fee Preview modal, plumbing/hooks
- No invoice/payment (Batch 6)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 23:53:04 +00:00

169 lines
6.4 KiB
TypeScript

import { Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto';
import { FeeRuleType, WarehouseFeeRule } 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;
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;
startDate: string | null;
endDate: string;
endIsOpen: boolean; // true when still accruing (no release/gate-clear yet)
elapsedDays: number;
chargeableDays: number;
amount: number;
}
const MS_PER_DAY = 24 * 60 * 60 * 1000;
@Injectable()
export class WarehouseFeeService {
constructor(
private readonly dataSource: DataSource,
private readonly feeRuleRepository: WarehouseFeeRuleRepository,
) {}
// ── 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', ...dto });
}
async updateRule(id: string, dto: UpdateFeeRuleDto): Promise<WarehouseFeeRule> {
const updated = await this.feeRuleRepository.update(id, dto);
if (!updated) throw new NotFoundException(`Fee rule ${id} not found`);
return updated;
}
deleteRule(id: string): Promise<void> {
return this.feeRuleRepository.softDelete(id);
}
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.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",
cgt.code AS "cargoTypeCode",
ctt.code AS "containerTypeCode"
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.containers ct ON ct.id = inv.container_id
LEFT JOIN freight.container_types ctt ON ctt.id = ct.container_type_id
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 check = (ruleVal: string | null | undefined, itemVal: string | null) => {
if (ruleVal == null) return true;
if (itemVal != null && ruleVal.toUpperCase() === itemVal.toUpperCase()) {
score += 1;
return true;
}
return false;
};
if (!check(rule.freightType, item.freightType)) return null;
if (!check(rule.tradeDirection, item.tradeDirection)) 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 compute(ruleType: FeeRuleType, rule: WarehouseFeeRule | null, item: ItemAttributes, now: Date): 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 elapsedDays = start
? Math.max(0, Math.ceil((new Date(endDate).getTime() - start.getTime()) / MS_PER_DAY))
: 0;
const chargeableDays = Math.max(0, elapsedDays - freeDays);
const amount = Math.round(chargeableDays * ratePerDay * 100) / 100;
return {
ruleType,
ruleId: rule?.id ?? null,
ruleName: rule?.name ?? null,
freeDays,
ratePerDay,
currency: rule?.currency ?? 'USD',
startDate: start ? start.toISOString() : null,
endDate: new Date(endDate).toISOString(),
endIsOpen,
elapsedDays,
chargeableDays,
amount,
};
}
/** Preview demurrage + storage fees for an inventory item using the most specific active rules. */
async previewForInventory(inventoryId: string): 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 byType.map((type) =>
this.compute(type, this.bestRule(rules.filter((r) => r.ruleType === type), item), item, now),
);
}
}