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:
362
backend/app/Services/CapacityService.php
Normal file
362
backend/app/Services/CapacityService.php
Normal 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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user