gmal-scope-builder/backend/app/api/ratecard.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

213 lines
7.5 KiB
Python

"""Ratecard build and export endpoints."""
import logging
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.gmal import GmalAsset, Role
from app.models.project import Project, ClientAsset, RatecardLine, ProjectStatus
from app.schemas.project import RatecardLineOut, RatecardLineUpdate, RatecardSummary
from app.services.ratecard_builder import build_ratecard
from app.services.team_shape import calculate_team_shape
from app.services.export_excel import export_ratecard_excel
from app.services.export_pdf import export_caveats_pdf
router = APIRouter()
logger = logging.getLogger(__name__)
@router.post("/{project_id}/ratecard/build")
async def build_project_ratecard(project_id: int, db: AsyncSession = Depends(get_db)):
"""Build ratecard from confirmed matches."""
project = await _get_project(project_id, db)
project.status = ProjectStatus.BUILDING
lines = await build_ratecard(db, project)
project.status = ProjectStatus.FINALIZED
await db.commit()
return {
"message": f"Ratecard built with {len(lines)} lines",
"total_lines": len(lines),
}
@router.get("/{project_id}/ratecard", response_model=RatecardSummary)
async def get_ratecard(project_id: int, db: AsyncSession = Depends(get_db)):
"""Get the full ratecard for a project."""
project = await _get_project(project_id, db)
result = await db.execute(
select(RatecardLine, ClientAsset, GmalAsset, Role)
.join(ClientAsset, RatecardLine.client_asset_id == ClientAsset.id)
.join(GmalAsset, RatecardLine.gmal_asset_id == GmalAsset.id)
.join(Role, RatecardLine.role_id == Role.id)
.where(RatecardLine.project_id == project_id)
.order_by(Role.sort_order, ClientAsset.sort_order)
)
lines = []
total_hours = 0
for rl, ca, gmal, role in result.all():
effective = float(rl.manual_override) if rl.manual_override is not None else float(rl.total_hours or 0)
total_hours += effective
lines.append(RatecardLineOut(
id=rl.id,
client_asset_id=rl.client_asset_id,
client_asset_name=ca.raw_name,
gmal_asset_id=rl.gmal_asset_id,
gmal_id=gmal.gmal_id,
role_id=rl.role_id,
role_title=role.role_title,
discipline=role.discipline,
base_hours=float(rl.base_hours) if rl.base_hours else None,
volume=rl.volume,
total_hours=float(rl.total_hours) if rl.total_hours else None,
manual_override=float(rl.manual_override) if rl.manual_override is not None else None,
notes=rl.notes,
))
# Count unique client assets
asset_ids = set(l.client_asset_id for l in lines)
return RatecardSummary(
project_id=project.id,
project_name=project.name,
model_type=project.model_type.value,
total_assets=len(asset_ids),
total_hours=round(total_hours, 2),
lines=lines,
)
@router.put("/{project_id}/ratecard/lines/{line_id}", response_model=RatecardLineOut)
async def update_ratecard_line(
project_id: int,
line_id: int,
data: RatecardLineUpdate,
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(RatecardLine).where(RatecardLine.id == line_id, RatecardLine.project_id == project_id)
)
line = result.scalar_one_or_none()
if not line:
raise HTTPException(status_code=404, detail="Ratecard line not found")
if data.manual_override is not None:
line.manual_override = data.manual_override
if data.notes is not None:
line.notes = data.notes
await db.commit()
await db.refresh(line)
# Re-fetch with joins for response
full_result = await db.execute(
select(RatecardLine, ClientAsset, GmalAsset, Role)
.join(ClientAsset, RatecardLine.client_asset_id == ClientAsset.id)
.join(GmalAsset, RatecardLine.gmal_asset_id == GmalAsset.id)
.join(Role, RatecardLine.role_id == Role.id)
.where(RatecardLine.id == line_id)
)
rl, ca, gmal, role = full_result.one()
return RatecardLineOut(
id=rl.id,
client_asset_id=rl.client_asset_id,
client_asset_name=ca.raw_name,
gmal_asset_id=rl.gmal_asset_id,
gmal_id=gmal.gmal_id,
role_id=rl.role_id,
role_title=role.role_title,
discipline=role.discipline,
base_hours=float(rl.base_hours) if rl.base_hours else None,
volume=rl.volume,
total_hours=float(rl.total_hours) if rl.total_hours else None,
manual_override=float(rl.manual_override) if rl.manual_override is not None else None,
notes=rl.notes,
)
@router.get("/{project_id}/team-shape")
async def get_team_shape(
project_id: int,
efficiency_pct: float = 0,
db: AsyncSession = Depends(get_db),
):
"""Get team shape (FTE per role) calculated from the ratecard."""
project = await _get_project(project_id, db)
team = await calculate_team_shape(db, project, efficiency_pct=efficiency_pct)
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": project.id,
"project_name": project.name,
"hours_per_fte": 1800,
"efficiency_pct": efficiency_pct,
"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,
}
@router.get("/{project_id}/ratecard/export/excel")
async def export_excel(
project_id: int,
efficiency_levels: str = "",
db: AsyncSession = Depends(get_db),
):
project = await _get_project(project_id, db)
# Parse efficiency levels: "10,25,50" -> [10, 25, 50]
levels = []
if efficiency_levels:
for s in efficiency_levels.split(","):
try:
v = int(s.strip())
if 0 < v < 100:
levels.append(v)
except ValueError:
pass
levels = sorted(set(levels))
data = await export_ratecard_excel(db, project, efficiency_levels=levels)
return Response(
content=data,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename={project.name}_ratecard.xlsx"},
)
@router.get("/{project_id}/ratecard/export/pdf")
async def export_pdf(project_id: int, db: AsyncSession = Depends(get_db)):
project = await _get_project(project_id, db)
data = await export_caveats_pdf(db, project)
return Response(
content=data,
media_type="application/pdf",
headers={"Content-Disposition": f"attachment; filename={project.name}_caveats.pdf"},
)
async def _get_project(project_id: int, db: AsyncSession) -> Project:
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
return project