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 { return this.feeRuleRepository.findAll({ order: { ruleType: 'ASC', priority: 'ASC' } }); } createRule(dto: CreateFeeRuleDto): Promise { return this.feeRuleRepository.create({ isActive: true, priority: 100, currency: 'USD', ...dto }); } async updateRule(id: string, dto: UpdateFeeRuleDto): Promise { const updated = await this.feeRuleRepository.update(id, dto); if (!updated) throw new NotFoundException(`Fee rule ${id} not found`); return updated; } deleteRule(id: string): Promise { return this.feeRuleRepository.softDelete(id); } private async loadItem(inventoryId: string): Promise { 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 { 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), ); } }