Files
headroom/backend/app/Services/VarianceCalculator.php
Santhosh Janardhanan 2a93245970 feat(backend): add reporting and variance calculation services
Implement core reporting infrastructure:
- ReconciliationCalculator: plan vs estimate reconciliation
- VarianceCalculator: project and member variance calculations
- ReportController: GET /api/reports/allocations endpoint
- Support did/is/will view type inference based on date ranges
- Distinguish blank plans from explicit zero values

Part of enhanced-allocation change.
2026-03-08 18:22:34 -04:00

165 lines
5.5 KiB
PHP

<?php
namespace App\Services;
use App\Models\Allocation;
use App\Models\ProjectMonthPlan;
use Carbon\Carbon;
class VarianceCalculator
{
/**
* Calculate row variance for a project in a given month.
* Row variance = allocated_total - planned_month
*
* @return array{
* allocated_total: float,
* planned_month: float,
* variance: float,
* status: string
* }
*/
public function calculateRowVariance(string $projectId, string $month): array
{
// Convert YYYY-MM to YYYY-MM-01 and then to a Carbon date for proper comparison
$monthDate = strlen($month) === 7 ? Carbon::createFromFormat('Y-m', $month)->startOfMonth() : Carbon::parse($month);
// Get total allocated hours for this project/month (including untracked)
$allocatedTotal = Allocation::where('project_id', $projectId)
->whereMonth('month', $monthDate->month)
->whereYear('month', $monthDate->year)
->sum('allocated_hours');
// Get planned hours for this project/month (treat null as 0)
$plannedMonth = $this->getPlannedHoursForMonth($projectId, $month);
$variance = $allocatedTotal - $plannedMonth;
return [
'allocated_total' => $allocatedTotal,
'planned_month' => $plannedMonth,
'variance' => $variance,
'status' => $this->determineStatus($variance),
];
}
/**
* Calculate column variance for a team member in a given month.
* Column variance = member_allocated - member_capacity
*
* @return array{
* allocated: float,
* capacity: float,
* variance: float,
* status: string
* }
*/
public function calculateColumnVariance(string $teamMemberId, string $month, CapacityService $capacityService): array
{
// Convert YYYY-MM to Carbon date for proper comparison
$monthDate = strlen($month) === 7 ? Carbon::createFromFormat('Y-m', $month)->startOfMonth() : Carbon::parse($month);
// Get total allocated hours for this member/month (excluding untracked)
$allocated = Allocation::where('team_member_id', $teamMemberId)
->whereMonth('month', $monthDate->month)
->whereYear('month', $monthDate->year)
->sum('allocated_hours');
// Get member capacity
$capacityData = $capacityService->calculateIndividualCapacity($teamMemberId, $month);
$capacity = $capacityData['hours'] ?? 0;
$variance = $allocated - $capacity;
return [
'allocated' => $allocated,
'capacity' => $capacity,
'variance' => $variance,
'status' => $this->determineStatus($variance),
];
}
/**
* Get planned hours for a project/month.
* Returns 0 if no plan exists (blank month treated as 0).
*/
public function getPlannedHoursForMonth(string $projectId, string $month): float
{
$monthDate = strlen($month) === 7 ? Carbon::createFromFormat('Y-m', $month)->startOfMonth() : Carbon::parse($month);
$plan = ProjectMonthPlan::where('project_id', $projectId)
->whereMonth('month', $monthDate->month)
->whereYear('month', $monthDate->year)
->first();
// Blank plan is treated as 0 for allocation variance
return (float) ($plan?->planned_hours ?? 0);
}
/**
* Determine status based on variance value.
*
* - Positive variance (> 0): OVER (red)
* - Negative variance (< 0): UNDER (amber)
* - Zero variance: MATCH (neutral)
*/
public function determineStatus(float $variance): string
{
$epsilon = 0.0001;
if ($variance > $epsilon) {
return 'OVER';
}
if ($variance < -$epsilon) {
return 'UNDER';
}
return 'MATCH';
}
/**
* Calculate both row and column variances for a complete matrix view.
*
* @return array{
* project_variances: array<string, array>,
* team_member_variances: array<string, array>
* }
*/
public function calculateMatrixVariances(string $month, CapacityService $capacityService): array
{
// Convert YYYY-MM to Carbon date for proper comparison
$monthDate = strlen($month) === 7 ? Carbon::createFromFormat('Y-m', $month)->startOfMonth() : Carbon::parse($month);
// Get all allocations for the month using whereMonth/whereYear
$allocations = Allocation::whereMonth('month', $monthDate->month)
->whereYear('month', $monthDate->year)
->get();
// Calculate row variances per project
$projectIds = $allocations->pluck('project_id')->unique()->toArray();
$projectVariances = [];
foreach ($projectIds as $projectId) {
$projectVariances[$projectId] = $this->calculateRowVariance($projectId, $month);
}
// Calculate column variances per team member (excluding null/untracked)
$teamMemberIds = $allocations->pluck('team_member_id')
->filter(fn ($id) => $id !== null)
->unique()
->toArray();
$teamMemberVariances = [];
foreach ($teamMemberIds as $teamMemberId) {
$teamMemberVariances[$teamMemberId] = $this->calculateColumnVariance($teamMemberId, $month, $capacityService);
}
return [
'project_variances' => $projectVariances,
'team_member_variances' => $teamMemberVariances,
];
}
}