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, * team_member_variances: 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, ]; } }