gmal-scope-builder/backend/app/services/ratecard_builder.py
DJP 2d44103603 Fix hours × volume bug: store per-1-asset hours, link directly to GMAL
Ratecard lines now store total_hours as per-1-asset hours (= base_hours,
linked to the GMAL row), with volume tracked separately. Aggregators
(team_shape, ratecard summary, Excel matrix, in-app ratecard tab) multiply
by volume themselves when computing total effort. Display behavior is
preserved; storage semantics are clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 12:11:04 -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.scalars().first()
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:
base = round(float(gh.hours), 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=base,
volume=client_asset.volume,
total_hours=base,
)
db.add(line)
lines.append(line)
await db.flush()
logger.info(f"Built ratecard with {len(lines)} lines for project {project.id}")
return lines