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>
252 lines
9 KiB
Python
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
|