Stage 11: Team Shape (FTE) + 27 new tests for Stages 6/7/8
Stage 11 backend (services/team_shape.py + api/team_shape.py):
- Ports V1's calculate_team_shape with the bug-4 fix already applied:
total_hours / manual_override on RatecardLine are per-1-asset; the
aggregator multiplies by line.volume.
- Programme roles never see efficiency cuts. Per-discipline efficiency
is capped at MAX_EFFICIENCY (90%).
- GET /opportunities/{id}/team-shape supports two modes:
* `efficiency_pct=N` blanket reduction across delivery roles, OR
* `discipline_overrides=<JSON>` per-discipline rates (Stage 10
efficiency profile output).
- Smoke-tested: 5,620.5 hrs / 3.12 FTE on the Versuni opportunity;
applying efficiency_pct=50 correctly halves to 2,810.25 hrs / 1.56 FTE.
Stage 11 frontend (Stage11TeamShape.tsx + api/teamShape.ts):
- Stats card (total / delivery / programme / adjusted FTE).
- Blanket efficiency slider 0–90% (disabled when per-discipline mode is
active so the two modes don't fight).
- One slider card per discipline showing live percentage; clearing all
reverts to the blanket slider.
- Per-discipline FTE table with Total hrs / FTE / Eff% / Adjusted hrs /
Adjusted FTE columns. Programme roles tagged with a badge.
Stage 6/7/8 backend tests (parallel test agent, +27 tests):
- test_assets.py (10): CRUD + sort_order auto-increment + cascade
delete + 400/404 paths + skipped real-Claude normalize.
- test_matching.py (10): 400 on no-assets, GET shape + ordering,
selection toggle deselects siblings, deselect leaves siblings alone,
cross-opp 404, skipped real-Claude run.
- test_ratecard.py (7): 400/404 paths, end-to-end build+get with the
bug-4 invariant explicitly asserted (line.base_hours == total_hours;
summary.total_hours == sum(base × volume)), no-selection skip,
rebuild idempotence, two-asset volume aggregation splits 1:2.
Suite: 100 collected, 95 passed, 5 skipped (all real-Anthropic), 0 failed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a5a50e1dd3
commit
21aecff0cb
9 changed files with 1486 additions and 2 deletions
80
backend/app/api/team_shape.py
Normal file
80
backend/app/api/team_shape.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
"""Stage 11 — team shape (FTE per role) endpoint."""
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.opportunity import Opportunity
|
||||
from app.services.team_shape import calculate_team_shape, HOURS_PER_FTE
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _get_opp(db: AsyncSession, opportunity_id: int) -> Opportunity:
|
||||
result = await db.execute(select(Opportunity).where(Opportunity.id == opportunity_id))
|
||||
opp = result.scalar_one_or_none()
|
||||
if opp is None:
|
||||
raise HTTPException(status_code=404, detail=f"Opportunity {opportunity_id} not found")
|
||||
return opp
|
||||
|
||||
|
||||
@router.get("/{opportunity_id}/team-shape")
|
||||
async def get_team_shape(
|
||||
opportunity_id: int,
|
||||
efficiency_pct: float = 0.0,
|
||||
discipline_overrides: str = "",
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Compute FTE per role from the ratecard.
|
||||
|
||||
Query params:
|
||||
- `efficiency_pct`: blanket percentage applied to every delivery role
|
||||
(programme roles excluded).
|
||||
- `discipline_overrides`: JSON-encoded `{discipline: pct}` map. When
|
||||
provided, takes precedence over `efficiency_pct`.
|
||||
|
||||
Programme roles never see efficiency cuts. Per-discipline efficiency
|
||||
is capped at MAX_EFFICIENCY (90%).
|
||||
"""
|
||||
opp = await _get_opp(db, opportunity_id)
|
||||
|
||||
overrides: dict[str, float] | None = None
|
||||
if discipline_overrides:
|
||||
try:
|
||||
parsed = json.loads(discipline_overrides)
|
||||
if isinstance(parsed, dict):
|
||||
overrides = {str(k): float(v) for k, v in parsed.items()}
|
||||
except (json.JSONDecodeError, TypeError, ValueError):
|
||||
raise HTTPException(status_code=400, detail="discipline_overrides must be a JSON object of {discipline: percent}")
|
||||
|
||||
team = await calculate_team_shape(
|
||||
db, opp,
|
||||
efficiency_pct=efficiency_pct,
|
||||
discipline_overrides=overrides,
|
||||
)
|
||||
|
||||
total_hours = sum(t["total_hours"] for t in team)
|
||||
total_fte = sum(t["fte"] for t in team)
|
||||
adjusted_hours = sum(t["adjusted_hours"] for t in team)
|
||||
adjusted_fte = sum(t["adjusted_fte"] for t in team)
|
||||
programme_fte = sum(t["fte"] for t in team if t["is_programme_role"])
|
||||
|
||||
return {
|
||||
"project_id": opp.id,
|
||||
"project_name": opp.name,
|
||||
"hours_per_fte": HOURS_PER_FTE,
|
||||
"efficiency_pct": efficiency_pct,
|
||||
"discipline_overrides": overrides,
|
||||
"total_hours": round(total_hours, 2),
|
||||
"total_fte": round(total_fte, 4),
|
||||
"adjusted_hours": round(adjusted_hours, 2),
|
||||
"adjusted_fte": round(adjusted_fte, 4),
|
||||
"delivery_fte": round(total_fte - programme_fte, 4),
|
||||
"programme_fte": round(programme_fte, 4),
|
||||
"hours_saved": round(total_hours - adjusted_hours, 2),
|
||||
"fte_saved": round(total_fte - adjusted_fte, 4),
|
||||
"roles": team,
|
||||
}
|
||||
|
|
@ -7,7 +7,10 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s [%(name)s] %(message)s")
|
||||
|
||||
from app.api import health, gmal, ingest, opportunities, approvals, notifications, users, assets, matching, ratecard
|
||||
from app.api import (
|
||||
health, gmal, ingest, opportunities, approvals, notifications, users,
|
||||
assets, matching, ratecard, team_shape,
|
||||
)
|
||||
from app.middleware.auth import get_current_user
|
||||
|
||||
app = FastAPI(title="OLIVER Sales Ops Platform", version="0.1.0")
|
||||
|
|
@ -34,6 +37,7 @@ app.include_router(opportunities.router, prefix="/api/opportunities", tags=["Opp
|
|||
app.include_router(assets.router, prefix="/api/opportunities", tags=["Assets"], dependencies=[_auth])
|
||||
app.include_router(matching.router, prefix="/api/opportunities", tags=["Matching"], dependencies=[_auth])
|
||||
app.include_router(ratecard.router, prefix="/api/opportunities", tags=["Ratecard"], dependencies=[_auth])
|
||||
app.include_router(team_shape.router, prefix="/api/opportunities", tags=["TeamShape"], dependencies=[_auth])
|
||||
app.include_router(approvals.router, prefix="/api", tags=["Approvals"], dependencies=[_auth])
|
||||
app.include_router(notifications.router, prefix="/api/notifications", tags=["Notifications"], dependencies=[_auth])
|
||||
app.include_router(users.router, prefix="/api/users", tags=["Users"], dependencies=[_auth])
|
||||
|
|
|
|||
111
backend/app/services/team_shape.py
Normal file
111
backend/app/services/team_shape.py
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
"""Calculate team shape (FTE headcount) from ratecard data — Stage 11.
|
||||
|
||||
Ported from V1 with the bug-4 fix already applied: total_hours / manual_override
|
||||
on RatecardLine are stored per-1-asset; this aggregator multiplies by volume.
|
||||
|
||||
Efficiency is applied per-discipline. Programme roles are never reduced.
|
||||
The total per-discipline efficiency is capped at MAX_EFFICIENCY (90%) so we
|
||||
don't accidentally model away a role entirely.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.gmal import Role
|
||||
from app.models.opportunity import Opportunity
|
||||
from app.models.asset import RatecardLine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HOURS_PER_FTE = 1800
|
||||
MAX_EFFICIENCY = 90
|
||||
|
||||
|
||||
async def calculate_team_shape(
|
||||
db: AsyncSession,
|
||||
opportunity: Opportunity,
|
||||
efficiency_pct: float = 0,
|
||||
discipline_overrides: dict[str, float] | None = None,
|
||||
) -> list[dict]:
|
||||
"""Compute FTE per role for an opportunity's ratecard.
|
||||
|
||||
Two efficiency modes:
|
||||
1. `discipline_overrides`: explicit `{discipline: pct}` map (Stage 10
|
||||
efficiency profile produces this).
|
||||
2. `efficiency_pct`: blanket percentage applied to every delivery role.
|
||||
|
||||
Programme roles never see efficiency cuts. Discipline efficiency is
|
||||
capped at MAX_EFFICIENCY.
|
||||
"""
|
||||
lines_result = await db.execute(
|
||||
select(RatecardLine).where(RatecardLine.opportunity_id == opportunity.id)
|
||||
)
|
||||
lines = list(lines_result.scalars().all())
|
||||
if not lines:
|
||||
return []
|
||||
|
||||
# Aggregate hours per role — multiply by volume (V1 bug-4 fix invariant).
|
||||
role_hours: dict[int, float] = defaultdict(float)
|
||||
for line in lines:
|
||||
per_asset = float(line.manual_override) if line.manual_override is not None else float(line.total_hours or 0)
|
||||
role_hours[line.role_id] += per_asset * (line.volume or 1)
|
||||
|
||||
role_ids = list(role_hours.keys())
|
||||
roles_result = await db.execute(select(Role).where(Role.id.in_(role_ids)))
|
||||
roles = {r.id: r for r in roles_result.scalars().all()}
|
||||
|
||||
# Cap each override at MAX_EFFICIENCY
|
||||
discipline_efficiency: dict[str, float] = {}
|
||||
if discipline_overrides:
|
||||
for disc, pct in discipline_overrides.items():
|
||||
discipline_efficiency[disc] = min(float(pct or 0), MAX_EFFICIENCY)
|
||||
|
||||
team: list[dict] = []
|
||||
for role_id, total_hours in role_hours.items():
|
||||
role = roles.get(role_id)
|
||||
if not role or total_hours == 0:
|
||||
continue
|
||||
|
||||
fte = round(total_hours / HOURS_PER_FTE, 4)
|
||||
|
||||
if role.is_programme_role:
|
||||
eff = 0.0
|
||||
elif discipline_efficiency:
|
||||
eff = float(discipline_efficiency.get(role.discipline, 0))
|
||||
elif efficiency_pct > 0:
|
||||
eff = float(efficiency_pct)
|
||||
else:
|
||||
eff = 0.0
|
||||
|
||||
if eff > 0:
|
||||
multiplier = 1 - (eff / 100)
|
||||
adjusted_hours = round(total_hours * multiplier, 2)
|
||||
adjusted_fte = round(adjusted_hours / HOURS_PER_FTE, 4)
|
||||
else:
|
||||
adjusted_hours = round(total_hours, 2)
|
||||
adjusted_fte = fte
|
||||
|
||||
team.append({
|
||||
"role_id": role_id,
|
||||
"role_title": role.role_title,
|
||||
"discipline": role.discipline,
|
||||
"is_programme_role": role.is_programme_role,
|
||||
"sort_order": role.sort_order or 0,
|
||||
"total_hours": round(total_hours, 2),
|
||||
"fte": fte,
|
||||
"efficiency_pct": round(eff, 1),
|
||||
"adjusted_hours": adjusted_hours,
|
||||
"adjusted_fte": adjusted_fte,
|
||||
"hours_saved": round(total_hours - adjusted_hours, 2),
|
||||
"fte_saved": round(fte - adjusted_fte, 4),
|
||||
})
|
||||
|
||||
team.sort(key=lambda t: (t["discipline"], t["sort_order"]))
|
||||
|
||||
total = sum(t["total_hours"] for t in team)
|
||||
total_fte = sum(t["fte"] for t in team)
|
||||
logger.info(f"Team shape for opp {opportunity.id}: {len(team)} roles, {total:.0f} hours, {total_fte:.2f} FTE")
|
||||
return team
|
||||
251
backend/tests/test_assets.py
Normal file
251
backend/tests/test_assets.py
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
"""Stage 6 — Asset Normalizer + ClientAsset CRUD coverage.
|
||||
|
||||
The real `/assets/normalize` call hits Anthropic and costs money — it lives
|
||||
behind a `requires_anthropic` skip marker, mirroring the pattern used in
|
||||
test_intake_agent.py and test_diagnosis.py. The 400 paths fire before any
|
||||
Claude call so we can exercise them safely.
|
||||
|
||||
ClientAsset FK has `ondelete=CASCADE` on `opportunities.id`, so the shared
|
||||
`opportunity` fixture's teardown also tidies any rows we seed here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
|
||||
CLIENT_ASSET_REQUIRED_KEYS = {
|
||||
"id",
|
||||
"opportunity_id",
|
||||
"raw_name",
|
||||
"raw_description",
|
||||
"client_tier",
|
||||
"volume",
|
||||
"sort_order",
|
||||
"created_at",
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# GET /assets — empty
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
async def test_list_assets_empty(client: httpx.AsyncClient, opportunity):
|
||||
r = await client.get(f"/opportunities/{opportunity['id']}/assets")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json() == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# POST /assets — sort_order auto-assignment
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
async def test_create_asset_assigns_sort_order(client: httpx.AsyncClient, opportunity):
|
||||
"""First asset should land at sort_order=0, the next at sort_order=1."""
|
||||
opp_id = opportunity["id"]
|
||||
|
||||
r = await client.post(
|
||||
f"/opportunities/{opp_id}/assets",
|
||||
json={
|
||||
"raw_name": "PDP hero banner",
|
||||
"raw_description": "Tier-A hero for product detail pages",
|
||||
"client_tier": "A",
|
||||
"volume": 12,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
first = r.json()
|
||||
assert CLIENT_ASSET_REQUIRED_KEYS.issubset(first.keys())
|
||||
assert first["opportunity_id"] == opp_id
|
||||
assert first["raw_name"] == "PDP hero banner"
|
||||
assert first["raw_description"] == "Tier-A hero for product detail pages"
|
||||
assert first["client_tier"] == "A"
|
||||
assert first["volume"] == 12
|
||||
assert first["sort_order"] == 0, (
|
||||
f"first asset on a fresh opp should be sort_order=0, got {first['sort_order']!r}"
|
||||
)
|
||||
|
||||
r = await client.post(
|
||||
f"/opportunities/{opp_id}/assets",
|
||||
json={"raw_name": "Paid social statics", "volume": 60},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
second = r.json()
|
||||
assert second["sort_order"] == 1, (
|
||||
f"second asset should land at sort_order=1, got {second['sort_order']!r}"
|
||||
)
|
||||
# default volume + nulls
|
||||
assert second["volume"] == 60
|
||||
assert second["raw_description"] is None
|
||||
assert second["client_tier"] is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# PUT /assets/{id}
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
async def test_update_asset_updates_fields(client: httpx.AsyncClient, opportunity):
|
||||
opp_id = opportunity["id"]
|
||||
r = await client.post(
|
||||
f"/opportunities/{opp_id}/assets",
|
||||
json={"raw_name": "Original name", "volume": 1},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
asset_id = r.json()["id"]
|
||||
|
||||
r = await client.put(
|
||||
f"/opportunities/{opp_id}/assets/{asset_id}",
|
||||
json={
|
||||
"raw_name": "Updated name",
|
||||
"raw_description": "Now with a description",
|
||||
"client_tier": "B",
|
||||
"volume": 100,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["id"] == asset_id
|
||||
assert body["raw_name"] == "Updated name"
|
||||
assert body["raw_description"] == "Now with a description"
|
||||
assert body["client_tier"] == "B"
|
||||
assert body["volume"] == 100
|
||||
|
||||
# Re-GET should agree.
|
||||
r = await client.get(f"/opportunities/{opp_id}/assets")
|
||||
assert r.status_code == 200
|
||||
[row] = [a for a in r.json() if a["id"] == asset_id]
|
||||
assert row["raw_name"] == "Updated name"
|
||||
assert row["raw_description"] == "Now with a description"
|
||||
assert row["client_tier"] == "B"
|
||||
assert row["volume"] == 100
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# DELETE /assets/{id}
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
async def test_delete_asset_removes_it(client: httpx.AsyncClient, opportunity):
|
||||
opp_id = opportunity["id"]
|
||||
r = await client.post(
|
||||
f"/opportunities/{opp_id}/assets",
|
||||
json={"raw_name": "Doomed asset"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
asset_id = r.json()["id"]
|
||||
|
||||
r = await client.delete(f"/opportunities/{opp_id}/assets/{asset_id}")
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# Re-GET returns []
|
||||
r = await client.get(f"/opportunities/{opp_id}/assets")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == []
|
||||
|
||||
# Trying to DELETE again -> 404
|
||||
r = await client.delete(f"/opportunities/{opp_id}/assets/{asset_id}")
|
||||
assert r.status_code == 404, r.text
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Unknown opportunity_id paths -> 404
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
async def test_post_asset_unknown_opportunity_404(client: httpx.AsyncClient):
|
||||
r = await client.post(
|
||||
"/opportunities/9999999/assets",
|
||||
json={"raw_name": "Orphan"},
|
||||
)
|
||||
assert r.status_code == 404, r.text
|
||||
|
||||
|
||||
async def test_put_asset_unknown_opportunity_404(client: httpx.AsyncClient):
|
||||
r = await client.put(
|
||||
"/opportunities/9999999/assets/1",
|
||||
json={"raw_name": "Orphan"},
|
||||
)
|
||||
assert r.status_code == 404, r.text
|
||||
|
||||
|
||||
async def test_delete_asset_unknown_opportunity_404(client: httpx.AsyncClient):
|
||||
r = await client.delete("/opportunities/9999999/assets/1")
|
||||
assert r.status_code == 404, r.text
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# /assets/normalize — error path (no Anthropic call needed)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
async def test_normalize_no_files_400(client: httpx.AsyncClient, opportunity):
|
||||
"""Running normalize on an opportunity with zero uploaded files is a 400.
|
||||
|
||||
The error should mention files / Stage 1 — same wording the
|
||||
asset_normalizer raises ValueError with.
|
||||
"""
|
||||
r = await client.post(f"/opportunities/{opportunity['id']}/assets/normalize")
|
||||
assert r.status_code == 400, r.text
|
||||
detail = r.json().get("detail", "").lower()
|
||||
assert "files" in detail or "stage 1" in detail, (
|
||||
f"detail should mention files / Stage 1, got: {detail!r}"
|
||||
)
|
||||
|
||||
|
||||
async def test_normalize_unknown_opportunity_404(client: httpx.AsyncClient):
|
||||
r = await client.post("/opportunities/9999999/assets/normalize")
|
||||
assert r.status_code == 404, r.text
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Real Anthropic happy path — skipped by default.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@pytest.mark.requires_anthropic
|
||||
@pytest.mark.skip(reason="Real Anthropic call — costs money. Run manually.")
|
||||
async def test_normalize_happy_path(client: httpx.AsyncClient, opportunity):
|
||||
"""Round-trip the Asset Normalizer against a small synthetic brief.
|
||||
|
||||
Verifies:
|
||||
- response shape (`assets_seeded`, `artifact_id`, `raw`)
|
||||
- assets are persisted and retrievable via GET /assets
|
||||
- re-running clears the prior rows (no duplicates)
|
||||
"""
|
||||
body = (
|
||||
b"Acme Corp 2027 always-on programme. Deliverables: Tier A PDP hero (~12/month), "
|
||||
b"Tier B paid social statics (~60/month), launch video (1).\n"
|
||||
)
|
||||
files = {"files": ("brief.txt", body, "text/plain")}
|
||||
r = await client.post(f"/opportunities/{opportunity['id']}/files", files=files)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
r = await client.post(f"/opportunities/{opportunity['id']}/assets/normalize")
|
||||
assert r.status_code == 200, r.text
|
||||
payload = r.json()
|
||||
assert "assets_seeded" in payload
|
||||
assert "artifact_id" in payload
|
||||
assert "raw" in payload
|
||||
|
||||
seeded = payload["assets_seeded"]
|
||||
assert isinstance(seeded, int) and seeded >= 1
|
||||
|
||||
r = await client.get(f"/opportunities/{opportunity['id']}/assets")
|
||||
assert r.status_code == 200
|
||||
first_run = r.json()
|
||||
assert len(first_run) == seeded
|
||||
|
||||
# Re-run wipes and reseeds.
|
||||
r = await client.post(f"/opportunities/{opportunity['id']}/assets/normalize")
|
||||
assert r.status_code == 200, r.text
|
||||
r = await client.get(f"/opportunities/{opportunity['id']}/assets")
|
||||
second_run = r.json()
|
||||
first_ids = {a["id"] for a in first_run}
|
||||
second_ids = {a["id"] for a in second_run}
|
||||
assert first_ids.isdisjoint(second_ids), (
|
||||
"post-rerun assets should have fresh ids; the old rows should be wiped"
|
||||
)
|
||||
374
backend/tests/test_matching.py
Normal file
374
backend/tests/test_matching.py
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
"""Stage 7 — AI matching coverage.
|
||||
|
||||
The real `/match` call hits Anthropic and costs money; it lives behind a
|
||||
`requires_anthropic` skip marker, mirroring test_diagnosis.py. We exercise
|
||||
everything else by seeding ClientAsset + Match rows directly via psycopg2,
|
||||
the same backdoor test_approvals.py and test_diagnosis.py use.
|
||||
|
||||
ClientAsset.matches has cascade='all, delete-orphan' and the FKs cascade
|
||||
on opportunity delete, so the shared `opportunity` fixture's teardown
|
||||
also tidies any rows we seed here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import httpx
|
||||
import psycopg2
|
||||
import pytest
|
||||
|
||||
|
||||
_DB_DSN = os.environ.get(
|
||||
"OSOP_TEST_DSN",
|
||||
"host=127.0.0.1 port=5435 dbname=oliver_sales_ops user=osop_user password=osop_pass_2026",
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Helpers
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _seed_client_asset(
|
||||
opp_id: int,
|
||||
raw_name: str = "Test asset",
|
||||
raw_description: str | None = None,
|
||||
volume: int = 1,
|
||||
sort_order: int = 0,
|
||||
client_tier: str | None = None,
|
||||
) -> int:
|
||||
conn = psycopg2.connect(_DB_DSN)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO client_assets
|
||||
(opportunity_id, raw_name, raw_description, volume, sort_order, client_tier)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(opp_id, raw_name, raw_description, volume, sort_order, client_tier),
|
||||
)
|
||||
new_id = cur.fetchone()[0]
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
return new_id
|
||||
|
||||
|
||||
def _seed_match(
|
||||
client_asset_id: int,
|
||||
gmal_asset_id: int,
|
||||
confidence: str = "exact",
|
||||
confidence_score: float = 0.95,
|
||||
is_selected: bool = False,
|
||||
rank: int = 1,
|
||||
ai_reasoning: str | None = None,
|
||||
caveat_text: str | None = None,
|
||||
) -> int:
|
||||
"""Insert a Match row directly. confidence is the lowercase Python value;
|
||||
Postgres stores the uppercase enum name."""
|
||||
conn = psycopg2.connect(_DB_DSN)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO matches
|
||||
(client_asset_id, gmal_asset_id, confidence, confidence_score,
|
||||
is_selected, rank, ai_reasoning, caveat_text)
|
||||
VALUES (%s, %s, %s::matchconfidence, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
client_asset_id,
|
||||
gmal_asset_id,
|
||||
confidence.upper(),
|
||||
confidence_score,
|
||||
is_selected,
|
||||
rank,
|
||||
ai_reasoning,
|
||||
caveat_text,
|
||||
),
|
||||
)
|
||||
new_id = cur.fetchone()[0]
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
return new_id
|
||||
|
||||
|
||||
def _pick_gmal_with_hour_routes() -> int:
|
||||
"""Return the lowest-id GMAL asset that has hour routes. The matching
|
||||
agent only considers `has_hour_routes=True` GMALs anyway."""
|
||||
conn = psycopg2.connect(_DB_DSN)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT id FROM gmal_assets WHERE has_hour_routes ORDER BY id LIMIT 1"
|
||||
)
|
||||
row = cur.fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
assert row is not None, "expected at least one GMAL with has_hour_routes — is the catalog seeded?"
|
||||
return row[0]
|
||||
|
||||
|
||||
def _read_match(match_id: int) -> dict:
|
||||
conn = psycopg2.connect(_DB_DSN)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT id, client_asset_id, gmal_asset_id, is_selected, rank "
|
||||
"FROM matches WHERE id = %s",
|
||||
(match_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
assert row is not None, f"no match row {match_id}"
|
||||
return {
|
||||
"id": row[0],
|
||||
"client_asset_id": row[1],
|
||||
"gmal_asset_id": row[2],
|
||||
"is_selected": row[3],
|
||||
"rank": row[4],
|
||||
}
|
||||
|
||||
|
||||
MATCH_REQUIRED_KEYS = {
|
||||
"id",
|
||||
"client_asset_id",
|
||||
"gmal_asset_id",
|
||||
"gmal_id",
|
||||
"gmal_name",
|
||||
"gmal_unique_name",
|
||||
"confidence",
|
||||
"confidence_score",
|
||||
"ai_reasoning",
|
||||
"caveat_text",
|
||||
"is_selected",
|
||||
"rank",
|
||||
"created_at",
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# /match — error paths
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
async def test_match_no_assets_400(client: httpx.AsyncClient, opportunity):
|
||||
"""Kicking off matching on an opportunity with zero ClientAssets is a 400."""
|
||||
r = await client.post(f"/opportunities/{opportunity['id']}/match")
|
||||
assert r.status_code == 400, r.text
|
||||
detail = r.json().get("detail", "").lower()
|
||||
assert "stage 6" in detail or "normalize" in detail, (
|
||||
f"detail should mention Stage 6 / normalize, got: {detail!r}"
|
||||
)
|
||||
|
||||
|
||||
async def test_match_unknown_opportunity_404(client: httpx.AsyncClient):
|
||||
r = await client.post("/opportunities/9999999/match")
|
||||
assert r.status_code == 404, r.text
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# GET /matches
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
async def test_list_matches_empty(client: httpx.AsyncClient, opportunity):
|
||||
r = await client.get(f"/opportunities/{opportunity['id']}/matches")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json() == []
|
||||
|
||||
|
||||
async def test_list_matches_unknown_opportunity_404(client: httpx.AsyncClient):
|
||||
r = await client.get("/opportunities/9999999/matches")
|
||||
assert r.status_code == 404, r.text
|
||||
|
||||
|
||||
async def test_list_matches_returns_seeded_rows(
|
||||
client: httpx.AsyncClient, opportunity
|
||||
):
|
||||
"""Seed 1 ClientAsset + 2 Match rows (one selected) and verify GET /matches
|
||||
surfaces both with the expected join shape (gmal_id, gmal_name populated,
|
||||
confidence lowercased, is_selected flags preserved). Order: by ClientAsset
|
||||
sort_order, then Match.rank."""
|
||||
opp_id = opportunity["id"]
|
||||
gmal_id = _pick_gmal_with_hour_routes()
|
||||
|
||||
ca_id = _seed_client_asset(opp_id, raw_name="Hero PDP", volume=10, sort_order=0)
|
||||
m1_id = _seed_match(
|
||||
ca_id,
|
||||
gmal_id,
|
||||
confidence="exact",
|
||||
confidence_score=0.95,
|
||||
is_selected=True,
|
||||
rank=1,
|
||||
ai_reasoning="strong fit",
|
||||
caveat_text="no major caveats",
|
||||
)
|
||||
m2_id = _seed_match(
|
||||
ca_id,
|
||||
gmal_id,
|
||||
confidence="close",
|
||||
confidence_score=0.72,
|
||||
is_selected=False,
|
||||
rank=2,
|
||||
ai_reasoning="weaker alt",
|
||||
)
|
||||
|
||||
r = await client.get(f"/opportunities/{opp_id}/matches")
|
||||
assert r.status_code == 200, r.text
|
||||
rows = r.json()
|
||||
assert isinstance(rows, list)
|
||||
assert len(rows) == 2
|
||||
|
||||
for row in rows:
|
||||
assert MATCH_REQUIRED_KEYS.issubset(row.keys())
|
||||
assert row["client_asset_id"] == ca_id
|
||||
assert row["gmal_asset_id"] == gmal_id
|
||||
# gmal_id (the GMAL101-style code) is populated by the join
|
||||
assert row["gmal_id"] is not None
|
||||
assert row["gmal_id"].startswith("GMAL"), (
|
||||
f"gmal_id should look like 'GMAL101', got {row['gmal_id']!r}"
|
||||
)
|
||||
assert row["gmal_name"] is not None
|
||||
# Confidence is the lowercase enum value
|
||||
assert row["confidence"] in {"exact", "close", "multiple", "none"}
|
||||
|
||||
# Order: rank ascending within the asset (sort_order then rank).
|
||||
assert [r["rank"] for r in rows] == [1, 2]
|
||||
by_id = {r["id"]: r for r in rows}
|
||||
assert by_id[m1_id]["is_selected"] is True
|
||||
assert by_id[m1_id]["confidence"] == "exact"
|
||||
assert by_id[m1_id]["confidence_score"] == 0.95
|
||||
assert by_id[m1_id]["ai_reasoning"] == "strong fit"
|
||||
assert by_id[m2_id]["is_selected"] is False
|
||||
assert by_id[m2_id]["confidence"] == "close"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# PUT /matches/{id}/select
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
async def test_select_match_deselects_siblings(
|
||||
client: httpx.AsyncClient, opportunity
|
||||
):
|
||||
"""is_selected=true on a match should deselect any other selected matches
|
||||
for the same client_asset (one selection per asset)."""
|
||||
opp_id = opportunity["id"]
|
||||
gmal_id = _pick_gmal_with_hour_routes()
|
||||
|
||||
ca_id = _seed_client_asset(opp_id, raw_name="Asset A", sort_order=0)
|
||||
selected_id = _seed_match(ca_id, gmal_id, rank=1, is_selected=True)
|
||||
other_id = _seed_match(ca_id, gmal_id, rank=2, is_selected=False)
|
||||
|
||||
# Flip the rank-2 match to selected; the rank-1 match should auto-deselect.
|
||||
r = await client.put(
|
||||
f"/opportunities/{opp_id}/matches/{other_id}/select",
|
||||
json={"is_selected": True},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["id"] == other_id
|
||||
assert body["is_selected"] is True
|
||||
|
||||
assert _read_match(other_id)["is_selected"] is True
|
||||
assert _read_match(selected_id)["is_selected"] is False, (
|
||||
"previously-selected sibling should auto-deselect on flip"
|
||||
)
|
||||
|
||||
|
||||
async def test_deselect_match_does_not_touch_siblings(
|
||||
client: httpx.AsyncClient, opportunity
|
||||
):
|
||||
"""is_selected=false on a match should deselect just that match, leaving
|
||||
any other selected siblings alone (the route only deselects siblings on
|
||||
a *positive* flip)."""
|
||||
opp_id = opportunity["id"]
|
||||
gmal_id = _pick_gmal_with_hour_routes()
|
||||
|
||||
ca_id = _seed_client_asset(opp_id, raw_name="Asset A", sort_order=0)
|
||||
sel1 = _seed_match(ca_id, gmal_id, rank=1, is_selected=True)
|
||||
# Pre-seed a second selected sibling — unusual but valid as input state.
|
||||
sel2 = _seed_match(ca_id, gmal_id, rank=2, is_selected=True)
|
||||
|
||||
r = await client.put(
|
||||
f"/opportunities/{opp_id}/matches/{sel1}/select",
|
||||
json={"is_selected": False},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["id"] == sel1
|
||||
assert body["is_selected"] is False
|
||||
|
||||
assert _read_match(sel1)["is_selected"] is False
|
||||
assert _read_match(sel2)["is_selected"] is True, (
|
||||
"deselect should NOT cascade — the other match stays selected"
|
||||
)
|
||||
|
||||
|
||||
async def test_select_cross_opportunity_404(
|
||||
client: httpx.AsyncClient, opportunity, opp_payload_factory
|
||||
):
|
||||
"""A match belonging to opportunity A cannot be toggled via opportunity B's
|
||||
URL."""
|
||||
opp_a_id = opportunity["id"]
|
||||
gmal_id = _pick_gmal_with_hour_routes()
|
||||
ca_id = _seed_client_asset(opp_a_id, raw_name="Asset A", sort_order=0)
|
||||
match_id = _seed_match(ca_id, gmal_id, rank=1, is_selected=False)
|
||||
|
||||
# Create a second opportunity, cleaned up explicitly.
|
||||
payload = opp_payload_factory(name_suffix="(matching-cross-opp)")
|
||||
create = await client.post("/opportunities", json=payload)
|
||||
assert create.status_code == 200, create.text
|
||||
opp_b_id = create.json()["id"]
|
||||
|
||||
try:
|
||||
r = await client.put(
|
||||
f"/opportunities/{opp_b_id}/matches/{match_id}/select",
|
||||
json={"is_selected": True},
|
||||
)
|
||||
assert r.status_code == 404, r.text
|
||||
# Sanity: the underlying row is unchanged.
|
||||
assert _read_match(match_id)["is_selected"] is False
|
||||
finally:
|
||||
await client.delete(f"/opportunities/{opp_b_id}")
|
||||
|
||||
|
||||
async def test_select_unknown_match_404(client: httpx.AsyncClient, opportunity):
|
||||
r = await client.put(
|
||||
f"/opportunities/{opportunity['id']}/matches/9999999/select",
|
||||
json={"is_selected": True},
|
||||
)
|
||||
assert r.status_code == 404, r.text
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Real Anthropic happy path — skipped by default.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@pytest.mark.requires_anthropic
|
||||
@pytest.mark.skip(reason="Real Anthropic call — costs money. Run manually.")
|
||||
async def test_match_happy_path(client: httpx.AsyncClient, opportunity):
|
||||
"""Round-trip the AI matching agent against a single seeded ClientAsset.
|
||||
|
||||
Verifies:
|
||||
- /match kicks off (200 with detail)
|
||||
- matches eventually appear via GET /matches
|
||||
- at least one rank-1 match exists per client asset
|
||||
"""
|
||||
opp_id = opportunity["id"]
|
||||
_seed_client_asset(opp_id, raw_name="Tier-A PDP hero", volume=12, sort_order=0)
|
||||
|
||||
r = await client.post(f"/opportunities/{opp_id}/match")
|
||||
assert r.status_code == 200, r.text
|
||||
assert "Matching" in r.json().get("detail", "")
|
||||
|
||||
# Background task; in a real run we'd poll. Here we just sanity-check
|
||||
# the kick-off — letting Claude finish in-process would slow the suite.
|
||||
387
backend/tests/test_ratecard.py
Normal file
387
backend/tests/test_ratecard.py
Normal file
|
|
@ -0,0 +1,387 @@
|
|||
"""Stage 8 — Ratecard build coverage.
|
||||
|
||||
Ratecard building is pure-Python (no Claude), so this is exercised end-to-end.
|
||||
We seed ClientAsset + Match rows directly via psycopg2 — same backdoor used
|
||||
elsewhere in the harness — to drive `/ratecard/build` and `/ratecard`.
|
||||
|
||||
The shared `opportunity` fixture creates `model_type=ai_oplus`. The Stage 8
|
||||
spec works against any model_type that has GmalHours rows; we use
|
||||
`current_oplus` here, which means we mint our own opportunity rather than
|
||||
re-using the fixture for the build/get tests.
|
||||
|
||||
ClientAsset / RatecardLine FKs cascade on opportunity delete, so the
|
||||
explicit cleanup in the per-test custom-opp helper handles teardown.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import date
|
||||
|
||||
import httpx
|
||||
import psycopg2
|
||||
import pytest
|
||||
|
||||
|
||||
_DB_DSN = os.environ.get(
|
||||
"OSOP_TEST_DSN",
|
||||
"host=127.0.0.1 port=5435 dbname=oliver_sales_ops user=osop_user password=osop_pass_2026",
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Helpers
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _seed_client_asset(
|
||||
opp_id: int,
|
||||
raw_name: str = "Test asset",
|
||||
volume: int = 1,
|
||||
sort_order: int = 0,
|
||||
) -> int:
|
||||
conn = psycopg2.connect(_DB_DSN)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO client_assets
|
||||
(opportunity_id, raw_name, volume, sort_order)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(opp_id, raw_name, volume, sort_order),
|
||||
)
|
||||
new_id = cur.fetchone()[0]
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
return new_id
|
||||
|
||||
|
||||
def _seed_match(
|
||||
client_asset_id: int,
|
||||
gmal_asset_id: int,
|
||||
is_selected: bool = True,
|
||||
confidence: str = "exact",
|
||||
confidence_score: float = 0.95,
|
||||
rank: int = 1,
|
||||
) -> int:
|
||||
conn = psycopg2.connect(_DB_DSN)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO matches
|
||||
(client_asset_id, gmal_asset_id, confidence, confidence_score,
|
||||
is_selected, rank)
|
||||
VALUES (%s, %s, %s::matchconfidence, %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
client_asset_id,
|
||||
gmal_asset_id,
|
||||
confidence.upper(),
|
||||
confidence_score,
|
||||
is_selected,
|
||||
rank,
|
||||
),
|
||||
)
|
||||
new_id = cur.fetchone()[0]
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
return new_id
|
||||
|
||||
|
||||
def _pick_gmal_for_model(model_type: str = "CURRENT_OPLUS", min_rows: int = 2) -> int:
|
||||
"""Return a GMAL asset id that has at least `min_rows` GmalHours rows for
|
||||
the given model_type. Stage 8 ratecard build emits one RatecardLine per
|
||||
GmalHours row, so this controls the line count we expect."""
|
||||
conn = psycopg2.connect(_DB_DSN)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT gmal_asset_id, COUNT(*)
|
||||
FROM gmal_hours
|
||||
WHERE model_type = %s::modeltype
|
||||
GROUP BY gmal_asset_id
|
||||
HAVING COUNT(*) >= %s
|
||||
ORDER BY gmal_asset_id
|
||||
LIMIT 1
|
||||
""",
|
||||
(model_type, min_rows),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
assert row is not None, (
|
||||
f"expected a GMAL with {min_rows}+ {model_type} hour rows — "
|
||||
"seed your gmal_hours table first"
|
||||
)
|
||||
return row[0]
|
||||
|
||||
|
||||
def _count_hour_rows(gmal_asset_id: int, model_type: str = "CURRENT_OPLUS") -> int:
|
||||
conn = psycopg2.connect(_DB_DSN)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT COUNT(*) FROM gmal_hours "
|
||||
"WHERE gmal_asset_id = %s AND model_type = %s::modeltype",
|
||||
(gmal_asset_id, model_type),
|
||||
)
|
||||
n = cur.fetchone()[0]
|
||||
finally:
|
||||
conn.close()
|
||||
return int(n)
|
||||
|
||||
|
||||
def _opportunity_payload(model_type: str, name_suffix: str = "") -> dict:
|
||||
return {
|
||||
"name": f"PYTEST Ratecard {name_suffix}".strip(),
|
||||
"client_name": "Test Client Ltd",
|
||||
"region": "EMEA",
|
||||
"brands": "BrandA, BrandB",
|
||||
"service_types": "Content Production",
|
||||
"description": "Ratecard test opportunity.",
|
||||
"deadline": date(2027, 1, 15).isoformat(),
|
||||
"go_live": date(2027, 3, 1).isoformat(),
|
||||
"model_type": model_type,
|
||||
}
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _custom_opp(client: httpx.AsyncClient, model_type: str, suffix: str):
|
||||
"""Create+yield+delete a fresh opportunity with the given model_type.
|
||||
|
||||
The shared `opportunity` fixture is locked to `ai_oplus`; this helper
|
||||
lets us point the ratecard tests at `current_oplus` (or any other)
|
||||
without having to mutate the fixture."""
|
||||
payload = _opportunity_payload(model_type, name_suffix=suffix)
|
||||
r = await client.post("/opportunities", json=payload)
|
||||
r.raise_for_status()
|
||||
opp = r.json()
|
||||
try:
|
||||
yield opp
|
||||
finally:
|
||||
try:
|
||||
await client.delete(f"/opportunities/{opp['id']}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# /ratecard/build — error paths
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
async def test_build_no_assets_400(client: httpx.AsyncClient, opportunity):
|
||||
"""Building a ratecard on an opp with zero ClientAssets is a 400."""
|
||||
r = await client.post(f"/opportunities/{opportunity['id']}/ratecard/build")
|
||||
assert r.status_code == 400, r.text
|
||||
detail = r.json().get("detail", "").lower()
|
||||
assert "stage 6" in detail or "client assets" in detail, (
|
||||
f"detail should mention Stage 6 / client assets, got: {detail!r}"
|
||||
)
|
||||
|
||||
|
||||
async def test_build_unknown_opportunity_404(client: httpx.AsyncClient):
|
||||
r = await client.post("/opportunities/9999999/ratecard/build")
|
||||
assert r.status_code == 404, r.text
|
||||
|
||||
|
||||
async def test_get_ratecard_unknown_opportunity_404(client: httpx.AsyncClient):
|
||||
r = await client.get("/opportunities/9999999/ratecard")
|
||||
assert r.status_code == 404, r.text
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Happy path — build + verify per-1-asset hours and volume aggregation
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
async def test_build_and_get_ratecard_happy(client: httpx.AsyncClient):
|
||||
"""End-to-end Stage 8: seed 1 ClientAsset (volume=100) + selected Match,
|
||||
POST /build, GET /ratecard, then verify the V1 bug-4 invariant:
|
||||
`total_hours == base_hours` per row (per-1-asset), and the project-wide
|
||||
total equals sum(base_hours × volume)."""
|
||||
async with _custom_opp(client, "current_oplus", suffix="(happy)") as opp:
|
||||
opp_id = opp["id"]
|
||||
gmal_asset_id = _pick_gmal_for_model("CURRENT_OPLUS", min_rows=2)
|
||||
expected_lines = _count_hour_rows(gmal_asset_id, "CURRENT_OPLUS")
|
||||
assert expected_lines >= 2
|
||||
|
||||
ca_id = _seed_client_asset(opp_id, raw_name="Hero PDP", volume=100, sort_order=0)
|
||||
_seed_match(ca_id, gmal_asset_id, is_selected=True, rank=1)
|
||||
|
||||
# Build
|
||||
r = await client.post(f"/opportunities/{opp_id}/ratecard/build")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["total_lines"] == expected_lines, (
|
||||
f"build should emit one line per GmalHours row, expected "
|
||||
f"{expected_lines}, got {body['total_lines']}"
|
||||
)
|
||||
assert "built" in body["detail"].lower()
|
||||
|
||||
# Read back
|
||||
r = await client.get(f"/opportunities/{opp_id}/ratecard")
|
||||
assert r.status_code == 200, r.text
|
||||
summary = r.json()
|
||||
assert summary["project_id"] == opp_id
|
||||
assert summary["model_type"] == "current_oplus"
|
||||
assert summary["total_assets"] == 1
|
||||
assert isinstance(summary["lines"], list)
|
||||
assert len(summary["lines"]) == expected_lines
|
||||
|
||||
# Per-line invariants
|
||||
per_asset_total = 0.0
|
||||
for line in summary["lines"]:
|
||||
# Per-1-asset bug-4 invariant: total_hours stores the per-asset
|
||||
# value (no manual_override), so it equals base_hours.
|
||||
assert line["base_hours"] is not None
|
||||
assert line["total_hours"] is not None
|
||||
assert line["base_hours"] == line["total_hours"], (
|
||||
f"per-1-asset invariant: base_hours should equal total_hours, "
|
||||
f"got base={line['base_hours']} total={line['total_hours']}"
|
||||
)
|
||||
assert line["volume"] == 100
|
||||
assert line["manual_override"] is None
|
||||
assert line["client_asset_id"] == ca_id
|
||||
assert line["gmal_id"] is not None
|
||||
assert line["gmal_id"].startswith("GMAL")
|
||||
assert line["role_title"] is not None
|
||||
per_asset_total += float(line["base_hours"])
|
||||
|
||||
# Project-wide total should be the volume-multiplied sum (V1 bug-4 fix).
|
||||
expected_total = round(per_asset_total * 100, 2)
|
||||
assert summary["total_hours"] == expected_total, (
|
||||
f"summary total should be sum(base_hours × volume) = {expected_total}, "
|
||||
f"got {summary['total_hours']}"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# No selected match -> builder skips, returns 0 lines
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
async def test_build_skips_assets_without_selection(client: httpx.AsyncClient):
|
||||
"""A ClientAsset whose only Match has is_selected=false should be skipped
|
||||
(the builder logs a warning and continues). The build call itself succeeds
|
||||
with 0 lines and the GET returns total_assets=0, lines=[]."""
|
||||
async with _custom_opp(client, "current_oplus", suffix="(no-selection)") as opp:
|
||||
opp_id = opp["id"]
|
||||
gmal_asset_id = _pick_gmal_for_model("CURRENT_OPLUS", min_rows=2)
|
||||
|
||||
ca_id = _seed_client_asset(opp_id, raw_name="Unselected asset", volume=10, sort_order=0)
|
||||
_seed_match(ca_id, gmal_asset_id, is_selected=False, rank=1)
|
||||
|
||||
r = await client.post(f"/opportunities/{opp_id}/ratecard/build")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["total_lines"] == 0
|
||||
|
||||
r = await client.get(f"/opportunities/{opp_id}/ratecard")
|
||||
assert r.status_code == 200, r.text
|
||||
summary = r.json()
|
||||
assert summary["total_assets"] == 0
|
||||
assert summary["lines"] == []
|
||||
assert summary["total_hours"] == 0.0
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Re-build wipes the prior ratecard
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
async def test_build_twice_wipes_first(client: httpx.AsyncClient):
|
||||
"""Building twice in a row should leave exactly N lines, not 2N — the
|
||||
builder wipes prior RatecardLine rows on each call."""
|
||||
async with _custom_opp(client, "current_oplus", suffix="(rebuild)") as opp:
|
||||
opp_id = opp["id"]
|
||||
gmal_asset_id = _pick_gmal_for_model("CURRENT_OPLUS", min_rows=2)
|
||||
expected_lines = _count_hour_rows(gmal_asset_id, "CURRENT_OPLUS")
|
||||
|
||||
ca_id = _seed_client_asset(opp_id, raw_name="Re-build asset", volume=5, sort_order=0)
|
||||
_seed_match(ca_id, gmal_asset_id, is_selected=True, rank=1)
|
||||
|
||||
r1 = await client.post(f"/opportunities/{opp_id}/ratecard/build")
|
||||
assert r1.status_code == 200
|
||||
assert r1.json()["total_lines"] == expected_lines
|
||||
|
||||
r2 = await client.post(f"/opportunities/{opp_id}/ratecard/build")
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["total_lines"] == expected_lines, (
|
||||
"re-build should wipe the first ratecard, not append to it"
|
||||
)
|
||||
|
||||
# And the GET should agree.
|
||||
r = await client.get(f"/opportunities/{opp_id}/ratecard")
|
||||
assert r.status_code == 200
|
||||
assert len(r.json()["lines"]) == expected_lines
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Two assets — total_hours splits correctly across volumes
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
async def test_build_two_assets_volume_aggregation(client: httpx.AsyncClient):
|
||||
"""Seed two ClientAssets (volumes 10 and 20), each with a selected match
|
||||
to the same GMAL. Verify:
|
||||
- line count is 2 × N (one set per asset)
|
||||
- summary.total_hours equals sum(line.base_hours × line.volume)
|
||||
- the project total splits evenly across the two assets in proportion
|
||||
to their volumes (10 vs 20)."""
|
||||
async with _custom_opp(client, "current_oplus", suffix="(two-asset)") as opp:
|
||||
opp_id = opp["id"]
|
||||
gmal_asset_id = _pick_gmal_for_model("CURRENT_OPLUS", min_rows=2)
|
||||
per_asset_n = _count_hour_rows(gmal_asset_id, "CURRENT_OPLUS")
|
||||
|
||||
ca1 = _seed_client_asset(opp_id, raw_name="Asset One", volume=10, sort_order=0)
|
||||
ca2 = _seed_client_asset(opp_id, raw_name="Asset Two", volume=20, sort_order=1)
|
||||
_seed_match(ca1, gmal_asset_id, is_selected=True, rank=1)
|
||||
_seed_match(ca2, gmal_asset_id, is_selected=True, rank=1)
|
||||
|
||||
r = await client.post(f"/opportunities/{opp_id}/ratecard/build")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["total_lines"] == 2 * per_asset_n
|
||||
|
||||
r = await client.get(f"/opportunities/{opp_id}/ratecard")
|
||||
assert r.status_code == 200, r.text
|
||||
summary = r.json()
|
||||
assert summary["total_assets"] == 2
|
||||
assert len(summary["lines"]) == 2 * per_asset_n
|
||||
|
||||
ca1_lines = [l for l in summary["lines"] if l["client_asset_id"] == ca1]
|
||||
ca2_lines = [l for l in summary["lines"] if l["client_asset_id"] == ca2]
|
||||
assert len(ca1_lines) == per_asset_n
|
||||
assert len(ca2_lines) == per_asset_n
|
||||
|
||||
# Volumes propagate onto the line.
|
||||
assert all(l["volume"] == 10 for l in ca1_lines)
|
||||
assert all(l["volume"] == 20 for l in ca2_lines)
|
||||
|
||||
# Per-1-asset invariant on every line.
|
||||
for l in summary["lines"]:
|
||||
assert l["base_hours"] == l["total_hours"]
|
||||
|
||||
# Total hours = sum(line.base_hours × line.volume).
|
||||
expected_total = round(
|
||||
sum(float(l["base_hours"]) * l["volume"] for l in summary["lines"]),
|
||||
2,
|
||||
)
|
||||
assert summary["total_hours"] == expected_total
|
||||
|
||||
# And the asset-2 contribution should be exactly 2× asset-1's
|
||||
# (same GMAL, same per-1-asset hours, volumes 20 vs 10).
|
||||
ca1_contrib = sum(float(l["base_hours"]) * l["volume"] for l in ca1_lines)
|
||||
ca2_contrib = sum(float(l["base_hours"]) * l["volume"] for l in ca2_lines)
|
||||
assert pytest.approx(ca2_contrib, rel=1e-9) == ca1_contrib * 2, (
|
||||
f"asset-2 (volume=20) should contribute exactly 2× asset-1 (volume=10), "
|
||||
f"got {ca2_contrib} vs {ca1_contrib}"
|
||||
)
|
||||
55
frontend/src/api/teamShape.ts
Normal file
55
frontend/src/api/teamShape.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import api from './client';
|
||||
|
||||
export interface TeamShapeRole {
|
||||
role_id: number;
|
||||
role_title: string;
|
||||
discipline: string;
|
||||
is_programme_role: boolean;
|
||||
sort_order: number;
|
||||
total_hours: number;
|
||||
fte: number;
|
||||
efficiency_pct: number;
|
||||
adjusted_hours: number;
|
||||
adjusted_fte: number;
|
||||
hours_saved: number;
|
||||
fte_saved: number;
|
||||
}
|
||||
|
||||
export interface TeamShape {
|
||||
project_id: number;
|
||||
project_name: string;
|
||||
hours_per_fte: number;
|
||||
efficiency_pct: number;
|
||||
discipline_overrides: Record<string, number> | null;
|
||||
total_hours: number;
|
||||
total_fte: number;
|
||||
adjusted_hours: number;
|
||||
adjusted_fte: number;
|
||||
delivery_fte: number;
|
||||
programme_fte: number;
|
||||
hours_saved: number;
|
||||
fte_saved: number;
|
||||
roles: TeamShapeRole[];
|
||||
}
|
||||
|
||||
export function useTeamShape(
|
||||
opportunityId: number | undefined,
|
||||
efficiencyPct = 0,
|
||||
disciplineOverrides?: Record<string, number>,
|
||||
) {
|
||||
const overridesJson = disciplineOverrides ? JSON.stringify(disciplineOverrides) : '';
|
||||
return useQuery({
|
||||
queryKey: ['team-shape', opportunityId ?? 0, efficiencyPct, overridesJson],
|
||||
queryFn: async (): Promise<TeamShape> => {
|
||||
const res = await api.get(`/opportunities/${opportunityId}/team-shape`, {
|
||||
params: {
|
||||
efficiency_pct: efficiencyPct,
|
||||
discipline_overrides: overridesJson,
|
||||
},
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
enabled: opportunityId !== undefined && opportunityId > 0,
|
||||
});
|
||||
}
|
||||
217
frontend/src/components/Stage11TeamShape.tsx
Normal file
217
frontend/src/components/Stage11TeamShape.tsx
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import { useTeamShape, TeamShapeRole } from '../api/teamShape';
|
||||
|
||||
interface Props {
|
||||
opportunityId: number;
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
const FTE_HOURS = 1800;
|
||||
|
||||
export default function Stage11TeamShape({ opportunityId }: Props) {
|
||||
const [blanketEff, setBlanketEff] = useState<number>(0);
|
||||
const [overrides, setOverrides] = useState<Record<string, number>>({});
|
||||
const useOverrides = Object.keys(overrides).length > 0;
|
||||
|
||||
const { data, isLoading } = useTeamShape(
|
||||
opportunityId,
|
||||
useOverrides ? 0 : blanketEff,
|
||||
useOverrides ? overrides : undefined,
|
||||
);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map: Record<string, TeamShapeRole[]> = {};
|
||||
(data?.roles ?? []).forEach((r) => {
|
||||
if (!map[r.discipline]) map[r.discipline] = [];
|
||||
map[r.discipline].push(r);
|
||||
});
|
||||
return map;
|
||||
}, [data]);
|
||||
|
||||
const disciplines = Object.keys(grouped).sort();
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
<section style={cardStyle}>
|
||||
<h3 style={titleStyle}>Team shape (FTE)</h3>
|
||||
<p style={hintStyle}>
|
||||
Aggregates ratecard hours per role and divides by {FTE_HOURS} hours/year. Programme roles never see
|
||||
efficiency cuts. Per-discipline override is capped at 90%.
|
||||
</p>
|
||||
|
||||
{!isLoading && data && (
|
||||
<div style={statsRowStyle}>
|
||||
<Stat label="Total hours" value={data.total_hours.toLocaleString()} fg="#FFC407" />
|
||||
<Stat label="Total FTE" value={data.total_fte.toFixed(2)} fg="#FFC407" />
|
||||
<Stat label="Delivery FTE" value={data.delivery_fte.toFixed(2)} />
|
||||
<Stat label="Programme FTE" value={data.programme_fte.toFixed(2)} />
|
||||
{(data.hours_saved > 0 || data.fte_saved > 0) && (
|
||||
<>
|
||||
<Stat label="Adjusted hrs" value={data.adjusted_hours.toLocaleString()} fg="#86efac" />
|
||||
<Stat label="Adjusted FTE" value={data.adjusted_fte.toFixed(2)} fg="#86efac" />
|
||||
<Stat label="Saved" value={`${data.hours_saved.toFixed(0)} hrs`} fg="#86efac" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section style={cardStyle}>
|
||||
<h3 style={titleStyle}>Efficiency controls</h3>
|
||||
<p style={hintStyle}>
|
||||
Apply a blanket percentage across delivery roles, OR set per-discipline overrides
|
||||
(Stage 10 efficiency profile). The per-discipline mode takes precedence.
|
||||
</p>
|
||||
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<label style={labelStyle}>
|
||||
<span style={metaLabelStyle}>Blanket efficiency: {blanketEff}%</span>
|
||||
<input
|
||||
type="range" min={0} max={90} step={5}
|
||||
value={blanketEff}
|
||||
onChange={(e) => setBlanketEff(parseInt(e.target.value, 10))}
|
||||
disabled={useOverrides}
|
||||
style={sliderStyle}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 18, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||
<span style={metaLabelStyle}>Per-discipline overrides</span>
|
||||
{useOverrides && (
|
||||
<button onClick={() => setOverrides({})} style={ghostBtnStyle}>Clear all</button>
|
||||
)}
|
||||
</div>
|
||||
{disciplines.length === 0 && (
|
||||
<p style={emptyStyle}>No roles in the ratecard yet — build it at Stage 8.</p>
|
||||
)}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 12, marginTop: 6 }}>
|
||||
{disciplines.map((disc) => (
|
||||
<DisciplineOverride
|
||||
key={disc}
|
||||
discipline={disc}
|
||||
value={overrides[disc] ?? 0}
|
||||
onChange={(pct) => {
|
||||
if (pct === 0) {
|
||||
const next = { ...overrides };
|
||||
delete next[disc];
|
||||
setOverrides(next);
|
||||
} else {
|
||||
setOverrides({ ...overrides, [disc]: pct });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{isLoading && <div style={cardStyle}><p style={emptyStyle}>Loading team shape…</p></div>}
|
||||
|
||||
{!isLoading && data && data.roles.length === 0 && (
|
||||
<div style={cardStyle}>
|
||||
<p style={emptyStyle}>No team shape — build the ratecard at Stage 8 first.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && data && data.roles.length > 0 && disciplines.map((disc) => (
|
||||
<section key={disc} style={cardStyle}>
|
||||
<h4 style={{ margin: 0, marginBottom: 8, fontSize: 13, color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
{disc}
|
||||
</h4>
|
||||
<div style={tableWrapStyle}>
|
||||
<table style={tableStyle}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={thStyle}>Role</th>
|
||||
<th style={{ ...thStyle, textAlign: 'right' }}>Total hrs</th>
|
||||
<th style={{ ...thStyle, textAlign: 'right' }}>FTE</th>
|
||||
<th style={{ ...thStyle, textAlign: 'right' }}>Eff %</th>
|
||||
<th style={{ ...thStyle, textAlign: 'right' }}>Adj hrs</th>
|
||||
<th style={{ ...thStyle, textAlign: 'right' }}>Adj FTE</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{grouped[disc].map((r) => (
|
||||
<tr key={r.role_id} style={{ borderBottom: '1px solid var(--color-border-light)' }}>
|
||||
<td style={tdStyle}>
|
||||
{r.role_title}
|
||||
{r.is_programme_role && <span style={progBadgeStyle}>PROGRAMME</span>}
|
||||
</td>
|
||||
<td style={{ ...tdStyle, textAlign: 'right', fontFamily: 'ui-monospace, monospace' }}>{r.total_hours.toFixed(2)}</td>
|
||||
<td style={{ ...tdStyle, textAlign: 'right', fontFamily: 'ui-monospace, monospace' }}>{r.fte.toFixed(3)}</td>
|
||||
<td style={{ ...tdStyle, textAlign: 'right', fontFamily: 'ui-monospace, monospace', color: r.efficiency_pct > 0 ? 'var(--color-accent)' : 'var(--color-text-muted)' }}>{r.efficiency_pct}%</td>
|
||||
<td style={{ ...tdStyle, textAlign: 'right', fontFamily: 'ui-monospace, monospace', color: '#86efac' }}>{r.adjusted_hours.toFixed(2)}</td>
|
||||
<td style={{ ...tdStyle, textAlign: 'right', fontFamily: 'ui-monospace, monospace', fontWeight: 600 }}>{r.adjusted_fte.toFixed(3)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DisciplineOverride({ discipline, value, onChange }: { discipline: string; value: number; onChange: (pct: number) => void }) {
|
||||
return (
|
||||
<div style={{ background: 'var(--color-bg-input)', border: '1px solid var(--color-border-light)', borderRadius: 8, padding: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 4 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 600 }}>{discipline}</span>
|
||||
<span style={{ fontFamily: 'ui-monospace, monospace', color: value > 0 ? 'var(--color-accent)' : 'var(--color-text-muted)' }}>
|
||||
{value}%
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range" min={0} max={90} step={5}
|
||||
value={value}
|
||||
onChange={(e) => onChange(parseInt(e.target.value, 10))}
|
||||
style={sliderStyle}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value, fg }: { label: string; value: number | string; fg?: string }) {
|
||||
return (
|
||||
<div>
|
||||
<div style={{ fontFamily: 'ui-monospace, monospace', fontWeight: 700, fontSize: 22, color: fg || 'var(--color-text)' }}>{value}</div>
|
||||
<div style={metaLabelStyle}>{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cardStyle: React.CSSProperties = {
|
||||
background: 'var(--color-bg-card)', border: '1px solid var(--color-border)',
|
||||
borderRadius: 10, padding: 18,
|
||||
};
|
||||
const titleStyle: React.CSSProperties = { margin: 0, fontSize: 15, fontWeight: 600 };
|
||||
const hintStyle: React.CSSProperties = { marginTop: 4, marginBottom: 12, color: 'var(--color-text-muted)', fontSize: 12 };
|
||||
const statsRowStyle: React.CSSProperties = { display: 'flex', gap: 28, padding: '8px 4px', flexWrap: 'wrap' };
|
||||
const metaLabelStyle: React.CSSProperties = {
|
||||
fontSize: 11, color: 'var(--color-text-muted)',
|
||||
textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 600,
|
||||
};
|
||||
const labelStyle: React.CSSProperties = { display: 'block' };
|
||||
const sliderStyle: React.CSSProperties = { width: '100%', accentColor: 'var(--color-accent)', marginTop: 4 };
|
||||
const ghostBtnStyle: React.CSSProperties = {
|
||||
background: 'transparent', color: 'var(--color-text-secondary)',
|
||||
border: '1px solid var(--color-border)', padding: '6px 10px', borderRadius: 6,
|
||||
fontWeight: 500, fontSize: 11, cursor: 'pointer',
|
||||
};
|
||||
const emptyStyle: React.CSSProperties = { color: 'var(--color-text-muted)', fontSize: 13 };
|
||||
const tableWrapStyle: React.CSSProperties = { overflowX: 'auto' };
|
||||
const tableStyle: React.CSSProperties = { width: '100%', borderCollapse: 'collapse', fontSize: 13 };
|
||||
const thStyle: React.CSSProperties = {
|
||||
padding: '8px 10px', textAlign: 'left',
|
||||
background: 'rgba(255,255,255,0.03)',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
fontWeight: 600, fontSize: 11,
|
||||
color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
};
|
||||
const tdStyle: React.CSSProperties = { padding: '8px 10px', verticalAlign: 'top', color: 'var(--color-text-secondary)' };
|
||||
const progBadgeStyle: React.CSSProperties = {
|
||||
marginLeft: 8, fontSize: 9, fontWeight: 700, letterSpacing: '0.05em',
|
||||
padding: '2px 6px', borderRadius: 8,
|
||||
background: 'rgba(255,196,7,0.10)', color: '#FFC407',
|
||||
};
|
||||
|
|
@ -10,6 +10,7 @@ import Stage5IngestAnswers from '../components/Stage5IngestAnswers';
|
|||
import Stage6Normalize from '../components/Stage6Normalize';
|
||||
import Stage7Match from '../components/Stage7Match';
|
||||
import Stage8Ratecard from '../components/Stage8Ratecard';
|
||||
import Stage11TeamShape from '../components/Stage11TeamShape';
|
||||
import StageApprovals from '../components/StageApprovals';
|
||||
|
||||
export default function OpportunityView() {
|
||||
|
|
@ -120,7 +121,11 @@ export default function OpportunityView() {
|
|||
<Stage8Ratecard opportunityId={opportunityId} canEdit={stageState?.status !== 'completed'} />
|
||||
)}
|
||||
|
||||
{activeStage > 8 && !GATED_STAGES.has(activeStage) && (
|
||||
{activeStage === 11 && (
|
||||
<Stage11TeamShape opportunityId={opportunityId} canEdit={stageState?.status !== 'completed'} />
|
||||
)}
|
||||
|
||||
{activeStage > 8 && activeStage !== 11 && !GATED_STAGES.has(activeStage) && (
|
||||
<div style={{ color: 'var(--color-text-muted)', fontSize: 13 }}>
|
||||
This stage isn't built yet — the state machine runs but there's no agent, UI, or artifact persistence
|
||||
specific to it. Advancing here is harmless on a test opportunity.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue