gmal-scope-builder/backend/app/api/ratecard.py
DJP e18976fdb2 Initial commit - GMAL Scope Builder
Dockerized web app (FastAPI + React + PostgreSQL) for scoping client ratecards
against the GMAL master asset database. Features:
- GMAL data ingestion from Excel (390 assets, 120 roles, 5 model types)
- AI-powered document parsing and asset extraction (Claude Opus 4.6)
- AI matching engine with parallel batching, confidence scoring, caveats
- Ratecard builder with hours x volume calculation
- Excel and PDF export
- GMAL browser and inline editor
- AI cost tracking per project (persisted to DB)
- Debug panel for AI call inspection
- Dark theme UI with gold (#FFC407) accent

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 17:35:14 -04:00

163 lines
5.8 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.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}/ratecard/export/excel")
async def export_excel(project_id: int, db: AsyncSession = Depends(get_db)):
project = await _get_project(project_id, db)
data = await export_ratecard_excel(db, project)
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