getHolidaysForMonth($month) ->pluck('date') ->map(fn (Carbon $date): string => $date->toDateString()) ->all(); return WorkingDaysCalculator::calculate($month, $holidayDates); } /** * Calculate capacity for a single team member for the requested month. */ public function calculateIndividualCapacity(string $teamMemberId, string $month): array { $cacheKey = $this->buildCacheKey($month, $teamMemberId); $tags = $this->getCapacityCacheTags($month, "team_member:{$teamMemberId}"); $resolver = function () use ($teamMemberId, $month): array { $period = $this->createMonthPeriod($month); $holidayDates = $this->getHolidaysForMonth($month) ->pluck('date') ->map(fn (Carbon $date): string => $date->toDateString()) ->all(); $holidayLookup = array_flip($holidayDates); $ptoDates = $this->buildPtoDates($this->getPtoForTeamMember($teamMemberId, $month), $month); $availabilities = $this->getAvailabilityEntries($teamMemberId, $month); $personDays = 0.0; $details = []; foreach ($period as $day) { $date = $day->toDateString(); if (! WorkingDaysCalculator::isWorkingDay($date, $holidayLookup)) { continue; } $availability = $availabilities->get($date, 1.0); $isPto = in_array($date, $ptoDates, true); if ($isPto) { $availability = 0.0; } $details[] = [ 'date' => $date, 'availability' => (float) $availability, 'is_pto' => $isPto, ]; $personDays += $availability; } $hours = (int) round($personDays * $this->hoursPerDay); return [ 'person_days' => round($personDays, 2), 'hours' => $hours, 'details' => $details, ]; }; /** @var array $capacity */ /** @var array $capacity */ $capacity = $this->rememberCapacity($cacheKey, now()->addHour(), $resolver, $tags); return $capacity; } /** * Calculate the combined capacity for all active team members. */ public function calculateTeamCapacity(string $month): array { $cacheKey = $this->buildCacheKey($month, 'team'); $tags = $this->getCapacityCacheTags($month, 'team'); /** @var array $payload */ $payload = $this->rememberCapacity($cacheKey, now()->addHour(), function () use ($month): array { $activeMembers = TeamMember::where('active', true)->get(); $totalDays = 0.0; $totalHours = 0; $members = []; foreach ($activeMembers as $member) { $capacity = $this->calculateIndividualCapacity($member->id, $month); $totalDays += $capacity['person_days']; $totalHours += $capacity['hours']; $members[] = [ 'id' => $member->id, 'name' => $member->name, 'person_days' => $capacity['person_days'], 'hours' => $capacity['hours'], ]; } return [ 'month' => $month, 'person_days' => round($totalDays, 2), 'hours' => $totalHours, 'members' => $members, ]; }, $tags); return $payload; } /** * Estimate revenue by multiplying capacity hours with hourly rates. */ public function calculatePossibleRevenue(string $month): float { $cacheKey = $this->buildCacheKey($month, 'revenue'); $tags = $this->getCapacityCacheTags($month, 'revenue'); /** @var float $revenue */ $revenue = $this->rememberCapacity($cacheKey, now()->addHour(), function () use ($month): float { $activeMembers = TeamMember::where('active', true)->get(); $revenue = 0.0; foreach ($activeMembers as $member) { $capacity = $this->calculateIndividualCapacity($member->id, $month); $revenue += $capacity['hours'] * (float) $member->hourly_rate; } return round($revenue, 2); }, $tags); return $revenue; } /** * Return all holidays in the requested month. */ public function getHolidaysForMonth(string $month): Collection { $period = $this->createMonthPeriod($month); return Holiday::whereBetween('date', [$period->getStartDate(), $period->getEndDate()]) ->orderBy('date') ->get(); } /** * Return approved PTO records for a team member inside the requested month. */ public function getPtoForTeamMember(string $teamMemberId, string $month): Collection { $period = $this->createMonthPeriod($month); return Pto::where('team_member_id', $teamMemberId) ->where('status', 'approved') ->where(function ($query) use ($period): void { $query->whereBetween('start_date', [$period->getStartDate(), $period->getEndDate()]) ->orWhereBetween('end_date', [$period->getStartDate(), $period->getEndDate()]) ->orWhere(function ($nested) use ($period): void { $nested->where('start_date', '<=', $period->getStartDate()) ->where('end_date', '>=', $period->getEndDate()); }); }) ->get(); } /** * Clear redis cache for a specific month and team member. */ public function forgetCapacityCacheForTeamMember(string $teamMemberId, array $months): void { $useRedis = $this->redisAvailable(); foreach ($months as $month) { $tags = $this->getCapacityCacheTags($month, "team_member:{$teamMemberId}"); if ($useRedis) { $this->flushCapacityTags($tags); continue; } $this->forgetCapacity($this->buildCacheKey($month, $teamMemberId)); } } /** * Clear redis cache for a month across all team members. */ public function forgetCapacityCacheForMonth(string $month): void { if ($this->redisAvailable()) { $this->flushCapacityTags($this->getCapacityCacheTags($month)); return; } foreach (TeamMember::pluck('id') as $teamMemberId) { $this->forgetCapacity($this->buildCacheKey($month, $teamMemberId)); } $this->forgetCapacity($this->buildCacheKey($month, 'team')); $this->forgetCapacity($this->buildCacheKey($month, 'revenue')); } /** * Build the cache key used for storing individual capacity data. */ private function buildCacheKey(string $month, string $teamMemberId): string { return "capacity:{$month}:{$teamMemberId}"; } private function getCapacityCacheTags(string $month, ?string $context = null): array { $tags = ['capacity', "capacity:month:{$month}"]; if ($context) { $tags[] = "capacity:{$context}"; } return $tags; } private function flushCapacityTags(array $tags): void { if (! $this->redisAvailable()) { return; } try { /** @var CacheRepository $store */ $store = Cache::store('redis'); $store->tags($tags)->flush(); } catch (Throwable) { // Ignore cache failures when Redis is unavailable. } } /** * Load availability entries for the team member within the month, keyed by date. */ private function getAvailabilityEntries(string $teamMemberId, string $month): Collection { $period = $this->createMonthPeriod($month); return TeamMemberAvailability::where('team_member_id', $teamMemberId) ->whereBetween('date', [$period->getStartDate(), $period->getEndDate()]) ->get() ->mapWithKeys(fn (TeamMemberAvailability $entry) => [$entry->date->toDateString() => (float) $entry->availability]); } /** * Create a CarbonPeriod for the given month. */ private function createMonthPeriod(string $month): CarbonPeriod { $start = Carbon::createFromFormat('Y-m', $month)->startOfMonth(); $end = $start->copy()->endOfMonth(); return CarbonPeriod::create($start, $end); } /** * Expand PTO records into a unique list of dates inside the requested month. */ private function buildPtoDates(Collection $ptos, string $month): array { $period = $this->createMonthPeriod($month); $dates = []; foreach ($ptos as $pto) { $ptoStart = Carbon::create($pto->start_date)->max($period->getStartDate()); $ptoEnd = Carbon::create($pto->end_date)->min($period->getEndDate()); if ($ptoStart->greaterThan($ptoEnd)) { continue; } foreach (CarbonPeriod::create($ptoStart, $ptoEnd) as $day) { $dates[] = $day->toDateString(); } } return array_unique($dates); } private function rememberCapacity(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); } } private function forgetCapacity(string $key): void { if (! $this->redisAvailable()) { return; } try { Cache::store('redis')->forget($key); } catch (Throwable) { // Ignore cache failures when Redis is unavailable. } } 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'); } }