buildCacheKey($teamMemberId, $month, 'overall'); $tags = $this->getCacheTags($month, $teamMemberId); $resolver = function () use ($teamMemberId, $month): array { $capacityData = $this->capacityService->calculateIndividualCapacity($teamMemberId, $month); $capacity = $capacityData['hours'] ?? 0; $allocatedHours = Allocation::where('team_member_id', $teamMemberId) ->whereDate('month', $this->normalizeMonth($month)) ->sum('allocated_hours'); $utilization = $capacity > 0 ? ($allocatedHours / $capacity) * 100 : 0; return [ 'capacity' => round($capacity, 2), 'allocated' => round($allocatedHours, 2), 'utilization' => round($utilization, 1), 'indicator' => $this->formatter->getIndicator($utilization), ]; }; return $this->rememberCache($cacheKey, now()->addHour(), $resolver, $tags); } /** * Calculate running utilization YTD for a team member. * Running utilization = (Allocated hours YTD) / (Capacity YTD) × 100% * * Results are cached for 1 hour. */ public function calculateRunningUtilization(string $teamMemberId, string $month): array { $cacheKey = $this->buildCacheKey($teamMemberId, $month, 'running'); $tags = $this->getCacheTags($month, $teamMemberId); $resolver = function () use ($teamMemberId, $month): array { $year = substr($month, 0, 4); $startMonth = "{$year}-01"; $endMonth = $month; // Get all months from January to current month $months = $this->getMonthsInRange($startMonth, $endMonth); $totalCapacity = 0; $totalAllocated = 0; foreach ($months as $m) { $capacityData = $this->capacityService->calculateIndividualCapacity($teamMemberId, $m); $totalCapacity += $capacityData['hours'] ?? 0; $allocated = Allocation::where('team_member_id', $teamMemberId) ->whereDate('month', $this->normalizeMonth($m)) ->sum('allocated_hours'); $totalAllocated += $allocated; } $utilization = $totalCapacity > 0 ? ($totalAllocated / $totalCapacity) * 100 : 0; return [ 'capacity_ytd' => round($totalCapacity, 2), 'allocated_ytd' => round($totalAllocated, 2), 'utilization' => round($utilization, 1), 'indicator' => $this->formatter->getIndicator($utilization), 'months_included' => count($months), ]; }; return $this->rememberCache($cacheKey, now()->addHour(), $resolver, $tags); } /** * Get combined utilization data for a team member. */ public function getUtilizationData(string $teamMemberId, string $month): array { return [ 'overall' => $this->calculateOverallUtilization($teamMemberId, $month), 'running' => $this->calculateRunningUtilization($teamMemberId, $month), ]; } /** * Calculate team-level utilization (average across active members). * * Results are cached for 1 hour. */ public function calculateTeamUtilization(string $month): array { $cacheKey = "utilization:team:{$month}:overall"; $tags = $this->getCacheTags($month); $resolver = function () use ($month): array { $activeMembers = TeamMember::where('active', true)->get(); $utilizations = []; $totalUtilization = 0; $count = 0; foreach ($activeMembers as $member) { $data = $this->calculateOverallUtilization($member->id, $month); $utilizations[$member->id] = $data; $totalUtilization += $data['utilization']; $count++; } $averageUtilization = $count > 0 ? $totalUtilization / $count : 0; return [ 'average_utilization' => round($averageUtilization, 1), 'average_indicator' => $this->formatter->getIndicator($averageUtilization), 'member_count' => $count, 'by_member' => $utilizations, ]; }; return $this->rememberCache($cacheKey, now()->addHour(), $resolver, $tags); } /** * Calculate team-level running utilization YTD. * * Results are cached for 1 hour. */ public function calculateTeamRunningUtilization(string $month): array { $cacheKey = "utilization:team:{$month}:running"; $tags = $this->getCacheTags($month); $resolver = function () use ($month): array { $activeMembers = TeamMember::where('active', true)->get(); $utilizations = []; $totalUtilization = 0; $count = 0; foreach ($activeMembers as $member) { $data = $this->calculateRunningUtilization($member->id, $month); $utilizations[$member->id] = $data; $totalUtilization += $data['utilization']; $count++; } $averageUtilization = $count > 0 ? $totalUtilization / $count : 0; return [ 'average_utilization' => round($averageUtilization, 1), 'average_indicator' => $this->formatter->getIndicator($averageUtilization), 'member_count' => $count, 'by_member' => $utilizations, ]; }; return $this->rememberCache($cacheKey, now()->addHour(), $resolver, $tags); } /** * Get utilization indicator color based on percentage. * * @deprecated Use UtilizationFormatter::getIndicator() instead */ public function getUtilizationIndicator(float $utilization): string { return $this->formatter->getIndicator($utilization); } /** * Get utilization indicator color name for display. * * @deprecated Use UtilizationFormatter::getDisplayColor() instead */ public function getUtilizationColor(float $utilization): string { return $this->formatter->getDisplayColor($utilization); } /** * Get utilization trend data for a team member over multiple months. */ public function getUtilizationTrend(string $teamMemberId, string $startMonth, string $endMonth): array { $months = $this->getMonthsInRange($startMonth, $endMonth); $trend = []; foreach ($months as $month) { $overall = $this->calculateOverallUtilization($teamMemberId, $month); $trend[] = [ 'month' => $month, 'utilization' => $overall['utilization'], 'indicator' => $overall['indicator'], 'capacity' => $overall['capacity'], 'allocated' => $overall['allocated'], ]; } return $trend; } /** * Clear cache for a specific team member and month(s). */ public function forgetUtilizationCache(string $teamMemberId, array $months): void { foreach ($months as $month) { $tags = $this->getCacheTags($month, $teamMemberId); Cache::store('array')->forget($this->buildCacheKey($teamMemberId, $month, 'overall')); Cache::store('array')->forget($this->buildCacheKey($teamMemberId, $month, 'running')); if ($this->redisAvailable()) { $this->flushCacheTags($tags); } } } /** * Clear cache for an entire month (all team members). */ public function forgetUtilizationCacheForMonth(string $month): void { Cache::store('array')->forget("utilization:team:{$month}:overall"); Cache::store('array')->forget("utilization:team:{$month}:running"); // Clear individual member caches foreach (TeamMember::pluck('id') as $teamMemberId) { Cache::store('array')->forget($this->buildCacheKey($teamMemberId, $month, 'overall')); Cache::store('array')->forget($this->buildCacheKey($teamMemberId, $month, 'running')); } if ($this->redisAvailable()) { $this->flushCacheTags($this->getCacheTags($month)); } } /** * Build cache key for utilization data. */ private function buildCacheKey(string $teamMemberId, string $month, string $type): string { return "utilization:{$type}:{$month}:{$teamMemberId}"; } /** * Get cache tags for utilization data. */ private function getCacheTags(string $month, ?string $teamMemberId = null): array { $tags = ['utilization', "utilization:{$month}"]; if ($teamMemberId) { $tags[] = "utilization:member:{$teamMemberId}"; } return $tags; } /** * Normalize month format to Y-m-01. */ private function normalizeMonth(string $month): string { if (strlen($month) === 7) { return $month.'-01'; } return $month; } /** * Get all months in a range (inclusive). * * @return array */ private function getMonthsInRange(string $startMonth, string $endMonth): array { $start = Carbon::createFromFormat('Y-m', $startMonth)->startOfMonth(); $end = Carbon::createFromFormat('Y-m', $endMonth)->startOfMonth(); $months = []; while ($start->lte($end)) { $months[] = $start->format('Y-m'); $start->addMonth(); } return $months; } /** * Remember value in cache with Redis/array fallback. */ private function rememberCache(string $key, DateTimeInterface|int $ttl, callable $callback, array $tags = []): mixed { if (! $this->redisAvailable()) { return Cache::store('array')->remember($key, $ttl, $callback); } try { /** @var CacheRepository $store */ $store = Cache::store('redis'); if (! empty($tags)) { $store = $store->tags($tags); } return $store->remember($key, $ttl, $callback); } catch (Throwable) { return Cache::store('array')->remember($key, $ttl, $callback); } } /** * Flush cache tags (Redis only). */ private function flushCacheTags(array $tags): void { if (! $this->redisAvailable()) { return; } try { /** @var CacheRepository $store */ $store = Cache::store('redis'); $store->tags($tags)->flush(); } catch (Throwable) { // Ignore cache failures } } /** * Check if Redis is available for caching. */ private function redisAvailable(): bool { if ($this->redisAvailable !== null) { return $this->redisAvailable; } if (! config('cache.stores.redis')) { return $this->redisAvailable = false; } $client = config('database.redis.client', 'phpredis'); if ($client === 'predis') { return $this->redisAvailable = class_exists('\Predis\Client'); } return $this->redisAvailable = extension_loaded('redis'); } }