Files
pn-new-crm/src/lib/services/report-math.ts
Matt 221ae5784e chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged:

- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
  never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
  after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
  redirects (ocr to ai, reports to dashboard, invitations to users),
  docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
  flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
  let-reassign), set-state-in-effect disables in CountryFlag and
  UploadForSigning preview-bytes effect, unused 'confirm' destructures in
  interest contract + reservation tabs, unescaped apostrophe in test-template
  card copy
2026-05-23 00:52:59 +02:00

118 lines
3.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<canonicalStage, totalRevenueString>. 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<string, string> {
const out: Record<string, string> = {};
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<string, number> {
const out: Record<string, number> = {};
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<string, number> = 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<string, number>): {
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<string, number>;
totalBerths: number;
} {
const statusCounts: Record<string, number> = {};
let totalBerths = 0;
for (const row of rows) {
statusCounts[row.status] = row.count;
totalBerths += row.count;
}
return { statusCounts, totalBerths };
}