import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { CURRENCY_CODES, CurrencyCode, 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'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; interface ItemAttributes { arrivedAt: Date | null; /** Backlog-registered box: real arrival on the record, but never billed. */ backlogRegistration: boolean; 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; /** This inventory row's own net weight (tonnes) — bulk STORAGE/DEMURRAGE for PER_TON cargo bills against this, not the booking-wide total. */ inventoryWeight: number; bookingContainerCount: number; /** This item's cargo type unit of measure (PER_TON | PER_ITEM); null defaults to PER_TON. Decides whether bulk day-based fees bill by weight or item count. */ cargoUnitOfMeasure: string | null; /** Booking-level Yes/No recorded after unloading; only true bills double handling (null = undecided). */ doubleHandling: boolean | null; 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_ITEM); 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; /** What `containerCount`/`billableUnits` are counted in — 'container' | 'truck' | 'ton' | 'item'. Bulk cargo bills by weight (ton) or item count depending on the cargo type's unit of measure. */ unitLabel: string; billableUnits: number; amount: number; tiers: Array<{ fromDay: number; toDay: number | null; appliedFromDay: number; appliedToDay: number; days: number; ratePerDay: number; amount: number; }>; /** * Truck detention: one row PER TRUCK — each truck has its own detention * window (it arrives and is released at its own time) and its own matching * rule by truck type, so days and amount differ between trucks. */ groups?: Array<{ assignmentId: string | null; vehicleId: string | null; plateNumber: string | null; vehicleType: string | null; truckCount: number; startDate: string | null; endDate: string | null; endIsOpen: boolean; 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: { permissionKeys: [ FREIGHT_PERMS.warehouseFeeInvoices.getNotification, ], }, 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.backlog_registration AS "backlogRegistration", inv.gate_cleared_at AS "gateClearedAt", inv.release_date AS "releaseDate", inv.quantity AS "inventoryQuantity", inv.weight AS "inventoryWeight", 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", b.double_handling AS "doubleHandling", 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(cgt.unit_of_measure, booking_cgt.unit_of_measure) AS "cargoUnitOfMeasure" 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): CurrencyCode { const code = currency?.toUpperCase(); return (CURRENCY_CODES as readonly string[]).includes(code ?? '') ? (code as CurrencyCode) : '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, }; } /** * Bulk's own billing quantity for THIS inventory row — weight (tons) for * PER_TON cargo, unit count for PER_ITEM cargo (Machinery, Truck, Automobile, * Livestock…). Shared by every cargo-scoped fee type (storage, demurrage, * double handling) so a booking split across several rows is never billed * more than once against its full total. 0 is a legitimate charge (nothing * weighed/counted yet), so no forced floor. */ private resolveBulkQuantity(item: ItemAttributes): { quantity: number; unitLabel: string } { const cargoUnit = (item.cargoUnitOfMeasure ?? 'PER_TON').toUpperCase(); if (cargoUnit === 'PER_ITEM') { return { quantity: Math.max(0, Number(item.inventoryQuantity) || 0), unitLabel: 'item' }; } return { quantity: Math.max(0, Number(item.inventoryWeight) || 0), unitLabel: 'ton' }; } 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 bulk = this.resolveBulkQuantity(item); const containerCount = isContainer ? Math.max(1, Math.round(Number(item.bookingContainerCount) || inventoryQuantity)) : bulk.quantity; const unitLabel = isContainer ? 'container' : bulk.unitLabel; 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, unitLabel, 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 THIS row's own // weight/count — never the whole booking's total. previewForInventory() // computes double handling once per inventory row, so a booking-wide total // would double- (or triple-) bill a booking split across several rows. const bulk = this.resolveBulkQuantity(item); // Double handling applies to IMPORT only — no charge for export/domestic — // AND only when warehouse staff recorded that the goods were actually // re-handled (booking flag = Yes after unloading). Undecided (null) or No // means no charge, so the rule can exist without billing every import. const isImport = (item.tradeDirection ?? '').toUpperCase() === 'IMPORT'; const applies = isImport && item.doubleHandling === true; const quantity = !applies ? 0 : basis === 'PER_CONTAINER' ? containerCount : bulk.quantity; const unitLabel = basis === 'PER_CONTAINER' ? 'container' : bulk.unitLabel; 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, unitLabel, 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); // A backlog registration carries a backdated arrival so the record is // honest about how long the box has sat, but it was never booked through // EDR and is not billed for that history. No rule applies, so no preview — // which also keeps it off the invoice, since invoicing reads this same list. if (item.backlogRegistration) return []; 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, unitLabel: 'truck', 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: [], }; } // One row PER TRUCK: each truck has its own detention window (it reaches the // destination and is released at its own time) and resolves its own rule by // CANONICAL truck type — the truck_types FK is the source of truth, with the // normalized legacy vehicle_type code as fallback so FK-less vehicles keep // billing. Per-truck timestamps fall back to the leg-level pair for legacy // legs recorded before per-truck tracking. const truckRows: Array<{ assignmentId: string; vehicleId: string; plateNumber: string | null; vehicleType: string | null; startAt: Date | string | null; endAt: Date | string | null; }> = await this.dataSource.query( `SELECT va.id AS "assignmentId", va.vehicle_id AS "vehicleId", COALESCE(v.power_plate_no, v.plate_number) AS "plateNumber", COALESCE(t.code, NULLIF(UPPER(TRIM(v.vehicle_type)), '')) AS "vehicleType", COALESCE(va.destination_arrived_at, $2::timestamptz) AS "startAt", COALESCE(va.returned_at, $3::timestamptz) AS "endAt" 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 ORDER BY va.created_at ASC`, [lastMileId, leg.arrivedAt ?? null, leg.deliveredAt ?? null], ); // No trucks assigned yet: keep the leg-level single-truck estimate so the // preview still tells the operator what detention would cost. const trucks = truckRows.length ? truckRows : [ { assignmentId: null as string | null, vehicleId: null as string | null, plateNumber: null as string | null, vehicleType: null as string | null, startAt: leg.arrivedAt ?? null, endAt: leg.deliveredAt ?? null, }, ]; 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( trucks.map(async (t) => { const item: ItemAttributes = { arrivedAt: null, // Truck detention is a per-truck charge, never a warehouse backlog row. backlogRegistration: false, gateClearedAt: null, releaseDate: null, freightType: leg.freightType ?? null, tradeDirection: leg.tradeDirection ?? null, cargoTypeCode: null, containerTypeCode: null, vehicleType: t.vehicleType ?? null, inventoryQuantity: 1, inventoryWeight: 0, bookingContainerCount: 1, cargoUnitOfMeasure: null, // Irrelevant to detention (truck-time based, never double handling). doubleHandling: null, facilityId: null, warehouseId: null, yardId: null, zoneId: null, }; const rule = this.bestRule(detentionRules, item); // truckCount 1 — this row IS one truck. const c = await this.computeTruckDetention( rule, { arrivedAt: t.startAt, deliveredAt: t.endAt, truckCount: 1 }, now, billingCurrency, ); return { ...t, c }; }), ); const totalAmount = Math.round(computed.reduce((s, x) => s + x.c.amount, 0) * 100) / 100; const totalTrucks = computed.length; const totalBillable = computed.reduce((s, x) => s + x.c.billableUnits, 0); // Header days: the worst truck — a single number can't represent per-truck // windows, and the longest detention is the one operations must act on. const chargeableDays = computed.reduce((m, x) => Math.max(m, x.c.chargeableDays), 0); const single = computed.length === 1 ? computed[0].c : null; const anyRuleName = computed.find((x) => x.c.ruleId)?.c.ruleName ?? null; const earliestStart = computed .map((x) => (x.startAt ? new Date(x.startAt).getTime() : null)) .filter((n): n is number => n != null) .sort((a, b) => a - b)[0]; const anyOpen = computed.some((x) => x.c.endIsOpen); return { ruleType: 'TRUCK_DETENTION_FEE', basis: null, unitLabel: 'truck', ruleId: single?.ruleId ?? null, ruleName: single ? single.ruleName : computed.length > 1 && anyRuleName ? 'Per truck rules' : anyRuleName, freeDays: 0, ratePerDay: single?.ratePerDay ?? 0, currency: targetCurrency, ruleCurrency: single?.ruleCurrency ?? null, billingCurrency: targetCurrency, startDate: earliestStart != null ? new Date(earliestStart).toISOString() : null, endDate: (anyOpen ? now : new Date(Math.max( ...computed.map((x) => (x.endAt ? new Date(x.endAt).getTime() : now.getTime())), ))).toISOString(), endIsOpen: anyOpen, elapsedDays: chargeableDays, chargeableDays, containerCount: totalTrucks, billableUnits: totalBillable, amount: totalAmount, tiers: single ? single.tiers : [], groups: computed.map((x) => ({ assignmentId: x.assignmentId, vehicleId: x.vehicleId, plateNumber: x.plateNumber, vehicleType: x.vehicleType, truckCount: 1, startDate: x.startAt ? new Date(x.startAt).toISOString() : null, endDate: x.c.endDate, endIsOpen: x.c.endIsOpen, 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, unitLabel: 'truck', 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 : [], }; } }