fix(pipeline-refactor): purge stale 9-stage name references

Audit of every '*_sent' / '*_signed' / 'in_communication' / 'details_sent'
/ 'deposit_10pct' / 'completed' literal under src/ caught four genuinely
broken sites that migration 0062 collapsed away but the runtime code
never followed through on:

1. alert-rules.ts: `interest.stale` matched 'details_sent' /
   'in_communication' / 'eoi_sent' — none of which exist post-migration.
   The alert never fired. Updated to the new mid-funnel canon (enquiry /
   qualified / nurturing).

2. berth-recommender.service.ts: TWO copies of the same stage-rank CASE
   (one for active history, one for fallthrough scoring) referenced the
   full legacy 8-stage ladder. Every WHEN missed → MAX(...) returned 0 →
   tier-ladder + heat-score logic collapsed silently. Rebuilt both
   against the 7-stage canon mirroring getHotDeals.

3. interests.service.ts: clearInterestOutcome reopen default was the
   dead 'in_communication'. Switched to 'qualified' (closest analog;
   rep can still override via data.reopenStage). Pre-fix, any reopened
   deal fell through safeStage() to 'enquiry'.

4. report-generators.ts: revenue-PDF "total completed" filter
   intersected pipeline_stage='completed' AND outcome='won'. The stage
   filter is redundant today (setInterestOutcome always writes
   'completed' for terminal outcomes) and is brittle to the upcoming
   sentinel-stage cleanup. Dropped the stage filter — outcome='won' is
   the canonical money-changed-hands signal.

Follow-up flagged: setInterestOutcome still writes pipeline_stage =
'completed' as a sentinel, which is non-canonical under the new 7-stage
type (PIPELINE_STAGES doesn't include 'completed'). Migration 0062's
intent is `outcome` carries terminal state forward; pipeline_stage stays
in-canon. Cleaning up requires sweeping every consumer of
pipeline_stage='completed' as a terminal marker — separate commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 14:56:58 +02:00
parent b966d8106d
commit 465650957b
4 changed files with 39 additions and 30 deletions

View File

@@ -74,9 +74,18 @@ async function reservationNoAgreement(portId: string): Promise<AlertCandidate[]>
// Pipeline stuck in mid-funnel stages with no contact for 14+ days.
async function interestStale(portId: string): Promise<AlertCandidate[]> {
// Mid-funnel stages where silence is a problem. EOI/deposit/contract stages
// have their own dedicated alerts (eoi.unsigned_long, deposit_overdue, etc.).
const STALE_STAGES = ['details_sent', 'in_communication', 'eoi_sent'];
// Mid-funnel stages where silence is a problem. EOI / reservation /
// deposit / contract stages have their own dedicated alerts
// (eoi.unsigned_long, reservation.no_agreement, deposit_overdue, etc.),
// so this alert sits before signing kicks in.
//
// 2026-05-14 pipeline-refactor sweep: the prior values
// ('details_sent', 'in_communication', 'eoi_sent') were collapsed by
// migration 0062 into the 7-stage canon (enquiry / qualified /
// nurturing / eoi / ...). Until this fix landed, this alert never
// fired because no row in the new schema carried the dead stage
// strings.
const STALE_STAGES = ['enquiry', 'qualified', 'nurturing'];
const rows = await db
.select({
id: interests.id,

View File

@@ -482,14 +482,13 @@ export async function recommendBerths(args: RecommendBerthsArgs): Promise<Recomm
) AS lost_count,
COALESCE(
MAX(CASE i.pipeline_stage
WHEN 'open' THEN 1
WHEN 'details_sent' THEN 2
WHEN 'in_communication' THEN 3
WHEN 'eoi_sent' THEN 4
WHEN 'eoi_signed' THEN 5
WHEN 'deposit_10pct' THEN 6
WHEN 'contract_sent' THEN 7
WHEN 'contract_signed' THEN 8
WHEN 'enquiry' THEN 1
WHEN 'nurturing' THEN 2
WHEN 'qualified' THEN 3
WHEN 'eoi' THEN 4
WHEN 'reservation' THEN 5
WHEN 'deposit_paid' THEN 6
WHEN 'contract' THEN 7
ELSE 0 END
) FILTER (WHERE i.archived_at IS NULL AND i.outcome IS NULL),
0
@@ -499,14 +498,13 @@ export async function recommendBerths(args: RecommendBerthsArgs): Promise<Recomm
) AS latest_fallthrough_at,
COALESCE(
MAX(CASE i.pipeline_stage
WHEN 'open' THEN 1
WHEN 'details_sent' THEN 2
WHEN 'in_communication' THEN 3
WHEN 'eoi_sent' THEN 4
WHEN 'eoi_signed' THEN 5
WHEN 'deposit_10pct' THEN 6
WHEN 'contract_sent' THEN 7
WHEN 'contract_signed' THEN 8
WHEN 'enquiry' THEN 1
WHEN 'nurturing' THEN 2
WHEN 'qualified' THEN 3
WHEN 'eoi' THEN 4
WHEN 'reservation' THEN 5
WHEN 'deposit_paid' THEN 6
WHEN 'contract' THEN 7
ELSE 0 END
) FILTER (WHERE i.outcome IS NOT NULL AND (i.outcome::text LIKE 'lost%' OR i.outcome = 'cancelled')),
0

View File

@@ -1063,7 +1063,12 @@ export async function clearInterestOutcome(
throw new ValidationError('Interest has no outcome to clear');
}
const reopenStage = data.reopenStage ?? 'in_communication';
// Default reopen stage = qualified (closest analog of the legacy
// 'in_communication' under the 7-stage pipeline; rep can override
// via data.reopenStage). The legacy default was silently invalid
// post-migration 0062 — reopened interests landed in a non-canonical
// stage that fell through safeStage() to 'enquiry'.
const reopenStage = data.reopenStage ?? 'qualified';
const now = new Date();
await db
.update(interests)

View File

@@ -127,10 +127,12 @@ export async function fetchRevenueData(
}
// Total revenue from WON interests only. Reporting audit caught the
// gap: setInterestOutcome forces pipelineStage='completed' for lost
// AND cancelled outcomes too, so filtering by stage alone counted
// those toward "TOTAL COMPLETED REVENUE". The outcome='won' filter is
// the canonical money-changed-hands signal.
// `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)
@@ -140,12 +142,7 @@ export async function fetchRevenueData(
)
.leftJoin(berths, eq(interestBerths.berthId, berths.id))
.where(
and(
eq(interests.portId, portId),
eq(interests.pipelineStage, 'completed'),
eq(interests.outcome, 'won'),
isNull(interests.archivedAt),
),
and(eq(interests.portId, portId), eq(interests.outcome, 'won'), isNull(interests.archivedAt)),
);
return {