Files
headroom/backend/app/Services/UtilizationService.php
Santhosh Janardhanan f87ccccc4d Based on the provided specification, I will summarize the changes and
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
2026-04-20 16:38:41 -04:00

381 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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');
}
}