From 98211066a585234158ed4a0e0c28a3e943a47033 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 15 May 2026 01:18:13 +0200 Subject: [PATCH] fix(legacy-stage): purge 9-stage enum keys from rank tables and stale copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L-001 hunt landed these: - src/lib/services/clients.service.ts — stageRank used pre-refactor 9-stage names exclusively (`contract_signed`, `deposit_10pct`, …). Every modern 7-stage interest fell to rank 0, making client-list "most-progressed deal" sort effectively random. Modern values now own the canonical ranks; legacy aliases map to their 7-stage equivalents so historical audit data still sorts. - src/lib/services/berth-recommender.service.ts — STAGE_ORDER had the same 9-stage shape. LATE_STAGE_THRESHOLD pointed at the (now nonexistent) `deposit_10pct` slot. Reworked to the 7-stage scale; threshold now at `deposit_paid` (5). - Stale comments referencing `deposit_10pct` in schema (clients, financial) and client-archive services updated to current copy. - Smart-archive dialog rendered `i.pipelineStage` as raw enum; now routes through `stageLabelFor` (the new helper added with A2). Test fixture updates: berth-recommender.test.ts numeric inputs re-mapped to the new 7-stage scale (eoi_signed=5 → eoi=3, etc.). 1373/1373 vitest pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../clients/smart-archive-dialog.tsx | 7 +++-- src/lib/db/schema/clients.ts | 2 +- src/lib/db/schema/financial.ts | 2 +- src/lib/db/schema/users.ts | 8 ++--- src/lib/services/berth-recommender.service.ts | 30 +++++++++++++------ .../client-archive-dossier.service.ts | 2 +- src/lib/services/client-archive.service.ts | 2 +- src/lib/services/clients.service.ts | 27 +++++++++++++---- src/lib/validators/interests.ts | 3 +- tests/unit/services/berth-recommender.test.ts | 13 ++++---- 10 files changed, 65 insertions(+), 31 deletions(-) diff --git a/src/components/clients/smart-archive-dialog.tsx b/src/components/clients/smart-archive-dialog.tsx index 48a24eba..bcc3c4fd 100644 --- a/src/components/clients/smart-archive-dialog.tsx +++ b/src/components/clients/smart-archive-dialog.tsx @@ -18,6 +18,7 @@ import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Textarea } from '@/components/ui/textarea'; import { apiFetch } from '@/lib/api/client'; +import { stageLabelFor } from '@/lib/constants'; import { toastError } from '@/lib/api/toast-error'; interface DossierBerth { @@ -339,7 +340,7 @@ function SmartArchiveDialogBody({ {i.interestId.slice(0, 8)} - {i.pipelineStage} + {stageLabelFor(i.pipelineStage)} {i.hasSignedEoi && Signed EOI} @@ -411,7 +412,9 @@ function SmartArchiveDialogBody({ Releasing will notify the sales rep. Other interests on this berth:{' '} {b.otherInterests .slice(0, 3) - .map((o) => `${o.clientName ?? '?'} (${o.pipelineStage})`) + .map( + (o) => `${o.clientName ?? '?'} (${stageLabelFor(o.pipelineStage)})`, + ) .join(', ')} {b.otherInterests.length > 3 ? ` +${b.otherInterests.length - 3}` : ''}

diff --git a/src/lib/db/schema/clients.ts b/src/lib/db/schema/clients.ts index 8e29c2ba..eb25dede 100644 --- a/src/lib/db/schema/clients.ts +++ b/src/lib/db/schema/clients.ts @@ -40,7 +40,7 @@ export const clients = pgTable( /** Better-auth user id of the operator who archived this client. */ archivedBy: text('archived_by'), /** Free-text reason captured at archive time. Required when archiving a - * client at deposit_10pct or later (compliance trail). Optional when + * client at deposit_paid or later (compliance trail). Optional when * archiving an early-stage lead. */ archiveReason: text('archive_reason'), /** Per-decision metadata captured during smart-archive flow. Used by diff --git a/src/lib/db/schema/financial.ts b/src/lib/db/schema/financial.ts index c78f7af3..18962bd9 100644 --- a/src/lib/db/schema/financial.ts +++ b/src/lib/db/schema/financial.ts @@ -120,7 +120,7 @@ export const invoices = pgTable( pdfFileId: text('pdf_file_id').references(() => files.id), /** Optional link to a sales interest. When the invoice is paid and `kind` * is 'deposit', recordPayment auto-advances the interest's pipelineStage - * to deposit_10pct (no-op if already further along). */ + * to deposit_paid (no-op if already further along). */ interestId: text('interest_id').references(() => interests.id, { onDelete: 'set null' }), /** Invoice kind. 'general' (default) is everyday billing; 'deposit' marks * the 10% berth-purchase deposit and is what triggers the stage advance. */ diff --git a/src/lib/db/schema/users.ts b/src/lib/db/schema/users.ts index 31b93fbd..d33907d9 100644 --- a/src/lib/db/schema/users.ts +++ b/src/lib/db/schema/users.ts @@ -18,10 +18,10 @@ export type RolePermissions = { edit: boolean; delete: boolean; change_stage: boolean; - /** Bypass the canTransitionStage table (e.g. mark a contract_signed - * deal as completed without going through deposit_10pct first when - * the data was entered out of order). Audit-logged with the reason - * the rep gives. Sales-team-restricted. */ + /** Bypass the canTransitionStage table (e.g. jump a deal straight to + * Contract without going through Deposit Paid first when the data + * was entered out of order). Audit-logged with the reason the rep + * gives. Sales-team-restricted. */ override_stage: boolean; generate_eoi: boolean; export: boolean; diff --git a/src/lib/services/berth-recommender.service.ts b/src/lib/services/berth-recommender.service.ts index 90f6148d..e8f0b394 100644 --- a/src/lib/services/berth-recommender.service.ts +++ b/src/lib/services/berth-recommender.service.ts @@ -184,20 +184,32 @@ export async function loadRecommenderSettings(portId: string): Promise = { + // modern + enquiry: 1, + qualified: 2, + nurturing: 2, + eoi: 3, + reservation: 4, + deposit_paid: 5, + contract: 6, + // legacy aliases open: 1, - details_sent: 2, - in_communication: 3, - eoi_sent: 4, - eoi_signed: 5, - deposit_10pct: 6, - contract_sent: 7, - contract_signed: 8, - completed: 9, + details_sent: 1, + in_communication: 2, + eoi_sent: 3, + eoi_signed: 3, + deposit_10pct: 5, + contract_sent: 6, + contract_signed: 6, + completed: 6, }; /** Stage at which a berth is "in late stage" (Tier D when active). */ -const LATE_STAGE_THRESHOLD = STAGE_ORDER.deposit_10pct!; // 6 +const LATE_STAGE_THRESHOLD = STAGE_ORDER.deposit_paid!; // 5 export type Tier = 'A' | 'B' | 'C' | 'D'; diff --git a/src/lib/services/client-archive-dossier.service.ts b/src/lib/services/client-archive-dossier.service.ts index cf2dab81..15609e0f 100644 --- a/src/lib/services/client-archive-dossier.service.ts +++ b/src/lib/services/client-archive-dossier.service.ts @@ -119,7 +119,7 @@ export interface ClientArchiveDossier { * confirmation + reason required). */ stakeLevel: ArchiveStakeLevel; /** The interest stage that earned the high-stakes classification (so - * the UI can explain "this client is in deposit_10pct, please confirm"). + * the UI can explain "this client is in Deposit Paid, please confirm"). * Null when low-stakes. */ highStakesStage: PipelineStage | null; diff --git a/src/lib/services/client-archive.service.ts b/src/lib/services/client-archive.service.ts index 1b36bf83..a36ea474 100644 --- a/src/lib/services/client-archive.service.ts +++ b/src/lib/services/client-archive.service.ts @@ -145,7 +145,7 @@ export async function archiveClientWithDecisions(args: { if (dossier.stakeLevel === 'high' && !decisions.reason.trim()) { throw new ValidationError( - 'A reason is required when archiving a client at deposit_10pct or later.', + 'A reason is required when archiving a client at Deposit Paid or later.', ); } diff --git a/src/lib/services/clients.service.ts b/src/lib/services/clients.service.ts index 69364979..b72c82bd 100644 --- a/src/lib/services/clients.service.ts +++ b/src/lib/services/clients.service.ts @@ -252,21 +252,36 @@ export async function listClients(portId: string, query: ListClientsInput) { // Aggregate berths per client, sorted so the most-action-worthy // interest floats to the top of the chip row. Priority: // 1. open outcome (active deal) before closed (won/lost/cancelled) - // 2. within open: most progressed stage first (contract_signed > … > open) + // 2. within open: most progressed stage first (contract > … > enquiry) // 3. tie-breaker: mooring number alphabetical for stable ordering // The list-view UI shows the top 2 with full labels; the rest fall // through into a "+N more" popover. + // + // L-001 fix: pre-refactor this map used the 9-stage legacy names + // (contract_signed, deposit_10pct, …) and every modern 7-stage value + // fell through to rank 0, making the sort effectively random for any + // post-refactor interest. Modern values now own the canonical ranks + // and legacy keys map to their 7-stage equivalents so historical data + // continues to sort correctly. const stageRank: Record = { + // modern (post 9→7 refactor) + contract: 1, + deposit_paid: 2, + reservation: 3, + eoi: 4, + nurturing: 5, + qualified: 6, + enquiry: 7, + // legacy aliases — kept so audit-log + soft-archive data sorts the same contract_signed: 1, + contract_sent: 1, + completed: 1, deposit_10pct: 2, - contract_sent: 3, eoi_signed: 4, - eoi_sent: 5, + eoi_sent: 4, in_communication: 6, details_sent: 7, - qualified: 8, - open: 9, - completed: 10, + open: 7, }; type LinkedBerth = { id: string; diff --git a/src/lib/validators/interests.ts b/src/lib/validators/interests.ts index 4e3653ca..65bf3731 100644 --- a/src/lib/validators/interests.ts +++ b/src/lib/validators/interests.ts @@ -129,7 +129,8 @@ export const setOutcomeSchema = z.object({ }); export const clearOutcomeSchema = z.object({ - // Stage to revert to when reopening (defaults to in_communication). + // Stage to revert to when reopening. When omitted the service picks the + // stage immediately before the outcome was set; falls back to qualified. reopenStage: z.enum(PIPELINE_STAGES).optional(), }); diff --git a/tests/unit/services/berth-recommender.test.ts b/tests/unit/services/berth-recommender.test.ts index 5091b2a1..c5e6004a 100644 --- a/tests/unit/services/berth-recommender.test.ts +++ b/tests/unit/services/berth-recommender.test.ts @@ -13,18 +13,21 @@ describe('classifyTier', () => { it('"B" when only lost interests exist (no active)', () => { expect(classifyTier({ activeInterestCount: 0, lostCount: 2, maxActiveStage: 0 })).toBe('B'); }); - it('"C" when an active interest is in an early stage', () => { + // L-001 renumber: 7-stage ranks are 1=enquiry, 2=qualified/nurturing, + // 3=eoi, 4=reservation, 5=deposit_paid, 6=contract. Tier D fires at + // deposit_paid (5) or later. + it('"C" when an active interest is in an early stage (eoi)', () => { expect(classifyTier({ activeInterestCount: 1, lostCount: 0, maxActiveStage: 3 })).toBe('C'); }); it('"C" even when a prior interest was lost, if there is an active one', () => { expect(classifyTier({ activeInterestCount: 1, lostCount: 5, maxActiveStage: 2 })).toBe('C'); }); it('"D" when an active interest is at deposit or beyond', () => { + expect(classifyTier({ activeInterestCount: 1, lostCount: 0, maxActiveStage: 5 })).toBe('D'); expect(classifyTier({ activeInterestCount: 1, lostCount: 0, maxActiveStage: 6 })).toBe('D'); - expect(classifyTier({ activeInterestCount: 1, lostCount: 0, maxActiveStage: 8 })).toBe('D'); }); - it('still "C" at eoi_signed (stage 5) - tier D only kicks in at deposit', () => { - expect(classifyTier({ activeInterestCount: 1, lostCount: 0, maxActiveStage: 5 })).toBe('C'); + it('still "C" at reservation (stage 4) - tier D only kicks in at deposit', () => { + expect(classifyTier({ activeInterestCount: 1, lostCount: 0, maxActiveStage: 4 })).toBe('C'); }); }); @@ -81,7 +84,7 @@ describe('computeHeat', () => { latestFallthroughAt: null, totalInterestCount: 0, eoiSignedCount: 0, - fallthroughMaxStage: 6, // deposit_10pct + fallthroughMaxStage: 5, // deposit_paid (was deposit_10pct=6 pre-refactor) }, w, NOW,