Files
pn-new-crm/src/lib/services/client-restore.service.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

423 lines
15 KiB
TypeScript

/**
* Smart-restore service.
*
* Reads the persisted decision log from clients.archive_metadata and
* classifies each decision as:
* - autoReversible → safe to undo right now (system handles it
* inside the restore tx).
* - reversibleWithPrompt → can be undone but the world has moved on a
* bit; surfaces in the wizard with a checkbox
* so the operator opts in.
* - locked → can't be undone (a different client now owns
* the resource, the berth is sold to someone
* else, etc).
*
* Mutating restore happens in `restoreClientWithSelections` once the UI
* has the operator's selections.
*/
import { and, eq, ne, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import type { Tx } from '@/lib/db/utils';
import { clients } from '@/lib/db/schema/clients';
import { interests, interestBerths } from '@/lib/db/schema/interests';
import { berths } from '@/lib/db/schema/berths';
import { yachts } from '@/lib/db/schema/yachts';
import { portalUsers } from '@/lib/db/schema/portal';
import { documents } from '@/lib/db/schema/documents';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { activeInterestsWhere } from '@/lib/services/active-interest';
import { ConflictError, NotFoundError } from '@/lib/errors';
import type { ArchiveMetadata } from '@/lib/services/client-archive.service';
// ─── Public types ───────────────────────────────────────────────────────────
export interface RestoreReversal {
/** Stable id derived from the original decision so the UI can reference it. */
id: string;
/** Mirror of the original decision kind for UI rendering. */
kind: ArchiveMetadata['decisions'][number]['kind'];
/** Refers back to the entity that was changed (berth, yacht, etc). */
refId: string;
/** Human-readable label for the wizard ("Berth A12", "Yacht Schaefer 44"). */
label: string;
/** Why this is being shown the way it is (e.g. "berth still available"). */
reason: string;
/** Carries the persisted decision detail through to applyReversal so we
* can re-link berths to their original interest, restore yacht owners,
* etc. without re-parsing meta.decisions. */
detail?: Record<string, unknown>;
}
export interface RestoreDossier {
client: { id: string; fullName: string; portId: string };
/** Always reversed automatically inside the restore transaction. */
autoReversible: RestoreReversal[];
/** Surfaces as opt-in checkboxes in the wizard. */
reversibleWithPrompt: RestoreReversal[];
/** Read-only list explaining what won't be restored. */
locked: Array<RestoreReversal & { lockReason: string }>;
}
export interface RestoreSelections {
/** ids from RestoreDossier.reversibleWithPrompt the operator opted into. */
applyReversals: string[];
}
export interface RestoreResult {
clientId: string;
autoReversed: number;
promptedReversed: number;
lockedSkipped: number;
}
// ─── Dossier ────────────────────────────────────────────────────────────────
export async function getRestoreDossier(clientId: string, portId: string): Promise<RestoreDossier> {
const [client] = await db
.select({
id: clients.id,
fullName: clients.fullName,
portId: clients.portId,
archivedAt: clients.archivedAt,
archiveMetadata: clients.archiveMetadata,
})
.from(clients)
.where(and(eq(clients.id, clientId), eq(clients.portId, portId)))
.limit(1);
if (!client) throw new NotFoundError('client');
if (!client.archivedAt) throw new ConflictError('client is not archived');
const auto: RestoreReversal[] = [];
const prompt: RestoreReversal[] = [];
const locked: Array<RestoreReversal & { lockReason: string }> = [];
const meta = (client.archiveMetadata ?? null) as ArchiveMetadata | null;
if (!meta || !meta.decisions || meta.decisions.length === 0) {
return {
client: { id: client.id, fullName: client.fullName, portId: client.portId },
autoReversible: [],
reversibleWithPrompt: [],
locked: [],
};
}
for (const d of meta.decisions) {
switch (d.kind) {
case 'berth_released': {
// Try to re-attach: only safe if the berth still exists and is
// still 'available' (i.e. nobody has snapped it up since).
const [b] = await db
.select({ id: berths.id, mooringNumber: berths.mooringNumber, status: berths.status })
.from(berths)
.where(eq(berths.id, d.refId))
.limit(1);
if (!b) {
locked.push({
id: `berth-${d.refId}`,
kind: d.kind,
refId: d.refId,
label: `Berth ${(d.detail?.mooringNumber as string) ?? d.refId.slice(0, 8)}`,
reason: 'released to available during archive',
lockReason: 'berth no longer exists',
});
break;
}
if (b.status === 'available') {
auto.push({
id: `berth-${d.refId}`,
kind: d.kind,
refId: d.refId,
label: `Berth ${b.mooringNumber}`,
reason: 'still available — re-attaching to the restored client',
detail: d.detail,
});
} else if (b.status === 'sold') {
locked.push({
id: `berth-${d.refId}`,
kind: d.kind,
refId: d.refId,
label: `Berth ${b.mooringNumber}`,
reason: 'released during archive',
lockReason: 'berth has since been sold to another client',
});
} else {
// under_offer to a different interest now
prompt.push({
id: `berth-${d.refId}`,
kind: d.kind,
refId: d.refId,
label: `Berth ${b.mooringNumber}`,
reason: 'currently under offer to another client — re-attach as a competing interest?',
detail: d.detail,
});
}
break;
}
case 'yacht_transferred': {
const [y] = await db
.select({
id: yachts.id,
name: yachts.name,
currentOwnerType: yachts.currentOwnerType,
currentOwnerId: yachts.currentOwnerId,
})
.from(yachts)
.where(eq(yachts.id, d.refId))
.limit(1);
if (!y) {
locked.push({
id: `yacht-${d.refId}`,
kind: d.kind,
refId: d.refId,
label: 'Yacht',
reason: 'transferred during archive',
lockReason: 'yacht no longer exists',
});
break;
}
// Look for active interests on the new owner that USE this yacht —
// if any exist, the new owner's deal depends on the yacht and we
// shouldn't yank ownership back without their consent.
const [usage] = await db
.select({ count: sql<number>`count(*)::int` })
.from(interests)
.where(
and(
activeInterestsWhere(portId),
eq(interests.yachtId, y.id),
ne(interests.clientId, clientId),
),
);
if ((usage?.count ?? 0) > 0) {
locked.push({
id: `yacht-${d.refId}`,
kind: d.kind,
refId: d.refId,
label: `Yacht ${y.name}`,
reason: 'transferred during archive',
lockReason: 'new owner has active interests using this yacht',
});
} else {
prompt.push({
id: `yacht-${d.refId}`,
kind: d.kind,
refId: d.refId,
label: `Yacht ${y.name}`,
reason:
'currently owned by another party with no active dependent interests — transfer back?',
});
}
break;
}
case 'yacht_marked_sold_away':
case 'yacht_retained': {
// Sold-away is a label change; restore can flip it back to active
// automatically. Retained never moved, no action needed.
if (d.kind === 'yacht_marked_sold_away') {
auto.push({
id: `yacht-status-${d.refId}`,
kind: d.kind,
refId: d.refId,
label: 'Yacht status',
reason: 'was marked sold-away during archive — restoring to active',
});
}
break;
}
case 'portal_user_revoked': {
const [pu] = await db
.select({ id: portalUsers.id, isActive: portalUsers.isActive })
.from(portalUsers)
.where(eq(portalUsers.clientId, clientId))
.limit(1);
if (pu && !pu.isActive) {
auto.push({
id: `portal-${pu.id}`,
kind: d.kind,
refId: pu.id,
label: 'Portal user account',
reason: 'was deactivated during archive — restoring access',
});
}
break;
}
case 'documenso_voided': {
// Already void in Documenso; we can't un-void. Inform the operator.
locked.push({
id: `doc-${d.refId}`,
kind: d.kind,
refId: d.refId,
label: 'Documenso envelope',
reason: 'voided during archive',
lockReason: 'voided envelopes cannot be re-opened — regenerate the EOI if needed',
});
break;
}
case 'invoice_voided':
case 'invoice_written_off':
locked.push({
id: `invoice-${d.refId}`,
kind: d.kind,
refId: d.refId,
label: 'Invoice',
reason:
d.kind === 'invoice_voided' ? 'voided during archive' : 'written off during archive',
lockReason:
'invoice status changes are not reversed by restore — un-cancel manually if needed',
});
break;
// Berth retained, yacht retained, document left, invoice left,
// reservation_* — no action surfaced because nothing changed.
default:
break;
}
}
return {
client: { id: client.id, fullName: client.fullName, portId: client.portId },
autoReversible: auto,
reversibleWithPrompt: prompt,
locked,
};
}
// ─── Mutating restore ────────────────────────────────────────────────────────
export async function restoreClientWithSelections(args: {
clientId: string;
portId: string;
selections: RestoreSelections;
meta: AuditMeta;
}): Promise<RestoreResult> {
const dossier = await getRestoreDossier(args.clientId, args.portId);
const opted = new Set(args.selections.applyReversals);
let autoReversed = 0;
let promptedReversed = 0;
await db.transaction(async (tx) => {
// Lock the client to prevent concurrent restore.
const [locked] = await tx
.select({ id: clients.id, archivedAt: clients.archivedAt })
.from(clients)
.where(and(eq(clients.id, args.clientId), eq(clients.portId, args.portId)))
.for('update');
if (!locked) throw new NotFoundError('client');
if (!locked.archivedAt) throw new ConflictError('client is not archived');
// Apply auto-reversals.
for (const r of dossier.autoReversible) {
await applyReversal(tx, r, args.clientId);
autoReversed += 1;
}
// Apply opted-in prompts.
for (const r of dossier.reversibleWithPrompt) {
if (!opted.has(r.id)) continue;
await applyReversal(tx, r, args.clientId);
promptedReversed += 1;
}
// Restore the client itself.
await tx
.update(clients)
.set({
archivedAt: null,
archivedBy: null,
archiveReason: null,
archiveMetadata: null,
updatedAt: new Date(),
})
.where(eq(clients.id, args.clientId));
});
void createAuditLog({
portId: args.portId,
userId: args.meta.userId,
action: 'restore',
entityType: 'client',
entityId: args.clientId,
metadata: {
autoReversed,
promptedReversed,
lockedSkipped: dossier.locked.length,
},
ipAddress: args.meta.ipAddress,
userAgent: args.meta.userAgent,
});
return {
clientId: args.clientId,
autoReversed,
promptedReversed,
lockedSkipped: dossier.locked.length,
};
}
async function applyReversal(tx: Tx, r: RestoreReversal, clientId: string): Promise<void> {
switch (r.kind) {
case 'berth_released': {
// Re-link the berth to whichever interest originally owned it
// (persisted in d.detail.interestId at archive time). We verify
// the interest still belongs to the restored client and isn't
// archived — defensive in case the operator deleted the interest
// separately while the client was archived.
const interestId = (r.detail?.interestId as string | undefined) ?? null;
if (!interestId) break;
const [iv] = await tx
.select({ id: interests.id, archivedAt: interests.archivedAt })
.from(interests)
.where(and(eq(interests.id, interestId), eq(interests.clientId, clientId)))
.limit(1);
if (!iv || iv.archivedAt) break;
// Idempotent re-insert: the unique index on (interestId, berthId)
// means a duplicate is a no-op via onConflictDoNothing.
await tx
.insert(interestBerths)
.values({
interestId,
berthId: r.refId,
isPrimary: false,
isSpecificInterest: true,
isInEoiBundle: false,
})
.onConflictDoNothing();
// Flip berth status back to under_offer so the public map reflects
// the re-link. Only when berth is currently 'available' (sold
// berths are immutable; under_offer to another client is handled
// via the prompt branch which the operator may opt into).
await tx
.update(berths)
.set({ status: 'under_offer' })
.where(and(eq(berths.id, r.refId), eq(berths.status, 'available')));
break;
}
case 'yacht_transferred': {
// Transfer back to the restored client.
await tx
.update(yachts)
.set({ currentOwnerType: 'client', currentOwnerId: clientId })
.where(eq(yachts.id, r.refId));
break;
}
case 'yacht_marked_sold_away':
await tx.update(yachts).set({ status: 'active' }).where(eq(yachts.id, r.refId));
break;
case 'portal_user_revoked':
await tx
.update(portalUsers)
.set({ isActive: true, updatedAt: new Date() })
.where(eq(portalUsers.id, r.refId));
break;
default:
break;
}
}
// Suppress lint for the test-helper imports used by future integration tests.
void documents;