- 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>
91 lines
3 KiB
Python
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
|