Pipeline funnel v2: 4-stage enum + migration 0007 + derived grid signals
Collapse the inherited 6-stage opportunity funnel to the locked 4-stage
per-investor funnel (lead -> engaged -> diligence -> commitment), terminal at
commitment. Migration 0007 remaps existing stage values (outreach/meeting ->
engaged, due_diligence -> diligence, committed/funded -> commitment) and
archives the stray 'lost' value (the grid row is left intact). Inject read-only
existing_investor (total_invested>0), last_activity_at, and staleness
(''/'aging'>=30d/'stale'>=60d) into the grid GET, stripped on write. Frontend:
4-stage chip tints + Pipeline board / opp-form / mock on the new enum.
The visible desktop existing-investor star + staleness recency column + the
Stale saved view are deferred to mobile Phase 3 (data is injected + test-locked
now, so that phase stays pure-frontend). Longshot was already retired by prior
cleanup -- no-op.
Tests: test_pipeline_stages_v2.py (migration remap + derivation boundaries) +
updated grid-pipeline-link / soft-delete / nl_query; 36/36 green, render-smoke
green, fresh-DB migrate clean.
This commit is contained in:
+85
-16
@@ -1596,7 +1596,8 @@ def sanitize_fundraising_grid(grid):
|
||||
# linked opportunity and injected on read — never persisted as row data (the GET handler
|
||||
# re-injects them after sanitize). The column DEFINITIONS persist like any other column
|
||||
# so their position / width / hidden state is kept.
|
||||
_computed_row_values = ('longshot_followup', 'pipeline', 'pipeline_stage', 'reminder_status')
|
||||
_computed_row_values = ('longshot_followup', 'pipeline', 'pipeline_stage', 'reminder_status',
|
||||
'existing_investor', 'last_activity_at', 'staleness')
|
||||
|
||||
clean_columns = []
|
||||
seen = set()
|
||||
@@ -1794,6 +1795,59 @@ def reminder_status_by_source_row(conn):
|
||||
out[srid] = st
|
||||
return out
|
||||
|
||||
|
||||
# Staleness ramp — one global threshold set (locked spec 2026-06-19): the last-contact recency
|
||||
# value colors fresh (grey) -> aging (amber) >= STALE_AGING_DAYS -> stale (red) >= STALE_DAYS.
|
||||
# Not stage-aware for v1. The same `staleness`/`last_activity_at` the grid injects also drives the
|
||||
# mobile card and (a user-built) "Stale" view, so everything color-codes off one server signal.
|
||||
STALE_AGING_DAYS = 30
|
||||
STALE_DAYS = 60
|
||||
|
||||
def existing_investor_by_source_row(conn):
|
||||
"""Return the set of grid source_row_ids whose investor has any committed capital
|
||||
(fundraising_investors.total_invested > 0) — the auto-derived "Existing Investor" flag
|
||||
(locked spec 2026-06-19). Injected read-only on grid read like pipeline_stage; never a
|
||||
maintained column. Orthogonal to stage: a re-solicited LP shows the star AND a live stage."""
|
||||
out = set()
|
||||
for r in conn.execute(
|
||||
"SELECT source_row_id FROM fundraising_investors WHERE total_invested > 0"
|
||||
).fetchall():
|
||||
srid = str(r['source_row_id'] or '')
|
||||
if srid:
|
||||
out.add(srid)
|
||||
return out
|
||||
|
||||
|
||||
def staleness_by_source_row(conn):
|
||||
"""Return {grid source_row_id: (last_activity_iso_or_None, staleness)} where staleness is
|
||||
'' (fresh or no recorded activity), 'aging' (>= STALE_AGING_DAYS since last contact), or
|
||||
'stale' (>= STALE_DAYS). Derived from the SAME last_activity_by_investor signal the reminders
|
||||
surface uses, so desktop grid + mobile card color-code identically. A row with no recorded
|
||||
activity gets '' (no false "stale" on a brand-new lead); the W1b nurture-gap nudge handles
|
||||
in-pipeline-with-no-activity separately."""
|
||||
last_by_inv = last_activity_by_investor(conn)
|
||||
out = {}
|
||||
today = datetime.utcnow().date()
|
||||
for r in conn.execute("SELECT id, source_row_id FROM fundraising_investors").fetchall():
|
||||
srid = str(r['source_row_id'] or '')
|
||||
if not srid:
|
||||
continue
|
||||
ts = last_by_inv.get(r['id'])
|
||||
level = ''
|
||||
if ts:
|
||||
try:
|
||||
d = datetime.strptime(str(ts)[:10], '%Y-%m-%d').date()
|
||||
age = (today - d).days
|
||||
if age >= STALE_DAYS:
|
||||
level = 'stale'
|
||||
elif age >= STALE_AGING_DAYS:
|
||||
level = 'aging'
|
||||
except ValueError:
|
||||
pass
|
||||
out[srid] = (ts, level)
|
||||
return out
|
||||
|
||||
|
||||
def maybe_run_scheduled_backup():
|
||||
conn = get_db()
|
||||
try:
|
||||
@@ -1830,7 +1884,11 @@ def start_backup_scheduler():
|
||||
|
||||
# ─── Request Handler ──────────────────────────────────────────────────────────
|
||||
|
||||
PIPELINE_STAGES = ['lead', 'outreach', 'meeting', 'due_diligence', 'committed', 'funded']
|
||||
# 4-stage per-investor funnel, terminal at 'commitment' (locked spec 2026-06-19). On commit the
|
||||
# deal is handed to fund admin + the $ recorded in the grid fund cell — there is no 'funded'/'lost'
|
||||
# stage (the grid's committed $ and the graveyard flag carry those). Migration 0007 remapped the
|
||||
# legacy 6-stage values. Keep the frontend kanban + opp-form + nl_query rank in sync with this list.
|
||||
PIPELINE_STAGES = ['lead', 'engaged', 'diligence', 'commitment']
|
||||
CONTACT_TYPES = ['investor', 'prospect', 'advisor', 'other']
|
||||
COMM_TYPES = ['email', 'call', 'meeting', 'note', 'text']
|
||||
|
||||
@@ -2718,7 +2776,7 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
query = """
|
||||
SELECT o.*,
|
||||
(SELECT COUNT(*) FROM contacts WHERE organization_id = o.id AND deleted_at IS NULL) as contact_count,
|
||||
(SELECT COALESCE(SUM(commitment_amount), 0) FROM opportunities WHERE organization_id = o.id AND stage = 'funded' AND deleted_at IS NULL) as total_funded
|
||||
(SELECT COALESCE(SUM(commitment_amount), 0) FROM opportunities WHERE organization_id = o.id AND stage = 'commitment' AND deleted_at IS NULL) as total_funded
|
||||
FROM organizations o WHERE 1=1 AND o.deleted_at IS NULL
|
||||
"""
|
||||
args = []
|
||||
@@ -3763,11 +3821,11 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
).fetchone()['total']
|
||||
|
||||
pipeline_value = conn.execute(
|
||||
"SELECT COALESCE(SUM(expected_amount), 0) as total FROM opportunities WHERE stage NOT IN ('funded', 'lost') AND deleted_at IS NULL"
|
||||
"SELECT COALESCE(SUM(expected_amount), 0) as total FROM opportunities WHERE stage != 'commitment' AND deleted_at IS NULL"
|
||||
).fetchone()['total']
|
||||
|
||||
active_opportunities = conn.execute(
|
||||
"SELECT COUNT(*) as c FROM opportunities WHERE stage NOT IN ('funded', 'lost') AND deleted_at IS NULL"
|
||||
"SELECT COUNT(*) as c FROM opportunities WHERE stage != 'commitment' AND deleted_at IS NULL"
|
||||
).fetchone()['c']
|
||||
|
||||
# Pipeline by stage
|
||||
@@ -3775,11 +3833,11 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
SELECT stage, COUNT(*) as count, COALESCE(SUM(expected_amount), 0) as total_value,
|
||||
COALESCE(SUM(commitment_amount), 0) as committed_value
|
||||
FROM opportunities
|
||||
WHERE stage != 'lost' AND deleted_at IS NULL
|
||||
WHERE deleted_at IS NULL
|
||||
GROUP BY stage
|
||||
ORDER BY CASE stage
|
||||
WHEN 'lead' THEN 1 WHEN 'outreach' THEN 2 WHEN 'meeting' THEN 3
|
||||
WHEN 'due_diligence' THEN 4 WHEN 'committed' THEN 5 WHEN 'funded' THEN 6
|
||||
WHEN 'lead' THEN 1 WHEN 'engaged' THEN 2
|
||||
WHEN 'diligence' THEN 3 WHEN 'commitment' THEN 4
|
||||
END
|
||||
""").fetchall())
|
||||
|
||||
@@ -3855,8 +3913,8 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
WHERE deleted_at IS NULL
|
||||
GROUP BY stage
|
||||
ORDER BY CASE stage
|
||||
WHEN 'lead' THEN 1 WHEN 'outreach' THEN 2 WHEN 'meeting' THEN 3
|
||||
WHEN 'due_diligence' THEN 4 WHEN 'committed' THEN 5 WHEN 'funded' THEN 6
|
||||
WHEN 'lead' THEN 1 WHEN 'engaged' THEN 2
|
||||
WHEN 'diligence' THEN 3 WHEN 'commitment' THEN 4
|
||||
END
|
||||
""").fetchall())
|
||||
|
||||
@@ -3874,7 +3932,7 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
SELECT priority, COUNT(*) as count,
|
||||
COALESCE(SUM(expected_amount), 0) as total_expected
|
||||
FROM opportunities
|
||||
WHERE stage NOT IN ('funded', 'lost') AND deleted_at IS NULL
|
||||
WHERE stage != 'commitment' AND deleted_at IS NULL
|
||||
GROUP BY priority
|
||||
""").fetchall())
|
||||
|
||||
@@ -5492,6 +5550,8 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
row = conn.execute("SELECT * FROM fundraising_state WHERE id = 'main'").fetchone()
|
||||
stage_by_row = pipeline_stage_by_source_row(conn)
|
||||
reminder_by_row = reminder_status_by_source_row(conn)
|
||||
existing_by_row = existing_investor_by_source_row(conn)
|
||||
recency_by_row = staleness_by_source_row(conn)
|
||||
conn.close()
|
||||
|
||||
try:
|
||||
@@ -5523,6 +5583,15 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
# in the blob). '' = no open reminder; a saved view can filter on this column to
|
||||
# supersede the binary follow_up checkbox.
|
||||
r['reminder_status'] = reminder_by_row.get(str(r.get('id') or ''), '')
|
||||
# Auto-derived "Existing Investor" flag (total_invested > 0) + last-contact recency
|
||||
# and its staleness ramp ('' / 'aging' / 'stale'). All read-only, computed fresh on
|
||||
# read like the columns above (stripped on write), so the desktop grid and the mobile
|
||||
# card render the star + the grey/amber/red recency off one server signal.
|
||||
srid = str(r.get('id') or '')
|
||||
r['existing_investor'] = srid in existing_by_row
|
||||
last_activity, staleness = recency_by_row.get(srid, (None, ''))
|
||||
r['last_activity_at'] = last_activity
|
||||
r['staleness'] = staleness
|
||||
|
||||
return self.send_json({
|
||||
"data": {
|
||||
@@ -5955,12 +6024,12 @@ def seed_demo_data():
|
||||
|
||||
# Create opportunities
|
||||
opp_data = [
|
||||
(contacts[6][0], orgs[6][0], "Cascade Wealth - Fund II", "meeting", 10000000, 10000000, 40, user2_id),
|
||||
(contacts[7][0], orgs[7][0], "Blue Harbor - Fund II", "due_diligence", 5000000, 5000000, 60, user2_id),
|
||||
(contacts[8][0], None, "William Johnson - Direct", "outreach", 0, 2000000, 20, admin_id),
|
||||
(contacts[6][0], orgs[6][0], "Cascade Wealth - Fund II", "engaged", 10000000, 10000000, 40, user2_id),
|
||||
(contacts[7][0], orgs[7][0], "Blue Harbor - Fund II", "diligence", 5000000, 5000000, 60, user2_id),
|
||||
(contacts[8][0], None, "William Johnson - Direct", "lead", 0, 2000000, 20, admin_id),
|
||||
(contacts[9][0], None, "Garcia Family Office - Fund II", "lead", 0, 15000000, 10, admin_id),
|
||||
(contacts[10][0], None, "Thomas Brown - WM Referral", "meeting", 0, 3000000, 30, user2_id),
|
||||
(contacts[11][0], None, "Linda Wilson - PM Intro", "outreach", 0, 5000000, 15, admin_id),
|
||||
(contacts[10][0], None, "Thomas Brown - WM Referral", "engaged", 0, 3000000, 30, user2_id),
|
||||
(contacts[11][0], None, "Linda Wilson - PM Intro", "lead", 0, 5000000, 15, admin_id),
|
||||
]
|
||||
for opp in opp_data:
|
||||
conn.execute("""
|
||||
|
||||
Reference in New Issue
Block a user