gmal-scope-builder/backend/app/services/team_shape.py
DJP 1c3f6fe78f Add AI/Automation efficiency adjustment for team shape
- Efficiency preview: toggle 10/25/50/75/90% to see adjusted FTE live
- Programme roles NOT reduced (they don't scale with AI)
- Excel export: select multiple efficiency levels, each gets its own tab
  showing original vs adjusted hours/FTE/headcount with hours saved
- Export buttons on both Ratecard and Team Shape tabs
- team_shape service accepts efficiency_pct parameter

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 10:36:44 -04:00

91 lines
3 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
async def calculate_team_shape(
db: AsyncSession,
project: Project,
efficiency_pct: float = 0,
) -> list[dict]:
"""Calculate FTE headcount per role from the ratecard.
FTE = total_hours_per_role / 1800
If efficiency_pct > 0, delivery role hours are reduced by that percentage.
Programme roles are NOT reduced (they don't scale with AI efficiency).
Returns list of dicts sorted by discipline then role order.
"""
# Load ratecard lines
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 team shape
efficiency_multiplier = 1 - (efficiency_pct / 100) if efficiency_pct > 0 else 1
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)
# Apply efficiency - programme roles are NOT reduced
if efficiency_pct > 0 and not role.is_programme_role:
adjusted_hours = round(total_hours * efficiency_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,
"adjusted_hours": adjusted_hours,
"adjusted_fte": adjusted_fte,
"hours_saved": round(total_hours - adjusted_hours, 2),
"fte_saved": round(fte - adjusted_fte, 4),
})
# Sort by discipline then sort_order
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 (efficiency: {efficiency_pct}%)")
return team