/** * Pure-math helpers extracted from report-generators.ts so the * revenue/forecast/occupancy/funnel computations can be unit-tested * deterministically without spinning up a Postgres fixture. * * The corresponding DB-bound `fetch*Data` functions in report-generators * call into these helpers after gathering rows. Tests for the SQL itself * remain integration-tier; this module covers the arithmetic so a future * weight-tuning change can't silently shift the forecast number. */ import { STAGE_WEIGHTS, canonicalizeStage } from '@/lib/constants'; export interface StageRevenueRow { stage: string; revenue: string | number | null; } export interface StageCountRow { stage: string; count: number; } export interface BerthStatusRow { status: string; count: number; } /** * Collapse a per-pipeline-stage revenue list into a canonicalized * Record. Handles the legacy 9-stage * keys via canonicalizeStage so historical rows fold into the modern * 7-stage bucket they belong to. */ export function rollupStageRevenue(rows: StageRevenueRow[]): Record { const out: Record = {}; for (const row of rows) { const key = canonicalizeStage(row.stage); const prior = parseFloat(out[key] ?? '0'); const next = row.revenue ? parseFloat(String(row.revenue)) : 0; out[key] = String(prior + next); } return out; } /** * Same as rollupStageRevenue but for counts (funnel breakdown). */ export function rollupStageCounts(rows: StageCountRow[]): Record { const out: Record = {}; for (const row of rows) { const key = canonicalizeStage(row.stage); out[key] = (out[key] ?? 0) + row.count; } return out; } /** * Pipeline-weighted forecast: sum(berth_price × stage_weight) for every * active interest. The weight per stage resolves from per-port admin * overrides (`system_settings.pipeline_weights`) and falls back to the * STAGE_WEIGHTS defaults. Legacy stage keys canonicalize before lookup * so the forecast doesn't silently undershoot due to a key miss. * * Returns the forecast as a 2-decimal-fixed string for stable * comparison + downstream PDF rendering. */ export function computeTotalForecast( rows: StageRevenueRow[], weights: Record = STAGE_WEIGHTS, ): string { let total = 0; for (const row of rows) { if (!row.revenue) continue; const weight = weights[canonicalizeStage(row.stage)] ?? 0; total += parseFloat(String(row.revenue)) * weight; } return total.toFixed(2); } /** * Occupancy rate as a percentage. "Occupied" = sold only - per the * 2026-05-14 product decision, under_offer is a hold (blocks sale to * other clients) but doesn't count as the berth being occupied yet. * Returns the rate to 1 decimal place; returns 0 when totalBerths=0 * to avoid NaN propagation through the PDF. */ export function computeOccupancyRate(statusCounts: Record): { occupancyRate: number; totalBerths: number; } { let totalBerths = 0; for (const k of Object.keys(statusCounts)) { totalBerths += statusCounts[k] ?? 0; } const occupiedCount = statusCounts['sold'] ?? 0; const occupancyRate = totalBerths > 0 ? Math.round((occupiedCount / totalBerths) * 100 * 10) / 10 : 0; return { occupancyRate, totalBerths }; } /** * Build the per-status count map from a status-grouped query result. * Returns the map AND the total count so callers don't have to sum * again themselves. */ export function rollupBerthStatusCounts(rows: BerthStatusRow[]): { statusCounts: Record; totalBerths: number; } { const statusCounts: Record = {}; let totalBerths = 0; for (const row of rows) { statusCounts[row.status] = row.count; totalBerths += row.count; } return { statusCounts, totalBerths }; }