address each point.
**Changes Summary**
This specification updates the `headroom-foundation` change set to
include actuals tracking. The new feature adds a `TeamMember` model for
team members and a `ProjectStatus` model for project statuses.
**Summary of Changes**
1. **Add Team Members**
* Created the `TeamMember` model with attributes: `id`, `name`,
`role`, and `active`.
* Implemented data migration to add all existing users as
`team_member_ids` in the database.
2. **Add Project Statuses**
* Created the `ProjectStatus` model with attributes: `id`, `name`,
`order`, and `is_active`.
* Defined initial project statuses as "Initial" and updated
workflow states accordingly.
3. **Actuals Tracking**
* Introduced a new `Actual` model for tracking actual hours worked
by team members.
* Implemented data migration to add all existing allocations as
`actual_hours` in the database.
* Added methods for updating and deleting actual records.
**Open Issues**
1. **Authorization Policy**: The system does not have an authorization
policy yet, which may lead to unauthorized access or data
modifications.
2. **Project Type Distinguish**: Although project types are
differentiated, there is no distinction between "Billable" and
"Support" in the database.
3. **Cost Reporting**: Revenue forecasts do not include support
projects, and their reporting treatment needs clarification.
**Implementation Roadmap**
1. **Authorization Policy**: Implement an authorization policy to
restrict access to authorized users only.
2. **Distinguish Project Types**: Clarify project type distinction
between "Billable" and "Support".
3. **Cost Reporting**: Enhance revenue forecasting to include support
projects with different reporting treatment.
**Task Assignments**
1. **Authorization Policy**
* Task Owner: John (Automated)
* Description: Implement an authorization policy using Laravel's
built-in middleware.
* Deadline: 2026-03-25
2. **Distinguish Project Types**
* Task Owner: Maria (Automated)
* Description: Update the `ProjectType` model to include a
distinction between "Billable" and "Support".
* Deadline: 2026-04-01
3. **Cost Reporting**
* Task Owner: Alex (Automated)
* Description: Enhance revenue forecasting to include support
projects with different reporting treatment.
* Deadline: 2026-04-15
381 lines
12 KiB
PHP
381 lines
12 KiB
PHP
<?php
|
||
|
||
namespace App\Services;
|
||
|
||
use App\Models\Allocation;
|
||
use App\Models\TeamMember;
|
||
use Carbon\Carbon;
|
||
use DateTimeInterface;
|
||
use Illuminate\Cache\Repository as CacheRepository;
|
||
use Illuminate\Support\Collection;
|
||
use Illuminate\Support\Facades\Cache;
|
||
use Throwable;
|
||
|
||
class UtilizationService
|
||
{
|
||
private ?bool $redisAvailable = null;
|
||
|
||
public function __construct(
|
||
private CapacityService $capacityService,
|
||
private UtilizationFormatter $formatter
|
||
) {}
|
||
|
||
/**
|
||
* Calculate overall utilization for a team member in a specific month.
|
||
* Overall utilization = (Allocated hours this month) / (Capacity this month) × 100%
|
||
*
|
||
* Results are cached for 1 hour.
|
||
*/
|
||
public function calculateOverallUtilization(string $teamMemberId, string $month): array
|
||
{
|
||
$cacheKey = $this->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<string>
|
||
*/
|
||
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');
|
||
}
|
||
}
|