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>
76 lines
2.6 KiB
Python
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
|