import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { ExchangeService } from '@edr/api-common'; import { NotificationAudience, NotificationType } from '@edr/types'; import { DataSource } from 'typeorm'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto'; import { FeeRuleBasis, 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; /** Vehicle type of the truck (truck detention scoping); null otherwise. */ vehicleType: 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; zoneId: string | null; } export type AccrualAlert = 'OK' | 'WARNING' | 'CHARGING'; export interface AccrualDashboardRow { inventoryId: string; status: string; bookingId: string | null; companyId: string | null; bookingReference: string | null; customerName: string | null; warehouseCode: string | null; zoneCode: string | null; receivedAt: string | null; currency: string; accruedAmount: number; freeDaysLeft: number | null; charging: boolean; alert: AccrualAlert; /** Reviewed by ops — suppressed from alerts (snoozed until snoozeUntil, or indefinitely). */ acknowledged: boolean; snoozeUntil: string | null; breakdown: Array<{ type: FeeRuleType; amount: number; freeDays: number; elapsedDays: number; chargeableDays: number; }>; } 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; 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; }>; /** Truck detention: per-vehicle-type breakdown — each truck-type group billed by its own matching rule. */ groups?: Array<{ vehicleType: string | null; truckCount: number; chargeableDays: number; ratePerDay: number; amount: number; ruleId: string | null; ruleName: string | null; }>; } const MS_PER_DAY = 24 * 60 * 60 * 1000; @Injectable() export class WarehouseFeeService { private readonly logger = new Logger(WarehouseFeeService.name); constructor( private readonly dataSource: DataSource, private readonly feeRuleRepository: WarehouseFeeRuleRepository, private readonly exchangeService: ExchangeService, private readonly inbox: NotificationInboxService, ) {} /** * Daily accrual alerts: for every in-warehouse item that is charging or within * its last free days, send the customer an in-app notification with the * outstanding accrued amount so they can collect before (more) charges hit. */ @Cron(CronExpression.EVERY_DAY_AT_6AM, { name: 'warehouse-accrual-alert' }) async sendAccrualAlerts(): Promise { try { const alerts = (await this.accrualDashboard()).filter( (r) => r.alert !== 'OK' && !r.acknowledged, ); if (!alerts.length) return; this.logger.log(`Accrual alerts: ${alerts.length} item(s) charging or nearing charges`); // Per-customer: notify each company about its own items. for (const row of alerts.filter((r) => r.companyId)) { const ref = row.bookingReference ?? row.inventoryId.slice(0, 8); const amount = `${row.accruedAmount.toFixed(2)} ${row.currency}`; const body = row.charging ? `Storage/demurrage is now charging on booking ${ref} — ${amount} accrued. Collect the cargo to stop further charges.` : `Booking ${ref} has ${row.freeDaysLeft ?? 0} free day(s) left before storage/demurrage charges start (${amount} accrued so far).`; try { await this.inbox.notify({ recipients: { companyId: row.companyId! }, audience: NotificationAudience.PORTAL, type: NotificationType.BOOKING_STATUS, title: row.charging ? 'Storage charges accruing' : 'Free days ending soon', body, link: row.bookingId ? `/bookings/${row.bookingId}` : undefined, data: { inventoryId: row.inventoryId, bookingId: row.bookingId, alert: row.alert, accruedAmount: row.accruedAmount, action: 'ACCRUAL_ALERT', }, }); } catch (err) { this.logger.warn( `Accrual alert failed for ${row.inventoryId}: ${(err as Error).message}`, ); } } // Ops staff: one digest covering every alerting item. const charging = alerts.filter((r) => r.charging).length; const nearing = alerts.length - charging; const currency = alerts[0]?.currency ?? 'USD'; const total = alerts.reduce((sum, r) => sum + r.accruedAmount, 0); try { await this.inbox.notify({ recipients: { allBackoffice: true }, audience: NotificationAudience.BACKOFFICE, type: NotificationType.BOOKING_STATUS, title: 'Warehouse fee accruals need attention', body: `${charging} item(s) charging, ${nearing} nearing the free-day limit — ${total.toFixed(2)} ${currency} accruing. Review the accrual dashboard.`, link: '/dashboard/warehouse-fee-invoices', data: { charging, nearing, totalAccrued: Math.round(total * 100) / 100, action: 'ACCRUAL_ALERT_DIGEST' }, }); } catch (err) { this.logger.warn(`Accrual staff digest failed: ${(err as Error).message}`); } } catch (err) { this.logger.warn(`Accrual alert tick failed: ${(err as Error).message}`); } } // ── 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', ...this.normalizeRuleInput(dto), }); } async updateRule(id: string, dto: UpdateFeeRuleDto): Promise { 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 { return this.feeRuleRepository.softDelete(id); } private normalizeRuleInput(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 { 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", 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 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.vehicleType, item.vehicleType)) 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; } /** * On-time dispatch rate: the share of items dispatched in the last N days that * LEFT before their storage free-days expired — CEIL((dispatched−arrived)/day) * <= freeDays, with freeDays resolved by the same rule matching the fee engine * uses (bestRule over active STORAGE_FEE rules). onTimePct is null when there * is nothing to measure (e.g. no dispatched items / no storage rules). */ async onTimeDispatchStats( windowDays = 90, ): Promise<{ sampleSize: number; onTimeCount: number; onTimePct: number | null }> { const storageRules = ( await this.feeRuleRepository.findAll({ where: { isActive: true } }) ).filter((r) => r.ruleType === 'STORAGE_FEE'); // Batched attribute pull mirroring loadItem's scope joins (multi-row) — only // the fields bestRule/matchScore reads, plus the two clock timestamps. const rows: Array< ItemAttributes & { arrivedAt: string; dispatchedAt: string } > = await this.dataSource.query( `SELECT inv.arrived_at AS "arrivedAt", inv.dispatched_at AS "dispatchedAt", 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", NULL AS "vehicleType" 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 WHERE inv.deleted_at IS NULL AND inv.arrived_at IS NOT NULL AND inv.dispatched_at IS NOT NULL AND inv.dispatched_at > now() - ($1 || ' days')::interval`, [windowDays], ); let onTimeCount = 0; for (const row of rows) { const freeDays = this.bestRule(storageRules, row)?.freeDays ?? 0; const elapsed = Math.max( 0, Math.ceil( (new Date(row.dispatchedAt).getTime() - new Date(row.arrivedAt).getTime()) / MS_PER_DAY, ), ); if (elapsed <= freeDays) onTimeCount += 1; } const sampleSize = rows.length; return { sampleSize, onTimeCount, onTimePct: sampleSize ? Math.round((onTimeCount / sampleSize) * 100) : null, }; } private normalizeCurrency(currency?: string | null): 'ETB' | 'USD' { return currency === 'ETB' ? 'ETB' : 'USD'; } private async convertAmount(amount: number, fromCurrency: string, toCurrency: string): Promise { 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 { // 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; 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, basis: null, 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 : [], }; } /** * 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 { 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); // Double handling applies to IMPORT only — no charge for export/domestic. const isImport = (item.tradeDirection ?? '').toUpperCase() === 'IMPORT'; const quantity = !isImport ? 0 : 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: [], }; } /** * Live accrual dashboard: for every item still in the warehouse, the fees * accruing right now (storage + demurrage + double-handling), how many free * days remain, and an alert level so staff can act before charges land. */ async accrualDashboard(billingCurrency = 'USD'): Promise { const items: Array<{ id: string; status: string; bookingId: string | null; companyId: string | null; bookingReference: string | null; customerName: string | null; warehouseCode: string | null; zoneCode: string | null; receivedAt: string | null; }> = await this.dataSource.query( `SELECT inv.id, inv.status, b.id AS "bookingId", b.company_id AS "companyId", b.reference AS "bookingReference", c.name AS "customerName", w.code AS "warehouseCode", z.code AS "zoneCode", inv.created_at AS "receivedAt" FROM freight.warehouse_inventory inv LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL LEFT JOIN freight.companies c ON c.id = b.company_id LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id LEFT JOIN freight.warehouse_zones z ON z.id = inv.zone_id WHERE inv.deleted_at IS NULL AND inv.status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING', 'READY_FOR_PICKUP', 'RESERVED') ORDER BY inv.created_at ASC`, ); const ackRows: Array<{ inventoryId: string; snoozeUntil: string | null }> = await this.dataSource.query( `SELECT inventory_id AS "inventoryId", snooze_until AS "snoozeUntil" FROM freight.warehouse_accrual_acks`, ); const now = new Date(); const acks = new Map(ackRows.map((a) => [a.inventoryId, a.snoozeUntil])); const rows = await Promise.all( items.map(async (it): Promise => { const previews = (await this.previewForInventory(it.id, billingCurrency)).filter( (p) => p.ruleId, ); const accruedAmount = Math.round(previews.reduce((sum, p) => sum + (p.amount ?? 0), 0) * 100) / 100; const charging = previews.some((p) => p.chargeableDays > 0); const freeDaysLeftVals = previews .filter((p) => p.endIsOpen) .map((p) => Math.max(0, p.freeDays - p.elapsedDays)); const freeDaysLeft = freeDaysLeftVals.length ? Math.min(...freeDaysLeftVals) : null; const alert: AccrualAlert = charging ? 'CHARGING' : freeDaysLeft != null && freeDaysLeft <= 2 ? 'WARNING' : 'OK'; return { inventoryId: it.id, status: it.status, bookingId: it.bookingId, companyId: it.companyId, bookingReference: it.bookingReference, customerName: it.customerName, warehouseCode: it.warehouseCode, zoneCode: it.zoneCode, receivedAt: it.receivedAt, currency: billingCurrency, accruedAmount, freeDaysLeft, charging, alert, acknowledged: acks.has(it.id) && (acks.get(it.id) == null || new Date(acks.get(it.id) as string) > now), snoozeUntil: acks.get(it.id) ?? null, breakdown: previews.map((p) => ({ type: p.ruleType, amount: p.amount, freeDays: p.freeDays, elapsedDays: p.elapsedDays, chargeableDays: p.chargeableDays, })), }; }), ); const rank = (a: AccrualAlert) => (a === 'CHARGING' ? 0 : a === 'WARNING' ? 1 : 2); // Acknowledged items sink to the bottom; among the rest, worst alert first. return rows.sort( (a, b) => Number(a.acknowledged) - Number(b.acknowledged) || rank(a.alert) - rank(b.alert) || b.accruedAmount - a.accruedAmount, ); } /** Mark an item's accrual reviewed. `snoozeDays` > 0 suppresses alerts until then; omitted = indefinitely. */ async acknowledgeAccrual( inventoryId: string, opts: { snoozeDays?: number; note?: string; userId?: string } = {}, ): Promise { const snoozeUntil = opts.snoozeDays && opts.snoozeDays > 0 ? new Date(Date.now() + opts.snoozeDays * 24 * 60 * 60 * 1000) : null; await this.dataSource.query( `INSERT INTO freight.warehouse_accrual_acks (inventory_id, acknowledged_by, acknowledged_at, snooze_until, note, updated_at) VALUES ($1, $2, now(), $3, $4, now()) ON CONFLICT (inventory_id) DO UPDATE SET acknowledged_by = EXCLUDED.acknowledged_by, acknowledged_at = now(), snooze_until = EXCLUDED.snooze_until, note = EXCLUDED.note, updated_at = now()`, [inventoryId, opts.userId ?? null, snoozeUntil, opts.note?.trim() || null], ); } /** Remove an acknowledgement so the item re-surfaces for alerts. */ async unacknowledgeAccrual(inventoryId: string): Promise { await this.dataSource.query( `DELETE FROM freight.warehouse_accrual_acks WHERE inventory_id = $1`, [inventoryId], ); } /** Preview demurrage + storage fees for an inventory item using the most specific active rules. */ async previewForInventory(inventoryId: string, billingCurrency = 'USD'): Promise { const item = await this.loadItem(inventoryId); const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } }); const now = new Date(); // Truck detention is a per-truck last-mile charge, not a per-inventory fee — // it is computed separately via previewTruckDetention(), not here. const byType: FeeRuleType[] = ['DEMURRAGE_FEE', 'STORAGE_FEE', 'DOUBLE_HANDLING_FEE']; return Promise.all( byType.map((type) => this.compute( type, this.bestRule(rules.filter((r) => r.ruleType === type), item), item, now, billingCurrency, ), ), ); } /** * Truck detention preview for an EDR last-mile leg. The vehicle should be * returned within the rule's grace window (default 3h) of arriving; beyond * that, detention accrues per truck per day (flat rate/day or progressive * tiers by detention day) until it is delivered/returned (or now, if open). */ async previewTruckDetention(lastMileId: string, billingCurrency = 'USD'): Promise { const [leg] = await this.dataSource.query( `SELECT lm.arrived_at AS "arrivedAt", lm.delivered_at AS "deliveredAt", b.freight_type AS "freightType", b.trade_direction AS "tradeDirection" FROM freight.last_mile lm LEFT JOIN freight.bookings b ON b.id = lm.booking_id WHERE lm.id = $1 AND lm.deleted_at IS NULL`, [lastMileId], ); if (!leg) throw new NotFoundException(`Last-mile record ${lastMileId} not found`); // Truck detention applies to IMPORT only — no charge for export/domestic. if ((leg.tradeDirection ?? '').toUpperCase() !== 'IMPORT') { const cur = this.normalizeCurrency(billingCurrency); return { ruleType: 'TRUCK_DETENTION_FEE', basis: null, ruleId: null, ruleName: null, freeDays: 0, ratePerDay: 0, currency: cur, ruleCurrency: null, billingCurrency: cur, startDate: leg.arrivedAt ? new Date(leg.arrivedAt).toISOString() : null, endDate: (leg.deliveredAt ? new Date(leg.deliveredAt) : new Date()).toISOString(), endIsOpen: !leg.deliveredAt, elapsedDays: 0, chargeableDays: 0, containerCount: 0, billableUnits: 0, amount: 0, tiers: [], groups: [], }; } // Group the leg's vehicles by CANONICAL truck type so each type is billed // by its own matching rule (rates differ by truck type). The FK to // truck_types is the source of truth — renaming a type's label no longer // silently unmatches its rule; the normalized legacy vehicle_type code is // only a fallback for vehicles without the FK (LEFT JOIN keeps them billed // instead of dropping them). Falls back to one untyped group. const groupRows: Array<{ vehicleType: string | null; truckCount: number | string }> = await this.dataSource.query( `SELECT COALESCE(t.code, NULLIF(UPPER(TRIM(v.vehicle_type)), '')) AS "vehicleType", count(*)::int AS "truckCount" FROM freight.last_mile_vehicle_assignments va JOIN freight.vehicles v ON v.id = va.vehicle_id AND v.deleted_at IS NULL LEFT JOIN freight.truck_types t ON t.id = v.truck_type_id AND t.deleted_at IS NULL WHERE va.last_mile_id = $1 AND va.deleted_at IS NULL GROUP BY 1`, [lastMileId], ); const groups = groupRows.length ? groupRows : [{ vehicleType: null, truckCount: 1 }]; const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } }); const detentionRules = rules.filter((r) => r.ruleType === 'TRUCK_DETENTION_FEE'); const now = new Date(); const targetCurrency = this.normalizeCurrency(billingCurrency); const computed = await Promise.all( groups.map(async (g) => { const item: ItemAttributes = { arrivedAt: null, gateClearedAt: null, releaseDate: null, freightType: leg.freightType ?? null, tradeDirection: leg.tradeDirection ?? null, cargoTypeCode: null, containerTypeCode: null, vehicleType: g.vehicleType ?? null, inventoryQuantity: 1, bookingContainerCount: 1, cargoQuantity: 0, facilityId: null, warehouseId: null, yardId: null, zoneId: null, }; const rule = this.bestRule(detentionRules, item); const c = await this.computeTruckDetention( rule, { arrivedAt: leg.arrivedAt, deliveredAt: leg.deliveredAt, truckCount: g.truckCount }, now, billingCurrency, ); return { vehicleType: g.vehicleType ?? null, truckCount: Math.max(1, Math.round(Number(g.truckCount) || 1)), c }; }), ); const totalAmount = Math.round(computed.reduce((s, x) => s + x.c.amount, 0) * 100) / 100; const totalTrucks = computed.reduce((s, x) => s + x.truckCount, 0); const totalBillable = computed.reduce((s, x) => s + x.c.billableUnits, 0); const chargeableDays = computed[0]?.c.chargeableDays ?? 0; const single = computed.length === 1 ? computed[0].c : null; const anyRuleName = computed.find((x) => x.c.ruleId)?.c.ruleName ?? null; return { ruleType: 'TRUCK_DETENTION_FEE', basis: null, ruleId: single?.ruleId ?? null, ruleName: single ? single.ruleName : computed.length > 1 && anyRuleName ? 'Per truck-type rules' : anyRuleName, freeDays: 0, ratePerDay: single?.ratePerDay ?? 0, currency: targetCurrency, ruleCurrency: single?.ruleCurrency ?? null, billingCurrency: targetCurrency, startDate: leg.arrivedAt ? new Date(leg.arrivedAt).toISOString() : null, endDate: (leg.deliveredAt ? new Date(leg.deliveredAt) : now).toISOString(), endIsOpen: !leg.deliveredAt, elapsedDays: chargeableDays, chargeableDays, containerCount: totalTrucks, billableUnits: totalBillable, amount: totalAmount, tiers: single ? single.tiers : [], groups: computed.map((x) => ({ vehicleType: x.vehicleType, truckCount: x.truckCount, chargeableDays: x.c.chargeableDays, ratePerDay: x.c.ratePerDay, amount: x.c.amount, ruleId: x.c.ruleId, ruleName: x.c.ruleName, })), }; } private async computeTruckDetention( rule: WarehouseFeeRule | null, row: { arrivedAt: Date | string | null; deliveredAt: Date | string | null; truckCount: number | string }, now: Date, billingCurrency: string, ): Promise { const graceHours = rule?.freeHours && Number(rule.freeHours) > 0 ? Number(rule.freeHours) : 3; const truckCount = Math.max(1, Math.round(Number(row.truckCount) || 1)); const start = row.arrivedAt ? new Date(row.arrivedAt) : null; const end = row.deliveredAt ? new Date(row.deliveredAt) : now; const endIsOpen = !row.deliveredAt; let chargeableDays = 0; if (start) { const detentionMs = end.getTime() - start.getTime() - graceHours * 60 * 60 * 1000; chargeableDays = detentionMs > 0 ? Math.ceil(detentionMs / MS_PER_DAY) : 0; } const ratePerDay = Number(rule?.ratePerDay ?? 0); const ruleCurrency = rule ? this.normalizeCurrency(rule.currency) : null; const targetCurrency = this.normalizeCurrency(billingCurrency); const hasTiers = Boolean(rule?.tiers?.length); const tiered = this.calculateTieredAmount(rule?.tiers, chargeableDays, truckCount); const billableUnits = hasTiers ? tiered.billableUnits : chargeableDays * truckCount; 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: 'TRUCK_DETENTION_FEE', basis: null, ruleId: rule?.id ?? null, ruleName: rule?.name ?? null, freeDays: 0, ratePerDay: convertedRatePerDay, currency: targetCurrency, ruleCurrency, billingCurrency: targetCurrency, startDate: start ? start.toISOString() : null, endDate: end.toISOString(), endIsOpen, elapsedDays: chargeableDays, chargeableDays, containerCount: truckCount, // reused as the per-truck count billableUnits, amount, tiers: hasTiers ? convertedTiers : [], }; } }