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:
parent
57bffb8347
commit
2d44103603
5 changed files with 19 additions and 14 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue