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

217 lines
8 KiB
Python

"""Export ratecard data to Excel."""
import io
import logging
from collections import defaultdict
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
from openpyxl.utils import get_column_letter
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.gmal import GmalAsset, Role
from app.models.project import Project, ClientAsset, Match, RatecardLine
logger = logging.getLogger(__name__)
HEADER_FILL = PatternFill(start_color="1F4E79", end_color="1F4E79", fill_type="solid")
HEADER_FONT = Font(color="FFFFFF", bold=True, size=11)
DISCIPLINE_FILL = PatternFill(start_color="D6E4F0", end_color="D6E4F0", fill_type="solid")
THIN_BORDER = Border(
left=Side(style="thin"),
right=Side(style="thin"),
top=Side(style="thin"),
bottom=Side(style="thin"),
)
async def export_ratecard_excel(db: AsyncSession, project: Project) -> bytes:
"""Generate an Excel workbook with the ratecard data.
Returns the workbook as bytes.
"""
wb = Workbook()
# Load all data
lines_result = await db.execute(
select(RatecardLine).where(RatecardLine.project_id == project.id)
)
lines = lines_result.scalars().all()
if not lines:
ws = wb.active
ws.title = "Ratecard"
ws["A1"] = "No ratecard data available"
return _workbook_to_bytes(wb)
# Load related entities
role_ids = list(set(l.role_id for l in lines))
asset_ids = list(set(l.client_asset_id for l in lines))
gmal_ids = list(set(l.gmal_asset_id for l in lines))
roles_result = await db.execute(select(Role).where(Role.id.in_(role_ids)))
roles = {r.id: r for r in roles_result.scalars().all()}
assets_result = await db.execute(select(ClientAsset).where(ClientAsset.id.in_(asset_ids)))
client_assets = {a.id: a for a in assets_result.scalars().all()}
gmals_result = await db.execute(select(GmalAsset).where(GmalAsset.id.in_(gmal_ids)))
gmals = {g.id: g for g in gmals_result.scalars().all()}
# Sheet 1: Ratecard Summary (roles x assets matrix)
ws1 = wb.active
ws1.title = "Ratecard Summary"
_build_ratecard_sheet(ws1, lines, roles, client_assets, gmals)
# Sheet 2: Asset Detail
ws2 = wb.create_sheet("Asset Detail")
await _build_asset_detail_sheet(ws2, db, project, client_assets, gmals)
return _workbook_to_bytes(wb)
def _build_ratecard_sheet(ws, lines, roles, client_assets, gmals):
"""Build the main ratecard matrix: rows=roles, cols=client assets."""
# Get unique sorted client assets and roles
asset_ids_ordered = sorted(client_assets.keys())
role_ids_ordered = sorted(roles.keys(), key=lambda rid: (roles[rid].discipline, roles[rid].sort_order or 0))
# Build hours lookup: {(role_id, client_asset_id): total_hours}
hours_map = {}
for line in lines:
effective_hours = line.manual_override if line.manual_override is not None else line.total_hours
hours_map[(line.role_id, line.client_asset_id)] = float(effective_hours or 0)
# Headers
ws.cell(row=1, column=1, value="Discipline").font = HEADER_FONT
ws.cell(row=1, column=1).fill = HEADER_FILL
ws.cell(row=1, column=2, value="Role").font = HEADER_FONT
ws.cell(row=1, column=2).fill = HEADER_FILL
for col_idx, asset_id in enumerate(asset_ids_ordered, 3):
ca = client_assets[asset_id]
gmal_id = None
for line in lines:
if line.client_asset_id == asset_id:
g = gmals.get(line.gmal_asset_id)
gmal_id = g.gmal_id if g else None
break
header = f"{ca.raw_name}\n(Vol: {ca.volume})"
if gmal_id:
header += f"\n[{gmal_id}]"
cell = ws.cell(row=1, column=col_idx, value=header)
cell.font = HEADER_FONT
cell.fill = HEADER_FILL
cell.alignment = Alignment(wrap_text=True, horizontal="center")
# Total column
total_col = len(asset_ids_ordered) + 3
ws.cell(row=1, column=total_col, value="Total Hours").font = HEADER_FONT
ws.cell(row=1, column=total_col).fill = HEADER_FILL
# Data rows
current_discipline = None
row_idx = 2
for role_id in role_ids_ordered:
role = roles[role_id]
# Check if this role has any hours at all
role_total = sum(hours_map.get((role_id, aid), 0) for aid in asset_ids_ordered)
if role_total == 0:
continue
# Discipline grouping
if role.discipline != current_discipline:
current_discipline = role.discipline
ws.cell(row=row_idx, column=1, value=current_discipline).font = Font(bold=True)
ws.cell(row=row_idx, column=1).fill = DISCIPLINE_FILL
for c in range(1, total_col + 1):
ws.cell(row=row_idx, column=c).fill = DISCIPLINE_FILL
row_idx += 1
ws.cell(row=row_idx, column=1, value=role.discipline)
ws.cell(row=row_idx, column=2, value=role.role_title)
row_total = 0
for col_idx, asset_id in enumerate(asset_ids_ordered, 3):
hours = hours_map.get((role_id, asset_id), 0)
if hours > 0:
ws.cell(row=row_idx, column=col_idx, value=round(hours, 2))
row_total += hours
ws.cell(row=row_idx, column=total_col, value=round(row_total, 2)).font = Font(bold=True)
row_idx += 1
# Grand total row
row_idx += 1
ws.cell(row=row_idx, column=1, value="TOTAL").font = Font(bold=True, size=12)
grand_total = 0
for col_idx, asset_id in enumerate(asset_ids_ordered, 3):
col_total = sum(hours_map.get((rid, asset_id), 0) for rid in role_ids_ordered)
if col_total > 0:
ws.cell(row=row_idx, column=col_idx, value=round(col_total, 2)).font = Font(bold=True)
grand_total += col_total
ws.cell(row=row_idx, column=total_col, value=round(grand_total, 2)).font = Font(bold=True, size=12)
# Column widths
ws.column_dimensions["A"].width = 25
ws.column_dimensions["B"].width = 35
for col_idx in range(3, total_col + 1):
ws.column_dimensions[get_column_letter(col_idx)].width = 18
async def _build_asset_detail_sheet(ws, db, project, client_assets, gmals):
"""Build the asset detail sheet showing matches and caveats."""
headers = ["Client Asset", "Volume", "Matched GMAL", "GMAL Name", "Confidence", "Score", "Caveats"]
for col_idx, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col_idx, value=header)
cell.font = HEADER_FONT
cell.fill = HEADER_FILL
# Load matches
from app.models.project import Match
matches_result = await db.execute(
select(Match).where(
Match.client_asset_id.in_(list(client_assets.keys())),
Match.is_selected == True,
)
)
matches = matches_result.scalars().all()
match_by_asset = {m.client_asset_id: m for m in matches}
row_idx = 2
for asset_id in sorted(client_assets.keys()):
ca = client_assets[asset_id]
match = match_by_asset.get(asset_id)
ws.cell(row=row_idx, column=1, value=ca.raw_name)
ws.cell(row=row_idx, column=2, value=ca.volume)
if match:
gmal = gmals.get(match.gmal_asset_id)
ws.cell(row=row_idx, column=3, value=gmal.gmal_id if gmal else "")
ws.cell(row=row_idx, column=4, value=gmal.unique_name if gmal else "")
ws.cell(row=row_idx, column=5, value=match.confidence.value)
ws.cell(row=row_idx, column=6, value=float(match.confidence_score) if match.confidence_score else 0)
ws.cell(row=row_idx, column=7, value=match.caveat_text or "")
else:
ws.cell(row=row_idx, column=3, value="No match")
row_idx += 1
# Column widths
widths = [30, 10, 15, 40, 12, 10, 60]
for i, w in enumerate(widths, 1):
ws.column_dimensions[get_column_letter(i)].width = w
def _workbook_to_bytes(wb: Workbook) -> bytes:
buf = io.BytesIO()
wb.save(buf)
buf.seek(0)
return buf.read()