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

252 lines
9 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,
profile_id: int | None = None,
tool_ids: str = "",
db: AsyncSession = Depends(get_db),
):
"""Get team shape (FTE per role) calculated from the ratecard.
Can use either:
- efficiency_pct: blanket % for all delivery roles (legacy)
- profile_id + tool_ids: per-discipline rates from profile + additive tool rates
"""
project = await _get_project(project_id, db)
profile_rates = None
tool_rates_combined = None
if profile_id:
from app.models.feedback import EfficiencyProfile, EfficiencyRate, ToolEfficiency, ToolEfficiencyRate
# Load profile rates
rates_result = await db.execute(
select(EfficiencyRate).where(EfficiencyRate.profile_id == profile_id)
)
profile_rates = {r.discipline: float(r.efficiency_pct) for r in rates_result.scalars().all()}
# Load tool rates if specified
if tool_ids:
tool_id_list = [int(x) for x in tool_ids.split(",") if x.strip().isdigit()]
if tool_id_list:
tool_rates_combined = {}
for tid in tool_id_list:
tr_result = await db.execute(
select(ToolEfficiencyRate).where(ToolEfficiencyRate.tool_id == tid)
)
for tr in tr_result.scalars().all():
tool_rates_combined[tr.discipline] = (
tool_rates_combined.get(tr.discipline, 0) + float(tr.additional_efficiency_pct)
)
team = await calculate_team_shape(
db, project,
efficiency_pct=efficiency_pct,
profile_rates=profile_rates,
tool_rates=tool_rates_combined,
)
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,
"profile_id": profile_id,
"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