From 2d441036035f51a568d2a27cf4d36c567952d0ff Mon Sep 17 00:00:00 2001 From: DJP Date: Mon, 27 Apr 2026 12:11:04 -0400 Subject: [PATCH] =?UTF-8?q?Fix=20hours=20=C3=97=20volume=20bug:=20store=20?= =?UTF-8?q?per-1-asset=20hours,=20link=20directly=20to=20GMAL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/app/api/ratecard.py | 5 +++-- backend/app/services/export_excel.py | 8 +++++--- backend/app/services/ratecard_builder.py | 6 +++--- backend/app/services/team_shape.py | 7 ++++--- frontend/src/pages/ProjectView.tsx | 7 ++++--- 5 files changed, 19 insertions(+), 14 deletions(-) diff --git a/backend/app/api/ratecard.py b/backend/app/api/ratecard.py index 2f0c514..def9b6d 100644 --- a/backend/app/api/ratecard.py +++ b/backend/app/api/ratecard.py @@ -54,8 +54,9 @@ async def get_ratecard(project_id: int, db: AsyncSession = Depends(get_db)): 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 + # total_hours / manual_override are per-1-asset; project total = per-asset × volume. + per_asset = float(rl.manual_override) if rl.manual_override is not None else float(rl.total_hours or 0) + total_hours += per_asset * (rl.volume or 1) lines.append(RatecardLineOut( id=rl.id, client_asset_id=rl.client_asset_id, diff --git a/backend/app/services/export_excel.py b/backend/app/services/export_excel.py index e988393..8e15849 100644 --- a/backend/app/services/export_excel.py +++ b/backend/app/services/export_excel.py @@ -122,11 +122,13 @@ def _build_ratecard_sheet(ws, lines, roles, client_assets, gmals, caveats: dict 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} + # Build hours lookup: {(role_id, client_asset_id): total_effort} + # total_hours / manual_override are stored per-1-asset; multiply by volume for display. 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) + per_asset = line.manual_override if line.manual_override is not None else line.total_hours + volume = line.volume or 1 + hours_map[(line.role_id, line.client_asset_id)] = float(per_asset or 0) * volume # Headers ws.cell(row=1, column=1, value="Discipline").font = HEADER_FONT diff --git a/backend/app/services/ratecard_builder.py b/backend/app/services/ratecard_builder.py index 378a91a..c3dddc9 100644 --- a/backend/app/services/ratecard_builder.py +++ b/backend/app/services/ratecard_builder.py @@ -58,15 +58,15 @@ async def build_ratecard(db: AsyncSession, project: Project) -> list[RatecardLin gmal_hours = hours_result.scalars().all() for gh in gmal_hours: - total = round(float(gh.hours) * client_asset.volume, 2) + 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=float(gh.hours), + base_hours=base, volume=client_asset.volume, - total_hours=total, + total_hours=base, ) db.add(line) lines.append(line) diff --git a/backend/app/services/team_shape.py b/backend/app/services/team_shape.py index d03d079..e4a7697 100644 --- a/backend/app/services/team_shape.py +++ b/backend/app/services/team_shape.py @@ -40,11 +40,12 @@ async def calculate_team_shape( if not lines: return [] - # Aggregate hours per role + # Aggregate hours per role. + # total_hours / manual_override are stored per-1-asset; multiply by volume here. role_hours: dict[int, float] = defaultdict(float) for line in lines: - effective = float(line.manual_override) if line.manual_override is not None else float(line.total_hours or 0) - role_hours[line.role_id] += effective + per_asset = float(line.manual_override) if line.manual_override is not None else float(line.total_hours or 0) + role_hours[line.role_id] += per_asset * (line.volume or 1) # Load role details role_ids = list(role_hours.keys()) diff --git a/frontend/src/pages/ProjectView.tsx b/frontend/src/pages/ProjectView.tsx index e4865ed..64e0cf4 100644 --- a/frontend/src/pages/ProjectView.tsx +++ b/frontend/src/pages/ProjectView.tsx @@ -980,8 +980,9 @@ export default function ProjectView() { hours: 0, }; } - const effective = l.manual_override ?? l.total_hours ?? 0; - assetMap[l.client_asset_id].hours += effective; + // total_hours / manual_override are stored per-1-asset; multiply by volume. + const perAsset = l.manual_override ?? l.total_hours ?? 0; + assetMap[l.client_asset_id].hours += perAsset * (l.volume || 1); } const assetList = Object.entries(assetMap).sort(([a], [b]) => Number(a) - Number(b)); const grandTotal = assetList.reduce((sum, [, a]) => sum + a.hours, 0); @@ -1036,7 +1037,7 @@ export default function ProjectView() { {l.base_hours?.toFixed(2)} {l.volume} - {(l.manual_override ?? l.total_hours)?.toFixed(2)} + {(((l.manual_override ?? l.total_hours) ?? 0) * (l.volume || 1)).toFixed(2)} ))}