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>
This commit is contained in:
DJP 2026-04-27 12:11:04 -04:00
parent 57bffb8347
commit 2d44103603
5 changed files with 19 additions and 14 deletions

View file

@ -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,

View file

@ -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

View file

@ -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)

View file

@ -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())

View file

@ -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() {
<td className="text-right">{l.base_hours?.toFixed(2)}</td>
<td className="text-center">{l.volume}</td>
<td className="text-right td-total">
{(l.manual_override ?? l.total_hours)?.toFixed(2)}
{(((l.manual_override ?? l.total_hours) ?? 0) * (l.volume || 1)).toFixed(2)}
</td>
</tr>
))}