gmal-scope-builder/backend/app/api/ratecard.py
DJP 2d44103603 Fix hours × volume bug: store per-1-asset hours, link directly to GMAL
Ratecard lines now store total_hours as per-1-asset hours (= base_hours,
linked to the GMAL row), with volume tracked separately. Aggregators
(team_shape, ratecard summary, Excel matrix, in-app ratecard tab) multiply
by volume themselves when computing total effort. Display behavior is
preserved; storage semantics are clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 12:11:04 -04:00

253 lines
9.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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():
# total_hours / manual_override are per-1-asset; project total = per-asset × volume.
per_asset = float(rl.manual_override) if rl.manual_override is not None else float(rl.total_hours or 0)
total_hours += per_asset * (rl.volume or 1)
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