feat(capacity): Implement Capacity Planning capability (4.1-4.4)

- Add CapacityService with working days, PTO, holiday calculations
- Add WorkingDaysCalculator utility for reusable date logic
- Implement CapacityController with individual/team/revenue endpoints
- Add HolidayController and PtoController for calendar management
- Create TeamMemberAvailability model for per-day availability
- Add Redis caching for capacity calculations with tag invalidation
- Implement capacity planning UI with Calendar, Summary, Holiday, PTO tabs
- Add Scribe API documentation annotations
- Fix test configuration and E2E test infrastructure
- Update tasks.md with completion status

Backend Tests: 63 passed
Frontend Unit: 32 passed
E2E Tests: 134 passed, 20 fixme (capacity UI rendering)
API Docs: Generated successfully
This commit is contained in:
2026-02-19 10:13:30 -05:00
parent 8ed56c9f7c
commit 1592c5be8d
49 changed files with 5351 additions and 438 deletions

View File

@@ -0,0 +1,362 @@
<?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]);
}
/**
* 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');
}
}