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:
@@ -121,11 +121,11 @@ def main():
|
||||
print("\n[link: creates one linked opportunity with seeds]")
|
||||
st, d = _req(port, "POST", "/api/fundraising/pipeline/link", token, {
|
||||
"source_row_id": "rowAcme", "fund_name": "Fund III",
|
||||
"expected_amount": 250000, "probability": 40, "stage": "outreach",
|
||||
"expected_amount": 250000, "probability": 40, "stage": "engaged",
|
||||
})
|
||||
opp = (d or {}).get("data") or {}
|
||||
check(st == 201 and (d or {}).get("already_linked") is False, f"link -> 201 new (got {st}, {d})")
|
||||
check(opp.get("stage") == "outreach" and opp.get("expected_amount") == 250000
|
||||
check(opp.get("stage") == "engaged" and opp.get("expected_amount") == 250000
|
||||
and opp.get("probability") == 40 and opp.get("fund_name") == "Fund III",
|
||||
f"seeds applied (got {{stage:{opp.get('stage')}, amt:{opp.get('expected_amount')}, "
|
||||
f"prob:{opp.get('probability')}, fund:{opp.get('fund_name')}}})")
|
||||
@@ -138,14 +138,14 @@ def main():
|
||||
|
||||
# ── idempotent re-link: returns existing, board-owned stage NOT reseeded ──
|
||||
print("\n[idempotent: re-link returns existing opp without reseeding funnel fields]")
|
||||
st, _ = _req(port, "PATCH", f"/api/opportunities/{opp_id}/stage", token, {"stage": "meeting"})
|
||||
check(st == 200, f"advance stage on the board -> meeting (got {st})")
|
||||
st, _ = _req(port, "PATCH", f"/api/opportunities/{opp_id}/stage", token, {"stage": "diligence"})
|
||||
check(st == 200, f"advance stage on the board -> diligence (got {st})")
|
||||
st, d = _req(port, "POST", "/api/fundraising/pipeline/link", token, {
|
||||
"source_row_id": "rowAcme", "stage": "lead", "expected_amount": 999, "probability": 5,
|
||||
})
|
||||
opp2 = (d or {}).get("data") or {}
|
||||
check(st == 200 and (d or {}).get("already_linked") is True, f"re-link -> already_linked (got {st}, {d})")
|
||||
check(opp2.get("stage") == "meeting" and opp2.get("expected_amount") == 250000,
|
||||
check(opp2.get("stage") == "diligence" and opp2.get("expected_amount") == 250000,
|
||||
f"funnel fields preserved, not reseeded (got stage={opp2.get('stage')}, amt={opp2.get('expected_amount')})")
|
||||
check(_opp_count_live(fr_id) == 1, "still exactly one live opp (no duplicate)")
|
||||
|
||||
@@ -154,9 +154,13 @@ def main():
|
||||
st, d = _req(port, "GET", "/api/fundraising/state", token)
|
||||
rows = {r["id"]: r for r in (d or {}).get("data", {}).get("grid", {}).get("rows", [])}
|
||||
check(rows.get("rowAcme", {}).get("pipeline") is True
|
||||
and rows.get("rowAcme", {}).get("pipeline_stage") == "meeting",
|
||||
f"rowAcme pipeline true @meeting (got {rows.get('rowAcme', {}).get('pipeline')}, "
|
||||
and rows.get("rowAcme", {}).get("pipeline_stage") == "diligence",
|
||||
f"rowAcme pipeline true @diligence (got {rows.get('rowAcme', {}).get('pipeline')}, "
|
||||
f"{rows.get('rowAcme', {}).get('pipeline_stage')})")
|
||||
# Phase 0 derived signals are injected read-only on every row (values depend on seed;
|
||||
# assert the keys are present so the strip/inject round-trip below is meaningful).
|
||||
check(all(k in rows.get("rowAcme", {}) for k in ("existing_investor", "staleness", "last_activity_at")),
|
||||
f"rowAcme carries derived existing_investor/staleness/last_activity (keys: {sorted(rows.get('rowAcme', {}).keys())})")
|
||||
check(rows.get("rowBeta", {}).get("pipeline") is False
|
||||
and rows.get("rowBeta", {}).get("pipeline_stage") == "",
|
||||
f"rowBeta not in pipeline (got {rows.get('rowBeta', {}).get('pipeline')})")
|
||||
@@ -172,12 +176,15 @@ def main():
|
||||
blob = json.loads(c.execute("SELECT grid_json FROM fundraising_state WHERE id='main'").fetchone()[0])
|
||||
c.close()
|
||||
stored_acme = {r["id"]: r for r in blob.get("rows", [])}.get("rowAcme", {})
|
||||
check("pipeline" not in stored_acme and "pipeline_stage" not in stored_acme,
|
||||
"computed keys are NOT persisted into the grid blob")
|
||||
check(not any(k in stored_acme for k in ("pipeline", "pipeline_stage", "existing_investor",
|
||||
"staleness", "last_activity_at")),
|
||||
"computed keys (pipeline + existing_investor/staleness/last_activity) NOT persisted into the grid blob")
|
||||
st, d = _req(port, "GET", "/api/fundraising/state", token)
|
||||
rt = {r["id"]: r for r in (d or {}).get("data", {}).get("grid", {}).get("rows", [])}.get("rowAcme", {})
|
||||
check(rt.get("pipeline") is True and rt.get("pipeline_stage") == "meeting",
|
||||
check(rt.get("pipeline") is True and rt.get("pipeline_stage") == "diligence",
|
||||
f"pipeline values re-injected after round-trip (got {rt.get('pipeline')}, {rt.get('pipeline_stage')})")
|
||||
check(all(k in rt for k in ("existing_investor", "staleness", "last_activity_at")),
|
||||
"derived signals re-injected after round-trip")
|
||||
|
||||
# ── guards ──
|
||||
print("\n[guard: a contactless row cannot be added to the pipeline]")
|
||||
@@ -234,7 +241,7 @@ def main():
|
||||
# ── re-link after unlink: a fresh opp is created (the archived one stays archived) ──
|
||||
print("\n[re-link after unlink: creates a new opp, flag reappears]")
|
||||
st, d = _req(port, "POST", "/api/fundraising/pipeline/link", token, {
|
||||
"source_row_id": "rowAcme", "stage": "outreach", "expected_amount": 50000,
|
||||
"source_row_id": "rowAcme", "stage": "engaged", "expected_amount": 50000,
|
||||
})
|
||||
relinked = (d or {}).get("data") or {}
|
||||
check(st == 201 and (d or {}).get("already_linked") is False and relinked.get("id") != opp_id,
|
||||
|
||||
Reference in New Issue
Block a user