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>
163 lines
5.8 KiB
Python
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
|