gmal-scope-builder/backend/app/services/team_shape.py
DJP 82046c784c P1: Role-specific efficiency profiles + BTG tool efficiencies
Backend:
- New models: MatchFeedback, EfficiencyProfile, EfficiencyRate, ToolEfficiency, ToolEfficiencyRate
- 3 preset profiles seeded: Conservative, Moderate, Aggressive with per-discipline rates
- 6 BTG tools seeded: Pencil, OMG, Creative X, Cortex, Semblance, Share of Model
- Efficiency API: CRUD for profiles and tools at /api/efficiency/
- team_shape.py: accepts profile_rates + tool_rates (per-discipline, additive, capped at 90%)
- team-shape endpoint: accepts profile_id and tool_ids query params
- Programme roles always exempt regardless of method

Example: Moderate profile + Creative X + Pencil → Account Mgmt 10%, Creative 70%, Production 65%

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:48:30 -04:00

115 lines
3.9 KiB
Python

"""Calculate team shape (FTE headcount) from ratecard data."""
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.project import Project, RatecardLine
logger = logging.getLogger(__name__)
HOURS_PER_FTE = 1800
MAX_EFFICIENCY = 90 # Cap at 90% - never fully eliminate a role
async def calculate_team_shape(
db: AsyncSession,
project: Project,
efficiency_pct: float = 0,
profile_rates: dict[str, float] | None = None,
tool_rates: dict[str, float] | None = None,
) -> list[dict]:
"""Calculate FTE headcount per role from the ratecard.
Efficiency can be applied in three ways (in priority order):
1. profile_rates + tool_rates: per-discipline rates from profile + additive tool rates
2. efficiency_pct: legacy blanket percentage for all delivery roles
3. None: no efficiency applied
Programme roles are NEVER reduced regardless of method.
Total efficiency per discipline is capped at MAX_EFFICIENCY%.
"""
lines_result = await db.execute(
select(RatecardLine).where(RatecardLine.project_id == project.id)
)
lines = lines_result.scalars().all()
if not lines:
return []
# Aggregate hours per role
role_hours: dict[int, float] = defaultdict(float)
for line in lines:
effective = float(line.manual_override) if line.manual_override is not None else float(line.total_hours or 0)
role_hours[line.role_id] += effective
# Load role details
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()}
# Build combined efficiency map per discipline
discipline_efficiency = {}
if profile_rates:
for disc, pct in profile_rates.items():
discipline_efficiency[disc] = pct
# Add tool rates (additive)
if tool_rates:
for disc, pct in tool_rates.items():
discipline_efficiency[disc] = discipline_efficiency.get(disc, 0) + pct
# Cap at MAX_EFFICIENCY
for disc in discipline_efficiency:
discipline_efficiency[disc] = min(discipline_efficiency[disc], MAX_EFFICIENCY)
# Build team shape
team = []
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)
# Determine efficiency for this role
if role.is_programme_role:
eff = 0
elif discipline_efficiency:
eff = discipline_efficiency.get(role.discipline, 0)
elif efficiency_pct > 0:
eff = efficiency_pct
else:
eff = 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_hours = sum(t["total_hours"] for t in team)
total_fte = sum(t["fte"] for t in team)
logger.info(f"Team shape: {len(team)} roles, {total_hours:.0f} hours, {total_fte:.2f} FTE")
return team