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>
253 lines
9.1 KiB
Python
253 lines
9.1 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():
|
||
# 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
|