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