gmal-scope-builder/backend/app/services/ratecard_builder.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

76 lines
2.6 KiB
Python

"""Build ratecards from confirmed matches."""
import logging
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.gmal import GmalHours, ModelType
from app.models.project import Project, ClientAsset, Match, RatecardLine
logger = logging.getLogger(__name__)
async def build_ratecard(db: AsyncSession, project: Project) -> list[RatecardLine]:
"""Build a ratecard for a project from its confirmed (selected) matches.
For each selected match, looks up the hours per role from gmal_hours
for the project's model type, multiplies by volume, and creates ratecard lines.
Returns the created RatecardLine objects.
"""
# Clear existing ratecard lines for this project
existing = await db.execute(
select(RatecardLine).where(RatecardLine.project_id == project.id)
)
for line in existing.scalars().all():
await db.delete(line)
await db.flush()
# Get all client assets with their selected matches
assets_result = await db.execute(
select(ClientAsset).where(ClientAsset.project_id == project.id)
)
client_assets = assets_result.scalars().all()
lines = []
for client_asset in client_assets:
# Get the selected match for this asset
match_result = await db.execute(
select(Match).where(
Match.client_asset_id == client_asset.id,
Match.is_selected == True,
)
)
selected_match = match_result.scalar_one_or_none()
if not selected_match:
logger.warning(f"No selected match for client asset {client_asset.id}: {client_asset.raw_name}")
continue
# Get hours for this GMAL asset and model type
hours_result = await db.execute(
select(GmalHours).where(
GmalHours.gmal_asset_id == selected_match.gmal_asset_id,
GmalHours.model_type == project.model_type,
)
)
gmal_hours = hours_result.scalars().all()
for gh in gmal_hours:
total = round(float(gh.hours) * client_asset.volume, 2)
line = RatecardLine(
project_id=project.id,
client_asset_id=client_asset.id,
gmal_asset_id=selected_match.gmal_asset_id,
role_id=gh.role_id,
base_hours=float(gh.hours),
volume=client_asset.volume,
total_hours=total,
)
db.add(line)
lines.append(line)
await db.flush()
logger.info(f"Built ratecard with {len(lines)} lines for project {project.id}")
return lines