Files
pn-new-crm/src/lib/services/report-generators.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

285 lines
10 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.
import { and, count, eq, gte, isNull, lte, sql, sum } from 'drizzle-orm';
import { db } from '@/lib/db';
import { interests, interestBerths } from '@/lib/db/schema/interests';
import { berths } from '@/lib/db/schema/berths';
import { auditLogs, systemSettings } from '@/lib/db/schema/system';
import { STAGE_WEIGHTS, canonicalizeStage } from '@/lib/constants';
import { activeInterestsWhere } from '@/lib/services/active-interest';
import {
rollupStageRevenue,
rollupStageCounts,
rollupBerthStatusCounts,
computeOccupancyRate,
computeTotalForecast,
} from '@/lib/services/report-math';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface PipelineData {
stageCounts: Record<string, number>;
topInterests: Array<{
id: string;
clientId: string;
pipelineStage: string;
berthPrice: string | null;
}>;
generatedAt: string;
}
export interface RevenueData {
/** Gross berth prices per pipeline stage (unweighted). */
stageRevenue: Record<string, string>;
/** Money-changed-hands total: sum of berth prices for won deals. */
totalCompleted: string;
/** Pipeline-weighted forecast: sum of (berth price × stage weight)
* for every active interest. Aligns with the dashboard forecast tile
* so the PDF and dashboard reconcile. */
totalForecast: string;
/** Pipeline weights actually applied (port-customizable). Echoes
* `system_settings.pipeline_weights` when set, otherwise the
* STAGE_WEIGHTS defaults. */
pipelineWeights: Record<string, number>;
generatedAt: string;
}
export interface ActivityData {
logs: Array<{
id: string;
action: string;
entityType: string;
entityId: string | null;
userId: string | null;
createdAt: Date;
}>;
summary: Record<string, number>;
generatedAt: string;
}
export interface OccupancyData {
statusCounts: Record<string, number>;
occupancyRate: number;
totalBerths: number;
generatedAt: string;
}
// ─── Pipeline ─────────────────────────────────────────────────────────────────
export async function fetchPipelineData(
portId: string,
_params: Record<string, unknown>,
): Promise<PipelineData> {
// Count interests per pipeline stage (non-archived).
// The reporting audit caught the missing .groupBy() - without it,
// postgres rejects the SELECT or collapses every interest into a
// single ELSE-stage row. groupBy fixes the per-stage breakdown.
const stageCounts = await db
.select({
stage: interests.pipelineStage,
count: count(),
})
.from(interests)
.where(activeInterestsWhere(portId))
.groupBy(interests.pipelineStage);
// M-L02: legacy 9-stage values (deposit_10pct, contract_sent…) may
// still be present on historical rows. rollupStageCounts canonicalizes
// via canonicalizeStage so historical rows fold into the modern bucket.
const stageCountMap = rollupStageCounts(stageCounts);
// Top 10 interests by berth price (via primary-berth junction join, plan §3.4).
const topInterestsRows = await db
.select({
id: interests.id,
clientId: interests.clientId,
pipelineStage: interests.pipelineStage,
berthPrice: berths.price,
})
.from(interests)
.leftJoin(
interestBerths,
and(eq(interestBerths.interestId, interests.id), eq(interestBerths.isPrimary, true)),
)
.leftJoin(berths, eq(interestBerths.berthId, berths.id))
.where(activeInterestsWhere(portId))
.orderBy(sql`${berths.price} DESC NULLS LAST`)
.limit(10);
return {
stageCounts: stageCountMap,
topInterests: topInterestsRows.map((r) => ({
id: r.id,
clientId: r.clientId,
// M-L02: canonicalize for the same reason - the PDF stage label
// should always resolve from the modern 7-stage set.
pipelineStage: canonicalizeStage(r.pipelineStage),
berthPrice: r.berthPrice ? String(r.berthPrice) : null,
})),
generatedAt: new Date().toISOString(),
};
}
// ─── Revenue ──────────────────────────────────────────────────────────────────
export async function fetchRevenueData(
portId: string,
_params: Record<string, unknown>,
): Promise<RevenueData> {
// Sum berth prices grouped by pipeline stage. Reads the primary-berth link
// via interest_berths (plan §3.4) - non-primary junction rows do not
// contribute to the revenue rollup.
const stageRevenue = await db
.select({
stage: interests.pipelineStage,
revenue: sum(berths.price),
})
.from(interests)
.leftJoin(
interestBerths,
and(eq(interestBerths.interestId, interests.id), eq(interestBerths.isPrimary, true)),
)
.leftJoin(berths, eq(interestBerths.berthId, berths.id))
.where(activeInterestsWhere(portId))
.groupBy(interests.pipelineStage);
// M-L02: canonicalize so legacy 9-stage rows fold into the modern bucket.
const stageRevenueMap = rollupStageRevenue(stageRevenue);
// Total revenue from WON interests only. Reporting audit caught the
// `outcome='won'` is the canonical money-changed-hands signal - won
// deals can technically be set from any pipeline stage, and the legacy
// belt-and-suspenders `pipeline_stage='completed'` filter is brittle to
// future cleanup of the 'completed' sentinel-stage convention (see
// PRE-DEPLOY-PLAN follow-ups). The outcome filter alone catches every
// won deal regardless of the stage it closed at.
const completedRevenue = await db
.select({ total: sum(berths.price) })
.from(interests)
.leftJoin(
interestBerths,
and(eq(interestBerths.interestId, interests.id), eq(interestBerths.isPrimary, true)),
)
.leftJoin(berths, eq(interestBerths.berthId, berths.id))
.where(
and(eq(interests.portId, portId), eq(interests.outcome, 'won'), isNull(interests.archivedAt)),
);
// Pipeline-weighted forecast - sums (berth price × stage weight) for
// every active interest. Stage weights resolve from
// `system_settings.pipeline_weights` (per-port admin override) and
// fall back to STAGE_WEIGHTS defaults. The PDF surfaces this number
// alongside totalCompleted so investors / leadership see both
// "money in the bank" and "expected from pipeline" on the same page.
let pipelineWeights: Record<string, number> = STAGE_WEIGHTS;
const weightsSetting = await db.query.systemSettings.findFirst({
where: and(eq(systemSettings.key, 'pipeline_weights'), eq(systemSettings.portId, portId)),
});
if (weightsSetting?.value && typeof weightsSetting.value === 'object') {
pipelineWeights = weightsSetting.value as Record<string, number>;
}
const forecastRows = await db
.select({
stage: interests.pipelineStage,
revenue: sum(berths.price),
})
.from(interests)
.leftJoin(
interestBerths,
and(eq(interestBerths.interestId, interests.id), eq(interestBerths.isPrimary, true)),
)
.leftJoin(berths, eq(interestBerths.berthId, berths.id))
.where(activeInterestsWhere(portId))
.groupBy(interests.pipelineStage);
// M-L02 covered inside computeTotalForecast via canonicalizeStage -
// legacy stage keys hit the weight map under their modern equivalent.
const totalForecast = computeTotalForecast(forecastRows, pipelineWeights);
return {
stageRevenue: stageRevenueMap,
totalCompleted: completedRevenue[0]?.total ? String(completedRevenue[0].total) : '0',
totalForecast,
pipelineWeights,
generatedAt: new Date().toISOString(),
};
}
// ─── Activity ─────────────────────────────────────────────────────────────────
export async function fetchActivityData(
portId: string,
params: Record<string, unknown>,
): Promise<ActivityData> {
const dateFrom = params.dateFrom as string | undefined;
const dateTo = params.dateTo as string | undefined;
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const fromDate = dateFrom ? new Date(dateFrom) : thirtyDaysAgo;
const conditions = [eq(auditLogs.portId, portId), gte(auditLogs.createdAt, fromDate)];
if (dateTo) {
conditions.push(lte(auditLogs.createdAt, new Date(dateTo)));
}
const logs = await db
.select({
id: auditLogs.id,
action: auditLogs.action,
entityType: auditLogs.entityType,
entityId: auditLogs.entityId,
userId: auditLogs.userId,
createdAt: auditLogs.createdAt,
})
.from(auditLogs)
.where(and(...conditions))
.orderBy(sql`${auditLogs.createdAt} DESC`)
.limit(200);
// Group by action type
const summary: Record<string, number> = {};
for (const log of logs) {
const key = `${log.action}:${log.entityType}`;
summary[key] = (summary[key] ?? 0) + 1;
}
return {
logs,
summary,
generatedAt: new Date().toISOString(),
};
}
// ─── Occupancy ────────────────────────────────────────────────────────────────
export async function fetchOccupancyData(
portId: string,
_params: Record<string, unknown>,
): Promise<OccupancyData> {
const statusCounts = await db
.select({
status: berths.status,
count: count(),
})
.from(berths)
.where(eq(berths.portId, portId))
.groupBy(berths.status);
const { statusCounts: statusCountMap, totalBerths } = rollupBerthStatusCounts(statusCounts);
// Occupied = sold only. Per 2026-05-14 decision, `under_offer` is a
// hold (blocks the berth from sale to other clients) but the berth is
// still technically available until the deal closes. computeOccupancyRate
// implements that rule + rounds to 1 decimal.
const { occupancyRate } = computeOccupancyRate(statusCountMap);
return {
statusCounts: statusCountMap,
occupancyRate,
totalBerths,
generatedAt: new Date().toISOString(),
};
}