fix(legacy-stage): purge 9-stage enum keys from rank tables and stale copy
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) <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,7 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { stageLabelFor } from '@/lib/constants';
|
||||||
import { toastError } from '@/lib/api/toast-error';
|
import { toastError } from '@/lib/api/toast-error';
|
||||||
|
|
||||||
interface DossierBerth {
|
interface DossierBerth {
|
||||||
@@ -339,7 +340,7 @@ function SmartArchiveDialogBody({
|
|||||||
<span className="font-mono">{i.interestId.slice(0, 8)}</span>
|
<span className="font-mono">{i.interestId.slice(0, 8)}</span>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
{i.pipelineStage}
|
{stageLabelFor(i.pipelineStage)}
|
||||||
</Badge>
|
</Badge>
|
||||||
{i.hasSignedEoi && <Badge className="text-xs">Signed EOI</Badge>}
|
{i.hasSignedEoi && <Badge className="text-xs">Signed EOI</Badge>}
|
||||||
</span>
|
</span>
|
||||||
@@ -411,7 +412,9 @@ function SmartArchiveDialogBody({
|
|||||||
Releasing will notify the sales rep. Other interests on this berth:{' '}
|
Releasing will notify the sales rep. Other interests on this berth:{' '}
|
||||||
{b.otherInterests
|
{b.otherInterests
|
||||||
.slice(0, 3)
|
.slice(0, 3)
|
||||||
.map((o) => `${o.clientName ?? '?'} (${o.pipelineStage})`)
|
.map(
|
||||||
|
(o) => `${o.clientName ?? '?'} (${stageLabelFor(o.pipelineStage)})`,
|
||||||
|
)
|
||||||
.join(', ')}
|
.join(', ')}
|
||||||
{b.otherInterests.length > 3 ? ` +${b.otherInterests.length - 3}` : ''}
|
{b.otherInterests.length > 3 ? ` +${b.otherInterests.length - 3}` : ''}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export const clients = pgTable(
|
|||||||
/** Better-auth user id of the operator who archived this client. */
|
/** Better-auth user id of the operator who archived this client. */
|
||||||
archivedBy: text('archived_by'),
|
archivedBy: text('archived_by'),
|
||||||
/** Free-text reason captured at archive time. Required when archiving a
|
/** 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. */
|
* archiving an early-stage lead. */
|
||||||
archiveReason: text('archive_reason'),
|
archiveReason: text('archive_reason'),
|
||||||
/** Per-decision metadata captured during smart-archive flow. Used by
|
/** Per-decision metadata captured during smart-archive flow. Used by
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export const invoices = pgTable(
|
|||||||
pdfFileId: text('pdf_file_id').references(() => files.id),
|
pdfFileId: text('pdf_file_id').references(() => files.id),
|
||||||
/** Optional link to a sales interest. When the invoice is paid and `kind`
|
/** Optional link to a sales interest. When the invoice is paid and `kind`
|
||||||
* is 'deposit', recordPayment auto-advances the interest's pipelineStage
|
* 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' }),
|
interestId: text('interest_id').references(() => interests.id, { onDelete: 'set null' }),
|
||||||
/** Invoice kind. 'general' (default) is everyday billing; 'deposit' marks
|
/** Invoice kind. 'general' (default) is everyday billing; 'deposit' marks
|
||||||
* the 10% berth-purchase deposit and is what triggers the stage advance. */
|
* the 10% berth-purchase deposit and is what triggers the stage advance. */
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ export type RolePermissions = {
|
|||||||
edit: boolean;
|
edit: boolean;
|
||||||
delete: boolean;
|
delete: boolean;
|
||||||
change_stage: boolean;
|
change_stage: boolean;
|
||||||
/** Bypass the canTransitionStage table (e.g. mark a contract_signed
|
/** Bypass the canTransitionStage table (e.g. jump a deal straight to
|
||||||
* deal as completed without going through deposit_10pct first when
|
* Contract without going through Deposit Paid first when the data
|
||||||
* the data was entered out of order). Audit-logged with the reason
|
* was entered out of order). Audit-logged with the reason the rep
|
||||||
* the rep gives. Sales-team-restricted. */
|
* gives. Sales-team-restricted. */
|
||||||
override_stage: boolean;
|
override_stage: boolean;
|
||||||
generate_eoi: boolean;
|
generate_eoi: boolean;
|
||||||
export: boolean;
|
export: boolean;
|
||||||
|
|||||||
@@ -184,20 +184,32 @@ export async function loadRecommenderSettings(portId: string): Promise<Recommend
|
|||||||
|
|
||||||
// ─── Tier mapping ──────────────────────────────────────────────────────────
|
// ─── Tier mapping ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// L-001: modern 7-stage ranks own the canonical positions; legacy 9-stage
|
||||||
|
// keys map to their post-refactor equivalents so historical interests in
|
||||||
|
// the recommender stage-aware queries continue to rank correctly.
|
||||||
const STAGE_ORDER: Record<string, number> = {
|
const STAGE_ORDER: Record<string, number> = {
|
||||||
|
// modern
|
||||||
|
enquiry: 1,
|
||||||
|
qualified: 2,
|
||||||
|
nurturing: 2,
|
||||||
|
eoi: 3,
|
||||||
|
reservation: 4,
|
||||||
|
deposit_paid: 5,
|
||||||
|
contract: 6,
|
||||||
|
// legacy aliases
|
||||||
open: 1,
|
open: 1,
|
||||||
details_sent: 2,
|
details_sent: 1,
|
||||||
in_communication: 3,
|
in_communication: 2,
|
||||||
eoi_sent: 4,
|
eoi_sent: 3,
|
||||||
eoi_signed: 5,
|
eoi_signed: 3,
|
||||||
deposit_10pct: 6,
|
deposit_10pct: 5,
|
||||||
contract_sent: 7,
|
contract_sent: 6,
|
||||||
contract_signed: 8,
|
contract_signed: 6,
|
||||||
completed: 9,
|
completed: 6,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Stage at which a berth is "in late stage" (Tier D when active). */
|
/** 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';
|
export type Tier = 'A' | 'B' | 'C' | 'D';
|
||||||
|
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ export interface ClientArchiveDossier {
|
|||||||
* confirmation + reason required). */
|
* confirmation + reason required). */
|
||||||
stakeLevel: ArchiveStakeLevel;
|
stakeLevel: ArchiveStakeLevel;
|
||||||
/** The interest stage that earned the high-stakes classification (so
|
/** 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. */
|
* Null when low-stakes. */
|
||||||
highStakesStage: PipelineStage | null;
|
highStakesStage: PipelineStage | null;
|
||||||
|
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ export async function archiveClientWithDecisions(args: {
|
|||||||
|
|
||||||
if (dossier.stakeLevel === 'high' && !decisions.reason.trim()) {
|
if (dossier.stakeLevel === 'high' && !decisions.reason.trim()) {
|
||||||
throw new ValidationError(
|
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.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -252,21 +252,36 @@ export async function listClients(portId: string, query: ListClientsInput) {
|
|||||||
// Aggregate berths per client, sorted so the most-action-worthy
|
// Aggregate berths per client, sorted so the most-action-worthy
|
||||||
// interest floats to the top of the chip row. Priority:
|
// interest floats to the top of the chip row. Priority:
|
||||||
// 1. open outcome (active deal) before closed (won/lost/cancelled)
|
// 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
|
// 3. tie-breaker: mooring number alphabetical for stable ordering
|
||||||
// The list-view UI shows the top 2 with full labels; the rest fall
|
// The list-view UI shows the top 2 with full labels; the rest fall
|
||||||
// through into a "+N more" popover.
|
// 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<string, number> = {
|
const stageRank: Record<string, number> = {
|
||||||
|
// 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_signed: 1,
|
||||||
|
contract_sent: 1,
|
||||||
|
completed: 1,
|
||||||
deposit_10pct: 2,
|
deposit_10pct: 2,
|
||||||
contract_sent: 3,
|
|
||||||
eoi_signed: 4,
|
eoi_signed: 4,
|
||||||
eoi_sent: 5,
|
eoi_sent: 4,
|
||||||
in_communication: 6,
|
in_communication: 6,
|
||||||
details_sent: 7,
|
details_sent: 7,
|
||||||
qualified: 8,
|
open: 7,
|
||||||
open: 9,
|
|
||||||
completed: 10,
|
|
||||||
};
|
};
|
||||||
type LinkedBerth = {
|
type LinkedBerth = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -129,7 +129,8 @@ export const setOutcomeSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const clearOutcomeSchema = 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(),
|
reopenStage: z.enum(PIPELINE_STAGES).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13,18 +13,21 @@ describe('classifyTier', () => {
|
|||||||
it('"B" when only lost interests exist (no active)', () => {
|
it('"B" when only lost interests exist (no active)', () => {
|
||||||
expect(classifyTier({ activeInterestCount: 0, lostCount: 2, maxActiveStage: 0 })).toBe('B');
|
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');
|
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', () => {
|
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');
|
expect(classifyTier({ activeInterestCount: 1, lostCount: 5, maxActiveStage: 2 })).toBe('C');
|
||||||
});
|
});
|
||||||
it('"D" when an active interest is at deposit or beyond', () => {
|
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: 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', () => {
|
it('still "C" at reservation (stage 4) - tier D only kicks in at deposit', () => {
|
||||||
expect(classifyTier({ activeInterestCount: 1, lostCount: 0, maxActiveStage: 5 })).toBe('C');
|
expect(classifyTier({ activeInterestCount: 1, lostCount: 0, maxActiveStage: 4 })).toBe('C');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,7 +84,7 @@ describe('computeHeat', () => {
|
|||||||
latestFallthroughAt: null,
|
latestFallthroughAt: null,
|
||||||
totalInterestCount: 0,
|
totalInterestCount: 0,
|
||||||
eoiSignedCount: 0,
|
eoiSignedCount: 0,
|
||||||
fallthroughMaxStage: 6, // deposit_10pct
|
fallthroughMaxStage: 5, // deposit_paid (was deposit_10pct=6 pre-refactor)
|
||||||
},
|
},
|
||||||
w,
|
w,
|
||||||
NOW,
|
NOW,
|
||||||
|
|||||||
Reference in New Issue
Block a user