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
This commit is contained in:
2026-04-20 16:38:41 -04:00
parent 90c15c70b7
commit f87ccccc4d
261 changed files with 54496 additions and 126 deletions

View File

@@ -4,12 +4,13 @@ namespace App\Services;
use App\Models\Actual;
use App\Models\Allocation;
use Carbon\Carbon;
class ActualsService
{
public function calculateVariance(string $projectId, string $teamMemberId, string $month): array
{
$monthDate = $month.'-01';
$monthDate = Carbon::createFromFormat('Y-m', $month)->startOfMonth();
$allocated = (float) Allocation::where('project_id', $projectId)
->where('team_member_id', $teamMemberId)
@@ -22,7 +23,7 @@ class ActualsService
->sum('hours_logged');
if ($allocated <= 0) {
$variancePercentage = $actual === 0 ? 0.0 : 100.0;
$variancePercentage = $actual == 0 ? 0.0 : 100.0;
} else {
$variancePercentage = (($actual - $allocated) / $allocated) * 100;
}
@@ -37,7 +38,7 @@ class ActualsService
public function getInactiveProjectStatuses(): array
{
return ['Done', 'Cancelled'];
return ['Done', 'Cancelled', 'Closed'];
}
public function canLogToInactiveProjects(): bool

View File

@@ -0,0 +1,175 @@
<?php
namespace App\Services;
/**
* Utilization Formatter Service
*
* Handles formatting and presentation logic for utilization data.
* Extracted from UtilizationService for single responsibility.
*/
class UtilizationFormatter
{
/**
* Utilization indicator thresholds.
*/
public const THRESHOLD_UNDERUTILIZED = 70;
public const THRESHOLD_LOW = 80;
public const THRESHOLD_OPTIMAL = 100;
public const THRESHOLD_CAUTION = 110;
/**
* Indicator color mapping.
*/
public const INDICATOR_GRAY = 'gray';
public const INDICATOR_BLUE = 'blue';
public const INDICATOR_GREEN = 'green';
public const INDICATOR_YELLOW = 'yellow';
public const INDICATOR_RED = 'red';
/**
* Get utilization indicator based on percentage.
*
* Thresholds:
* - < 70%: gray (underutilized)
* - 70-80%: blue (low)
* - 80-100%: green (optimal)
* - 100-110%: yellow (caution)
* - > 110%: red (over-allocated)
*/
public function getIndicator(float $utilization): string
{
if ($utilization < self::THRESHOLD_UNDERUTILIZED) {
return self::INDICATOR_GRAY;
}
if ($utilization < self::THRESHOLD_LOW) {
return self::INDICATOR_BLUE;
}
if ($utilization <= self::THRESHOLD_OPTIMAL) {
return self::INDICATOR_GREEN;
}
if ($utilization <= self::THRESHOLD_CAUTION) {
return self::INDICATOR_YELLOW;
}
return self::INDICATOR_RED;
}
/**
* Get display color for UI frameworks (maps yellow to amber).
*/
public function getDisplayColor(float $utilization): string
{
return match ($this->getIndicator($utilization)) {
self::INDICATOR_GRAY => 'gray',
self::INDICATOR_BLUE => 'blue',
self::INDICATOR_GREEN => 'green',
self::INDICATOR_YELLOW => 'amber',
self::INDICATOR_RED => 'red',
default => 'gray',
};
}
/**
* Get status description for utilization level.
*/
public function getStatusDescription(float $utilization): string
{
return match ($this->getIndicator($utilization)) {
self::INDICATOR_GRAY => 'Under-utilized',
self::INDICATOR_BLUE => 'Low utilization',
self::INDICATOR_GREEN => 'Optimal',
self::INDICATOR_YELLOW => 'High utilization',
self::INDICATOR_RED => 'Over-allocated',
default => 'Unknown',
};
}
/**
* Format utilization percentage for display.
*/
public function formatPercentage(float $utilization, int $decimals = 1): string
{
return number_format($utilization, $decimals).'%';
}
/**
* Format hours for display.
*/
public function formatHours(float $hours, int $decimals = 1): string
{
return number_format($hours, $decimals).'h';
}
/**
* Get Tailwind CSS classes for utilization badge.
*/
public function getTailwindClasses(float $utilization): array
{
$indicator = $this->getIndicator($utilization);
return [
'bg' => match ($indicator) {
self::INDICATOR_GRAY => 'bg-gray-100',
self::INDICATOR_BLUE => 'bg-blue-100',
self::INDICATOR_GREEN => 'bg-green-100',
self::INDICATOR_YELLOW => 'bg-yellow-100',
self::INDICATOR_RED => 'bg-red-100',
default => 'bg-gray-100',
},
'text' => match ($indicator) {
self::INDICATOR_GRAY => 'text-gray-700',
self::INDICATOR_BLUE => 'text-blue-700',
self::INDICATOR_GREEN => 'text-green-700',
self::INDICATOR_YELLOW => 'text-yellow-700',
self::INDICATOR_RED => 'text-red-700',
default => 'text-gray-700',
},
'border' => match ($indicator) {
self::INDICATOR_GRAY => 'border-gray-300',
self::INDICATOR_BLUE => 'border-blue-300',
self::INDICATOR_GREEN => 'border-green-300',
self::INDICATOR_YELLOW => 'border-yellow-300',
self::INDICATOR_RED => 'border-red-300',
default => 'border-gray-300',
},
];
}
/**
* Get DaisyUI badge class for utilization indicator.
*/
public function getDaisyuiBadgeClass(float $utilization): string
{
return match ($this->getIndicator($utilization)) {
self::INDICATOR_GRAY => 'badge-neutral',
self::INDICATOR_BLUE => 'badge-info',
self::INDICATOR_GREEN => 'badge-success',
self::INDICATOR_YELLOW => 'badge-warning',
self::INDICATOR_RED => 'badge-error',
default => 'badge-neutral',
};
}
/**
* Format a complete utilization response with all display metadata.
*/
public function formatUtilizationResponse(float $utilization, float $capacity, float $allocated): array
{
return [
'capacity' => round($capacity, 2),
'allocated' => round($allocated, 2),
'utilization' => round($utilization, 1),
'indicator' => $this->getIndicator($utilization),
'display' => [
'percentage' => $this->formatPercentage($utilization),
'color' => $this->getDisplayColor($utilization),
'status' => $this->getStatusDescription($utilization),
'badge_class' => $this->getDaisyuiBadgeClass($utilization),
],
];
}
}

View File

@@ -0,0 +1,380 @@
<?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');
}
}