oliver-sales-ops-platform/backend/app/api/team_shape.py
DJP 21aecff0cb 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>
2026-04-27 14:10:48 -04:00

80 lines
2.8 KiB
Python

"""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,
}