Files
pn-new-crm/tests/integration/analytics-service.test.ts
Matt bded8b21f1 feat(reporting): money-math sweep — Step 1 PRE-DEPLOY-PLAN
Single coherent commit completing § 1.1 (hot-path correctness) plus
§ 1.1.4.5 (multi-berth EOI mooring fix). Numbers users see are now
self-consistent across dashboard / kanban / hot deals / PDF reports.

## Active-interest sweep (canonical predicate everywhere)

Routed every "active interest" filter through `activeInterestsWhere`
(commit b966d81 helper). The helper enforces port-scoping + archivedAt
IS NULL + outcome IS NULL — strict definition, won is closed.

Touched sites:
- src/lib/services/reminders.service.ts:digestPort — no longer fires
  reminders for won/lost/cancelled deals
- src/lib/services/berths.service.ts:getLatestInterestStageByBerth
- src/lib/services/client-archive-dossier.service.ts (next-in-line
  others lookup)
- src/lib/services/client-archive.service.ts (remaining-under-offer
  recount before flipping berth back to available)
- src/lib/services/client-restore.service.ts (yacht-usage check)
- src/lib/services/interests.service.ts:listInterestsForBoard +
  getInterestStageCounts + the "others on same berth" lookup —
  kanban / board now exclude terminal deals
- src/lib/services/report-generators.ts: fetchPipelineData,
  fetchRevenueData stage breakdowns, top-N interests

## Pipeline-value currency conversion

`getKpis()` now fetches the port's defaultCurrency from `ports` and
converts each berth's `priceCurrency`→port-default via
`currency.service`. Returns `pipelineValue` + `pipelineValueCurrency`
instead of the lying `pipelineValueUsd`. Missing rates fall through to
raw amount summing (so the tile still shows an approximate number) —
behind a follow-up to surface a "rates incomplete" indicator.

3 consumers updated: KpiCards, PipelineValueTile, ActiveDealsTile.

## Occupancy = sold only

Both the dashboard KPI tile and the revenue-report PDF occupancy data
now count only `berth.status='sold'`. `under_offer` is a hold, not
occupation. The analytics timeline switches from
`berth_reservations`-derived to a cumulative-won-deals derivation via
`interests.outcome='won' AND outcome_at::date <= day` — same source of
truth, historical shape preserved.

## Revenue PDF two-card layout

Added `totalForecast` + `pipelineWeights` to `RevenueData`. Summary
section now renders both:
- "Completed revenue (won)"  — money in the bank
- "Forecast revenue (pipeline-weighted)" — expected pipeline value

Pipeline weights resolve from `system_settings.pipeline_weights`
(per-port admin override) and fall back to STAGE_WEIGHTS defaults. PDF
and dashboard forecast tiles reconcile.

## Multi-berth EOI mooring (4.5)

Documenso `Berth Number` form field now carries the formatBerthRange
output for BOTH single- and multi-berth EOIs. Single-berth output is
byte-identical to the legacy primary-only path
(`formatBerthRange(['A1']) === 'A1'`). Multi-berth EOIs now render
the full range ("A1-A3, B5") in the existing field instead of being
silently dropped against a nonexistent `Berth Range` field.

Dropped:
- `'Berth Range'` from the Documenso formValues payload + TS type
- `setBerthRange()` helper from fill-eoi-form.ts (now redundant)
- The "missing Berth Range AcroForm field" warning log

Updated CLAUDE.md to reflect — no Documenso admin template change
needed.

## Tests

- Updated `documenso-payload.test.ts` — new fixture asserts
  formatBerthRange output flows into Berth Number; multi-berth case
  added.
- Updated `analytics-service.test.ts:computeOccupancyTimeline` —
  fixture creates a won interest instead of a reservation.
- Updated `alerts-engine.test.ts:interest.stale` — fixture stage
  switched from dead `'in_communication'` to canonical `'qualified'`.
- Updated `report-templates.test.tsx:revenue` — fixture carries
  `totalForecast` + `pipelineWeights` to match new RevenueData.

1373/1373 vitest pass. tsc + eslint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:19:38 +02:00

225 lines
7.9 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.
/**
* Analytics service integration tests — exercise the four computations
* against a seeded port + assert the cache layer reads/writes correctly.
*/
import { describe, it, expect } from 'vitest';
import { eq, and } from 'drizzle-orm';
import { db } from '@/lib/db';
import { interests, interestBerths } from '@/lib/db/schema/interests';
import { invoices } from '@/lib/db/schema/financial';
import { analyticsSnapshots } from '@/lib/db/schema/insights';
import {
computePipelineFunnel,
computeOccupancyTimeline,
computeRevenueBreakdown,
computeLeadSourceAttribution,
getPipelineFunnel,
refreshSnapshotsForPort,
ALL_METRICS,
ALL_RANGES,
SNAPSHOT_TTL_MS,
} from '@/lib/services/analytics.service';
import { makePort, makeClient, makeBerth, makeYacht } from '../helpers/factories';
describe('analytics service', () => {
describe('computePipelineFunnel', () => {
it('aggregates interests by stage with conversion percentages', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
// 3 enquiry, 2 qualified, 1 nurturing
for (const stage of [
'enquiry',
'enquiry',
'enquiry',
'qualified',
'qualified',
'nurturing',
]) {
await db.insert(interests).values({
portId: port.id,
clientId: client.id,
pipelineStage: stage,
});
}
const result = await computePipelineFunnel(port.id, '30d');
const enquiry = result.stages.find((s) => s.stage === 'enquiry');
const qualified = result.stages.find((s) => s.stage === 'qualified');
const nurturing = result.stages.find((s) => s.stage === 'nurturing');
expect(enquiry?.count).toBe(3);
expect(enquiry?.conversionPct).toBe(100);
expect(qualified?.count).toBe(2);
expect(qualified?.conversionPct).toBeCloseTo(66.7, 0);
expect(nurturing?.count).toBe(1);
expect(nurturing?.conversionPct).toBeCloseTo(33.3, 0);
});
it('returns zeros when port has no interests', async () => {
const port = await makePort();
const result = await computePipelineFunnel(port.id, '30d');
expect(result.stages).toHaveLength(7);
expect(result.stages.every((s) => s.count === 0)).toBe(true);
});
});
describe('computeOccupancyTimeline', () => {
it('returns 7 points for 7d range with cumulative won-deal occupancy', async () => {
// Post 2026-05-14 the timeline derives occupancy from won
// interests (cumulative as of each day) rather than active
// reservations — see analytics.service.ts comment + PRE-DEPLOY-
// PLAN § 1.1.3. Fixture: 3 berths, one of which sold 5 days ago.
const port = await makePort();
await makeBerth({ portId: port.id });
await makeBerth({ portId: port.id });
const client = await makeClient({ portId: port.id });
await makeYacht({
portId: port.id,
ownerType: 'client',
ownerId: client.id,
});
const berth = await makeBerth({ portId: port.id });
const fiveDaysAgo = new Date(Date.now() - 5 * 86_400_000);
const [interest] = await db
.insert(interests)
.values({
portId: port.id,
clientId: client.id,
pipelineStage: 'contract',
outcome: 'won',
outcomeAt: fiveDaysAgo,
})
.returning();
await db.insert(interestBerths).values({
interestId: interest!.id,
berthId: berth.id,
isPrimary: true,
});
const result = await computeOccupancyTimeline(port.id, '7d');
expect(result.points).toHaveLength(7);
// Last point is today; should reflect 1/3 occupancy.
const today = result.points[result.points.length - 1]!;
expect(today.total).toBe(3);
expect(today.occupied).toBe(1);
expect(today.occupancyPct).toBeCloseTo(33.3, 0);
});
});
describe('computeRevenueBreakdown', () => {
it('groups invoice totals by status and currency', async () => {
const port = await makePort();
const baseInvoice = {
portId: port.id,
clientName: 'Acme',
billingEntityType: 'client' as const,
billingEntityId: 'client-id',
dueDate: '2026-12-31',
currency: 'USD',
subtotal: '0',
createdBy: 'seed',
};
await db.insert(invoices).values([
{ ...baseInvoice, invoiceNumber: 'INV-001', total: '1000', status: 'paid' },
{ ...baseInvoice, invoiceNumber: 'INV-002', total: '500', status: 'paid' },
{ ...baseInvoice, invoiceNumber: 'INV-003', total: '2000', status: 'sent' },
]);
const result = await computeRevenueBreakdown(port.id, '30d');
const paid = result.bars.find((b) => b.status === 'paid');
const sent = result.bars.find((b) => b.status === 'sent');
expect(paid?.amount).toBe(1500);
expect(sent?.amount).toBe(2000);
});
});
describe('computeLeadSourceAttribution', () => {
it('counts interests grouped by source descending', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
for (const source of ['website', 'website', 'website', 'manual', 'referral', 'referral']) {
await db.insert(interests).values({
portId: port.id,
clientId: client.id,
pipelineStage: 'open',
source,
});
}
const result = await computeLeadSourceAttribution(port.id, '30d');
expect(result.slices[0]).toEqual({ source: 'website', count: 3 });
expect(result.slices[1]).toEqual({ source: 'referral', count: 2 });
expect(result.slices[2]).toEqual({ source: 'manual', count: 1 });
});
it('groups null source as "unspecified"', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
await db.insert(interests).values({
portId: port.id,
clientId: client.id,
pipelineStage: 'open',
source: null,
});
const result = await computeLeadSourceAttribution(port.id, '30d');
expect(result.slices.find((s) => s.source === 'unspecified')?.count).toBe(1);
});
});
describe('cache', () => {
it('getPipelineFunnel writes a snapshot and returns it on subsequent calls', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
await db.insert(interests).values({
portId: port.id,
clientId: client.id,
pipelineStage: 'open',
});
const first = await getPipelineFunnel(port.id, '30d');
// Snapshot written.
const row = await db.query.analyticsSnapshots.findFirst({
where: and(
eq(analyticsSnapshots.portId, port.id),
eq(analyticsSnapshots.metricId, 'pipeline_funnel.30d'),
),
});
expect(row).toBeDefined();
expect(row?.data).toEqual(first);
// Mutate the snapshot row directly to confirm cache is being read,
// not recomputed.
const sentinel = { stages: [{ stage: 'sentinel', count: 999, conversionPct: 0 }] };
await db
.update(analyticsSnapshots)
.set({ data: sentinel })
.where(
and(
eq(analyticsSnapshots.portId, port.id),
eq(analyticsSnapshots.metricId, 'pipeline_funnel.30d'),
),
);
const second = await getPipelineFunnel(port.id, '30d');
expect(second).toEqual(sentinel);
});
it('refreshSnapshotsForPort warms every metric × range combo', async () => {
const port = await makePort();
await refreshSnapshotsForPort(port.id);
const rows = await db
.select({ metricId: analyticsSnapshots.metricId })
.from(analyticsSnapshots)
.where(eq(analyticsSnapshots.portId, port.id));
const expected = ALL_METRICS.length * ALL_RANGES.length;
expect(rows).toHaveLength(expected);
});
it('snapshot ttl constant is 15 minutes', () => {
expect(SNAPSHOT_TTL_MS).toBe(15 * 60 * 1000);
});
});
});