1. Slow Team Member Dropdown - Fixed - Added cached team members store with 5-minute TTL - Dropdown now loads instantly on subsequent visits 2. Error Preventing Capacity Save - Fixed - Added saveAvailability API endpoint - Added backend service method to persist availability overrides - Added proper error handling and success feedback - Cache invalidation on save 3. Filters Not Working - Fixed - Fixed PTOManager to use shared selectedMemberId - Filters now react to team member selection Test Results: - Backend: 76 passed ✅ - Frontend Unit: 10 passed ✅ - E2E: 130 passed, 24 skipped ✅ Refs: openspec/changes/headroom-foundation
378 lines
12 KiB
PHP
378 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Holiday;
|
|
use App\Models\Pto;
|
|
use App\Models\TeamMember;
|
|
use App\Models\TeamMemberAvailability;
|
|
use App\Utilities\WorkingDaysCalculator;
|
|
use Carbon\Carbon;
|
|
use Carbon\CarbonPeriod;
|
|
use DateTimeInterface;
|
|
use Illuminate\Cache\Repository as CacheRepository;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Throwable;
|
|
|
|
class CapacityService
|
|
{
|
|
private int $hoursPerDay = 8;
|
|
|
|
private ?bool $redisAvailable = null;
|
|
|
|
/**
|
|
* Calculate how many working days exist for the supplied month (weekends and holidays excluded).
|
|
*/
|
|
public function calculateWorkingDays(string $month): int
|
|
{
|
|
$holidayDates = $this->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]);
|
|
}
|
|
|
|
public function upsertTeamMemberAvailability(string $teamMemberId, string $date, float $availability): TeamMemberAvailability
|
|
{
|
|
$entry = TeamMemberAvailability::updateOrCreate(
|
|
['team_member_id' => $teamMemberId, 'date' => $date],
|
|
['availability' => $availability]
|
|
);
|
|
|
|
$month = Carbon::createFromFormat('Y-m-d', $date)->format('Y-m');
|
|
|
|
$this->forgetCapacityCacheForTeamMember($teamMemberId, [$month]);
|
|
$this->forgetCapacityCacheForMonth($month);
|
|
|
|
return $entry;
|
|
}
|
|
|
|
/**
|
|
* 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');
|
|
}
|
|
}
|