"""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