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:
@@ -10,15 +10,22 @@ use App\Models\Project;
|
||||
use App\Models\TeamMember;
|
||||
use App\Services\ActualsService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
class ActualController extends Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
protected ActualsService $actualsService;
|
||||
private const LOCKED_PROJECT_STATUSES = ['Done', 'Cancelled', 'Closed'];
|
||||
|
||||
private const MAX_PER_PAGE = 250;
|
||||
private const MAX_HOURS_PER_ENTRY = 744; // 24h * 31 days - maximum hours in a month
|
||||
private const VARIANCE_GREEN_THRESHOLD = 5;
|
||||
private const VARIANCE_YELLOW_THRESHOLD = 20;
|
||||
|
||||
public function __construct(ActualsService $actualsService)
|
||||
{
|
||||
@@ -31,9 +38,9 @@ class ActualController extends Controller
|
||||
'month' => ['required', 'date_format:Y-m'],
|
||||
'project_ids.*' => ['uuid'],
|
||||
'team_member_ids.*' => ['uuid'],
|
||||
'include_inactive' => ['boolean'],
|
||||
'include_inactive' => ['nullable', 'in:true,false,1,0'],
|
||||
'page' => ['integer', 'min:1'],
|
||||
'per_page' => ['integer', 'min:1', 'max:250'],
|
||||
'per_page' => ['integer', 'min:1', 'max:' . self::MAX_PER_PAGE],
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
@@ -46,7 +53,7 @@ class ActualController extends Controller
|
||||
$monthKey = $request->query('month');
|
||||
|
||||
try {
|
||||
$monthDate = Carbon::createFromFormat('Y-m', $monthKey)->startOfMonth();
|
||||
$monthDate = Carbon::createFromFormat('Y-m', $monthKey)->startOfMonth()->toDateString();
|
||||
} catch (\Throwable) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed',
|
||||
@@ -63,17 +70,23 @@ class ActualController extends Controller
|
||||
$searchTerm = null;
|
||||
}
|
||||
|
||||
// Escape LIKE wildcards to prevent SQL injection via pattern matching
|
||||
$escapedSearchTerm = $searchTerm !== null
|
||||
? str_replace(['%', '_', '\\'], ['\\%', '\\_', '\\\\'], $searchTerm)
|
||||
: null;
|
||||
|
||||
$inactiveStatuses = $this->actualsService->getInactiveProjectStatuses();
|
||||
|
||||
$projects = Project::with('status')
|
||||
->when($projectIdsFilter, fn ($query) => $query->whereIn('id', $projectIdsFilter))
|
||||
->when(! $includeInactive, fn ($query) => $query->whereHas('status', fn ($query) => $query->whereNotIn('name', self::LOCKED_PROJECT_STATUSES)))
|
||||
->when($searchTerm, fn ($query) => $query->where(fn ($query) => $query->where('code', 'like', "%{$searchTerm}%")->orWhere('title', 'like', "%{$searchTerm}%")))
|
||||
->when(! $includeInactive, fn ($query) => $query->whereHas('status', fn ($query) => $query->whereNotIn('name', $inactiveStatuses)))
|
||||
->when($escapedSearchTerm !== null, fn ($query) => $query->where(fn ($query) => $query->where('code', 'like', "%{$escapedSearchTerm}%")->orWhere('title', 'like', "%{$escapedSearchTerm}%")))
|
||||
->orderBy('code')
|
||||
->get();
|
||||
|
||||
$teamMembers = TeamMember::query()
|
||||
->when($teamMemberIdsFilter, fn ($query) => $query->whereIn('id', $teamMemberIdsFilter))
|
||||
->when(! $includeInactive, fn ($query) => $query->where('active', true))
|
||||
->when($searchTerm, fn ($query) => $query->where('name', 'like', "%{$searchTerm}%"))
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
@@ -85,14 +98,14 @@ class ActualController extends Controller
|
||||
|
||||
if (! empty($projectIds) && ! empty($teamMemberIds)) {
|
||||
$allocations = Allocation::query()
|
||||
->where('month', $monthDate)
|
||||
->whereDate('month', $monthDate)
|
||||
->when($projectIds, fn ($query) => $query->whereIn('project_id', $projectIds))
|
||||
->when($teamMemberIds, fn ($query) => $query->whereIn('team_member_id', $teamMemberIds))
|
||||
->get()
|
||||
->keyBy(fn (Allocation $allocation) => $allocation->project_id.'-'.$allocation->team_member_id);
|
||||
|
||||
$actuals = Actual::query()
|
||||
->where('month', $monthDate)
|
||||
->whereDate('month', $monthDate)
|
||||
->when($projectIds, fn ($query) => $query->whereIn('project_id', $projectIds))
|
||||
->when($teamMemberIds, fn ($query) => $query->whereIn('team_member_id', $teamMemberIds))
|
||||
->get()
|
||||
@@ -152,7 +165,7 @@ class ActualController extends Controller
|
||||
}
|
||||
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$perPage = max(1, min(250, (int) $request->query('per_page', 25)));
|
||||
$perPage = max(1, min(self::MAX_PER_PAGE, (int) $request->query('per_page', 25)));
|
||||
$total = count($rows);
|
||||
$currentPageItems = array_slice($rows, ($page - 1) * $perPage, $perPage);
|
||||
|
||||
@@ -186,7 +199,7 @@ class ActualController extends Controller
|
||||
'project_id' => 'required|uuid|exists:projects,id',
|
||||
'team_member_id' => 'required|uuid|exists:team_members,id',
|
||||
'month' => 'required|date_format:Y-m',
|
||||
'hours' => 'required|numeric|min:0',
|
||||
'hours' => 'required|numeric|min:0|max:' . self::MAX_HOURS_PER_ENTRY,
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
@@ -215,6 +228,9 @@ class ActualController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
// Authorization check for creating actuals
|
||||
$this->authorize('create', [Actual::class, $request->input('team_member_id')]);
|
||||
|
||||
$project = Project::with('status')->find($request->input('project_id'));
|
||||
|
||||
if ($project && $project->status && ! $this->actualsService->canLogToInactiveProjects()) {
|
||||
@@ -235,32 +251,40 @@ class ActualController extends Controller
|
||||
$hours = (float) $request->input('hours');
|
||||
$notes = $request->input('notes');
|
||||
|
||||
$existing = Actual::where('project_id', $request->input('project_id'))
|
||||
->where('team_member_id', $request->input('team_member_id'))
|
||||
->where('month', $monthDate)
|
||||
->first();
|
||||
|
||||
$status = 201;
|
||||
$actual = null;
|
||||
|
||||
if ($existing) {
|
||||
$existing->hours_logged = (float) $existing->hours_logged + $hours;
|
||||
DB::transaction(function () use ($request, $monthDate, $hours, $notes, &$status, &$actual) {
|
||||
$existing = Actual::where('project_id', $request->input('project_id'))
|
||||
->where('team_member_id', $request->input('team_member_id'))
|
||||
->whereDate('month', $monthDate)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($notes) {
|
||||
$existing->notes = $this->appendNotes($existing->notes, $notes);
|
||||
if ($existing) {
|
||||
// Use atomic increment to prevent race conditions
|
||||
DB::table('actuals')
|
||||
->where('id', $existing->id)
|
||||
->increment('hours_logged', $hours);
|
||||
|
||||
if ($notes) {
|
||||
$existing->notes = $this->appendNotes($existing->notes, $notes);
|
||||
$existing->save();
|
||||
}
|
||||
|
||||
$existing->refresh();
|
||||
$actual = $existing;
|
||||
$status = 200;
|
||||
} else {
|
||||
$actual = Actual::create([
|
||||
'project_id' => $request->input('project_id'),
|
||||
'team_member_id' => $request->input('team_member_id'),
|
||||
'month' => $monthDate,
|
||||
'hours_logged' => $hours,
|
||||
'notes' => $notes,
|
||||
]);
|
||||
}
|
||||
|
||||
$existing->save();
|
||||
$actual = $existing;
|
||||
$status = 200;
|
||||
} else {
|
||||
$actual = Actual::create([
|
||||
'project_id' => $request->input('project_id'),
|
||||
'team_member_id' => $request->input('team_member_id'),
|
||||
'month' => $monthDate,
|
||||
'hours_logged' => $hours,
|
||||
'notes' => $notes,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
$actual->load(['project.status', 'teamMember']);
|
||||
$this->hydrateVariance($actual, $monthKey);
|
||||
@@ -299,8 +323,11 @@ class ActualController extends Controller
|
||||
], 404);
|
||||
}
|
||||
|
||||
// Authorization check for updating actuals
|
||||
$this->authorize('update', $actual);
|
||||
|
||||
$validator = Validator::make($request->all(), [
|
||||
'hours' => 'required|numeric|min:0',
|
||||
'hours' => 'required|numeric|min:0|max:' . self::MAX_HOURS_PER_ENTRY,
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
@@ -339,6 +366,9 @@ class ActualController extends Controller
|
||||
], 404);
|
||||
}
|
||||
|
||||
// Authorization check for deleting actuals
|
||||
$this->authorize('delete', $actual);
|
||||
|
||||
$actual->delete();
|
||||
|
||||
return response()->json([
|
||||
@@ -398,7 +428,7 @@ class ActualController extends Controller
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($status->name, self::LOCKED_PROJECT_STATUSES, true);
|
||||
return in_array($status->name, $this->actualsService->getInactiveProjectStatuses(), true);
|
||||
}
|
||||
|
||||
private function formatHours(float $hours): string
|
||||
@@ -418,11 +448,11 @@ class ActualController extends Controller
|
||||
|
||||
$absolute = abs($variancePercentage);
|
||||
|
||||
if ($absolute <= 5) {
|
||||
if ($absolute <= self::VARIANCE_GREEN_THRESHOLD) {
|
||||
return 'green';
|
||||
}
|
||||
|
||||
if ($absolute <= 20) {
|
||||
if ($absolute <= self::VARIANCE_YELLOW_THRESHOLD) {
|
||||
return 'yellow';
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\TeamMemberResource;
|
||||
use App\Models\TeamMember;
|
||||
use App\Services\TeamMemberService;
|
||||
use App\Services\UtilizationService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@@ -22,12 +23,18 @@ class TeamMemberController extends Controller
|
||||
*/
|
||||
protected TeamMemberService $teamMemberService;
|
||||
|
||||
/**
|
||||
* Utilization Service instance
|
||||
*/
|
||||
protected UtilizationService $utilizationService;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
public function __construct(TeamMemberService $teamMemberService)
|
||||
public function __construct(TeamMemberService $teamMemberService, UtilizationService $utilizationService)
|
||||
{
|
||||
$this->teamMemberService = $teamMemberService;
|
||||
$this->utilizationService = $utilizationService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,6 +124,8 @@ class TeamMemberController extends Controller
|
||||
* @authenticated
|
||||
*
|
||||
* @urlParam id string required Team member UUID. Example: 550e8400-e29b-41d4-a716-446655440000
|
||||
* @queryParam include_utilization boolean Include utilization data. Example: true
|
||||
* @queryParam month string Month for utilization calculation (Y-m format). Required if include_utilization is true. Example: 2026-02
|
||||
*
|
||||
* @response 200 {
|
||||
* "data": {
|
||||
@@ -134,7 +143,7 @@ class TeamMemberController extends Controller
|
||||
* }
|
||||
* @response 404 {"message":"Team member not found"}
|
||||
*/
|
||||
public function show(string $id): JsonResponse
|
||||
public function show(Request $request, string $id): JsonResponse
|
||||
{
|
||||
$teamMember = $this->teamMemberService->findById($id);
|
||||
|
||||
@@ -144,7 +153,17 @@ class TeamMemberController extends Controller
|
||||
], 404);
|
||||
}
|
||||
|
||||
return $this->wrapResource(new TeamMemberResource($teamMember));
|
||||
$data = (new TeamMemberResource($teamMember))->toArray($request);
|
||||
|
||||
// Include utilization data if requested
|
||||
if ($request->boolean('include_utilization') && $request->has('month')) {
|
||||
$month = $request->query('month');
|
||||
$data['utilization'] = $this->utilizationService->getUtilizationData($id, $month);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
264
backend/app/Http/Controllers/Api/UtilizationController.php
Normal file
264
backend/app/Http/Controllers/Api/UtilizationController.php
Normal file
@@ -0,0 +1,264 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\UtilizationService;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* @group Utilization
|
||||
*
|
||||
* API endpoints for utilization calculations.
|
||||
*
|
||||
* Utilization measures how effectively team member capacity is being used.
|
||||
* It's calculated as (Allocated Hours / Capacity) × 100%.
|
||||
*
|
||||
* **Color Indicators:**
|
||||
* - `gray`: Under-utilized (<70%)
|
||||
* - `blue`: Low utilization (70-80%)
|
||||
* - `green`: Optimal (80-100%)
|
||||
* - `yellow`: High utilization (100-110%)
|
||||
* - `red`: Over-allocated (>110%)
|
||||
*/
|
||||
class UtilizationController extends Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
public function __construct(
|
||||
private UtilizationService $utilizationService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get Running (YTD) Utilization
|
||||
*
|
||||
* Calculate year-to-date utilization for a specific team member.
|
||||
* Running utilization = (Allocated hours Jan-current) / (Capacity Jan-current) × 100%
|
||||
*
|
||||
* @authenticated
|
||||
*
|
||||
* @queryParam team_member_id string required UUID of the team member. Example: 550e8400-e29b-41d4-a716-446655440000
|
||||
* @queryParam month string required Month in Y-m format (calculates from Jan of this year to this month). Example: 2026-03
|
||||
*
|
||||
* @response 200 {
|
||||
* "capacity_ytd": 480.0,
|
||||
* "allocated_ytd": 450.0,
|
||||
* "utilization": 93.8,
|
||||
* "indicator": "green",
|
||||
* "months_included": 3
|
||||
* }
|
||||
* @response 422 {"message":"Validation failed","errors":{"team_member_id":["The selected team member id is invalid."]}}
|
||||
*/
|
||||
public function running(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('viewRunningUtilization', \App\Models\TeamMember::class);
|
||||
|
||||
$request->validate([
|
||||
'team_member_id' => 'required|uuid|exists:team_members,id',
|
||||
'month' => 'required|date_format:Y-m',
|
||||
]);
|
||||
|
||||
$result = $this->utilizationService->calculateRunningUtilization(
|
||||
$request->team_member_id,
|
||||
$request->month
|
||||
);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Overall (Monthly) Utilization
|
||||
*
|
||||
* Calculate utilization for a specific team member in a specific month.
|
||||
* Overall utilization = (Allocated hours this month) / (Capacity this month) × 100%
|
||||
*
|
||||
* @authenticated
|
||||
*
|
||||
* @queryParam team_member_id string required UUID of the team member. Example: 550e8400-e29b-41d4-a716-446655440000
|
||||
* @queryParam month string required Month in Y-m format. Example: 2026-02
|
||||
*
|
||||
* @response 200 {
|
||||
* "capacity": 160.0,
|
||||
* "allocated": 140.0,
|
||||
* "utilization": 87.5,
|
||||
* "indicator": "green"
|
||||
* }
|
||||
* @response 422 {"message":"Validation failed","errors":{"month":["The month format is invalid."]}}
|
||||
*/
|
||||
public function overall(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('viewOverallUtilization', \App\Models\TeamMember::class);
|
||||
|
||||
$request->validate([
|
||||
'team_member_id' => 'required|uuid|exists:team_members,id',
|
||||
'month' => 'required|date_format:Y-m',
|
||||
]);
|
||||
|
||||
$result = $this->utilizationService->calculateOverallUtilization(
|
||||
$request->team_member_id,
|
||||
$request->month
|
||||
);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Combined Utilization Data
|
||||
*
|
||||
* Get both overall (monthly) and running (YTD) utilization for a team member.
|
||||
*
|
||||
* @authenticated
|
||||
*
|
||||
* @queryParam team_member_id string required UUID of the team member. Example: 550e8400-e29b-41d4-a716-446655440000
|
||||
* @queryParam month string required Month in Y-m format. Example: 2026-03
|
||||
*
|
||||
* @response 200 {
|
||||
* "overall": {
|
||||
* "capacity": 160.0,
|
||||
* "allocated": 140.0,
|
||||
* "utilization": 87.5,
|
||||
* "indicator": "green"
|
||||
* },
|
||||
* "running": {
|
||||
* "capacity_ytd": 480.0,
|
||||
* "allocated_ytd": 450.0,
|
||||
* "utilization": 93.8,
|
||||
* "indicator": "green",
|
||||
* "months_included": 3
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
public function data(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('viewUtilization', \App\Models\TeamMember::class);
|
||||
|
||||
$request->validate([
|
||||
'team_member_id' => 'required|uuid|exists:team_members,id',
|
||||
'month' => 'required|date_format:Y-m',
|
||||
]);
|
||||
|
||||
$result = $this->utilizationService->getUtilizationData(
|
||||
$request->team_member_id,
|
||||
$request->month
|
||||
);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Team Utilization
|
||||
*
|
||||
* Calculate average utilization across all active team members for a specific month.
|
||||
*
|
||||
* @authenticated
|
||||
*
|
||||
* @queryParam month string required Month in Y-m format. Example: 2026-02
|
||||
*
|
||||
* @response 200 {
|
||||
* "average_utilization": 85.4,
|
||||
* "average_indicator": "green",
|
||||
* "member_count": 3,
|
||||
* "by_member": {
|
||||
* "550e8400-e29b-41d4-a716-446655440000": {
|
||||
* "capacity": 160.0,
|
||||
* "allocated": 140.0,
|
||||
* "utilization": 87.5,
|
||||
* "indicator": "green"
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
public function team(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('viewTeamUtilization', \App\Models\TeamMember::class);
|
||||
|
||||
$request->validate([
|
||||
'month' => 'required|date_format:Y-m',
|
||||
]);
|
||||
|
||||
$result = $this->utilizationService->calculateTeamUtilization(
|
||||
$request->month
|
||||
);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Team Running Utilization (YTD)
|
||||
*
|
||||
* Calculate average year-to-date utilization across all active team members.
|
||||
*
|
||||
* @authenticated
|
||||
*
|
||||
* @queryParam month string required Month in Y-m format. Example: 2026-03
|
||||
*
|
||||
* @response 200 {
|
||||
* "average_utilization": 88.2,
|
||||
* "average_indicator": "green",
|
||||
* "member_count": 3,
|
||||
* "by_member": {}
|
||||
* }
|
||||
*/
|
||||
public function teamRunning(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('viewTeamRunningUtilization', \App\Models\TeamMember::class);
|
||||
|
||||
$request->validate([
|
||||
'month' => 'required|date_format:Y-m',
|
||||
]);
|
||||
|
||||
$result = $this->utilizationService->calculateTeamRunningUtilization(
|
||||
$request->month
|
||||
);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Utilization Trend
|
||||
*
|
||||
* Get utilization data for a team member over a range of months.
|
||||
*
|
||||
* @authenticated
|
||||
*
|
||||
* @queryParam team_member_id string required UUID of the team member. Example: 550e8400-e29b-41d4-a716-446655440000
|
||||
* @queryParam start_month string required Start month in Y-m format. Example: 2026-01
|
||||
* @queryParam end_month string required End month in Y-m format (must be >= start_month). Example: 2026-06
|
||||
*
|
||||
* @response 200 [
|
||||
* {
|
||||
* "month": "2026-01",
|
||||
* "utilization": 75.0,
|
||||
* "indicator": "blue",
|
||||
* "capacity": 176.0,
|
||||
* "allocated": 132.0
|
||||
* },
|
||||
* {
|
||||
* "month": "2026-02",
|
||||
* "utilization": 87.5,
|
||||
* "indicator": "green",
|
||||
* "capacity": 160.0,
|
||||
* "allocated": 140.0
|
||||
* }
|
||||
* ]
|
||||
*/
|
||||
public function trend(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('viewUtilizationTrend', \App\Models\TeamMember::class);
|
||||
|
||||
$request->validate([
|
||||
'team_member_id' => 'required|uuid|exists:team_members,id',
|
||||
'start_month' => 'required|date_format:Y-m',
|
||||
'end_month' => 'required|date_format:Y-m|after_or_equal:start_month',
|
||||
]);
|
||||
|
||||
$result = $this->utilizationService->getUtilizationTrend(
|
||||
$request->team_member_id,
|
||||
$request->start_month,
|
||||
$request->end_month
|
||||
);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ class User extends Authenticatable implements JWTSubject
|
||||
'email',
|
||||
'password',
|
||||
'role',
|
||||
'team_member_id',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -40,6 +41,14 @@ class User extends Authenticatable implements JWTSubject
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the team member associated with the user.
|
||||
*/
|
||||
public function teamMember()
|
||||
{
|
||||
return $this->belongsTo(TeamMember::class, 'team_member_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
|
||||
17
backend/app/Observers/AllocationEventServiceProvider.php
Normal file
17
backend/app/Observers/AllocationEventServiceProvider.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\Allocation;
|
||||
use App\Observers\AllocationObserver;
|
||||
|
||||
class AllocationEventServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any observers for your application.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
Allocation::observe(AllocationObserver::class);
|
||||
}
|
||||
}
|
||||
49
backend/app/Observers/AllocationObserver.php
Normal file
49
backend/app/Observers/AllocationObserver.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Models\Allocation;
|
||||
use App\Services\UtilizationService;
|
||||
|
||||
class AllocationObserver
|
||||
{
|
||||
protected UtilizationService $utilizationService;
|
||||
|
||||
/**
|
||||
* Handle the event for created, */
|
||||
public function created(Allocation $allocation)
|
||||
{
|
||||
$months = [];
|
||||
$month = substr($allocation->month, 0, 4);
|
||||
$year = substr($month, 0, 4);
|
||||
$months[] = $year.'-'.$month;
|
||||
|
||||
$this->utilizationService->forgetUtilizationCache($allocation->team_member_id, $months);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the event for updated.
|
||||
*/
|
||||
public function updated(Allocation $allocation)
|
||||
{
|
||||
$months = [];
|
||||
$month = substr($allocation->month, 1, 4);
|
||||
$year = substr($month, 0, 4);
|
||||
$months[] = $year.'-'.$month;
|
||||
|
||||
$this->utilizationService->forgetUtilizationCache($allocation->team_member_id, $months);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the event for deleted.
|
||||
*/
|
||||
public function deleted(Allocation $allocation)
|
||||
{
|
||||
$months = [];
|
||||
$month = substr($allocation->month, 1, 4);
|
||||
$year = substr($month, 0, 4);
|
||||
$months[] = $year.'-'.$month;
|
||||
|
||||
$this->utilizationService->forgetUtilizationCacheForMonth($month);
|
||||
}
|
||||
}
|
||||
94
backend/app/Policies/ActualPolicy.php
Normal file
94
backend/app/Policies/ActualPolicy.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Actual;
|
||||
use App\Models\User;
|
||||
|
||||
class ActualPolicy
|
||||
{
|
||||
/**
|
||||
* Determine whether the user can view any actuals.
|
||||
*/
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can view a specific actual.
|
||||
*/
|
||||
public function view(User $user, Actual $actual): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can create actuals.
|
||||
*
|
||||
* Superusers and managers can create actuals for any team member.
|
||||
* Developers can only create actuals for themselves (if linked to a team member).
|
||||
*/
|
||||
public function create(User $user, ?string $teamMemberId = null): bool
|
||||
{
|
||||
// Superusers and managers can create any actual
|
||||
if (in_array($user->role, ['superuser', 'manager'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Developers can only create actuals for their own team member record
|
||||
if ($user->role === 'developer') {
|
||||
// If no team_member_id provided, deny (defensive)
|
||||
if ($teamMemberId === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if user is linked to this team member
|
||||
return $user->team_member_id === $teamMemberId;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can update the actual.
|
||||
*
|
||||
* Superusers and managers can update any actual.
|
||||
* Developers can only update their own actuals.
|
||||
*/
|
||||
public function update(User $user, Actual $actual): bool
|
||||
{
|
||||
// Superusers and managers can update any actual
|
||||
if (in_array($user->role, ['superuser', 'manager'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Developers can only update their own actuals
|
||||
if ($user->role === 'developer') {
|
||||
return $user->team_member_id === $actual->team_member_id;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can delete the actual.
|
||||
*
|
||||
* Superusers and managers can delete any actual.
|
||||
* Developers can only delete their own actuals.
|
||||
*/
|
||||
public function delete(User $user, Actual $actual): bool
|
||||
{
|
||||
// Superusers and managers can delete any actual
|
||||
if (in_array($user->role, ['superuser', 'manager'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Developers can only delete their own actuals
|
||||
if ($user->role === 'developer') {
|
||||
return $user->team_member_id === $actual->team_member_id;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -69,4 +69,54 @@ class TeamMemberPolicy
|
||||
// Only superusers can force delete team members
|
||||
return $user->role === 'superuser';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can view utilization data.
|
||||
*
|
||||
* All authenticated users can view utilization data.
|
||||
*/
|
||||
public function viewUtilization(User $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can view running utilization.
|
||||
*/
|
||||
public function viewRunningUtilization(User $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can view overall utilization.
|
||||
*/
|
||||
public function viewOverallUtilization(User $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can view team utilization.
|
||||
*/
|
||||
public function viewTeamUtilization(User $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can view team running utilization.
|
||||
*/
|
||||
public function viewTeamRunningUtilization(User $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can view utilization trend.
|
||||
*/
|
||||
public function viewUtilizationTrend(User $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
1
backend/app/Providers/AppServiceProvider
Normal file
1
backend/app/Providers/AppServiceProvider
Normal file
@@ -0,0 +1 @@
|
||||
grep: app/Observers/AllocationEventServiceProvider.php: No such file or directory
|
||||
@@ -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
|
||||
|
||||
175
backend/app/Services/UtilizationFormatter.php
Normal file
175
backend/app/Services/UtilizationFormatter.php
Normal 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),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ class ActualFactory extends Factory
|
||||
'team_member_id' => TeamMember::factory(),
|
||||
'month' => fake()->dateTimeBetween('-6 months', 'now')->format('Y-m-01'),
|
||||
'hours_logged' => fake()->randomFloat(2, 0, 160),
|
||||
'notes' => fake()->optional()->sentence(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->uuid('team_member_id')->nullable()->after('role');
|
||||
$table->foreign('team_member_id')
|
||||
->references('id')
|
||||
->on('team_members')
|
||||
->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropForeign(['team_member_id']);
|
||||
$table->dropColumn('team_member_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -11,6 +11,7 @@ use App\Http\Controllers\Api\PtoController;
|
||||
use App\Http\Controllers\Api\ReportController;
|
||||
use App\Http\Controllers\Api\RolesController;
|
||||
use App\Http\Controllers\Api\TeamMemberController;
|
||||
use App\Http\Controllers\Api\UtilizationController;
|
||||
use App\Http\Middleware\JwtAuth;
|
||||
use App\Http\Resources\UserResource;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
@@ -76,6 +77,16 @@ Route::middleware(JwtAuth::class)->group(function () {
|
||||
Route::apiResource('allocations', AllocationController::class);
|
||||
Route::post('/allocations/bulk', [AllocationController::class, 'bulkStore']);
|
||||
|
||||
// Utilization
|
||||
Route::prefix('utilization')->group(function () {
|
||||
Route::get('/running', [UtilizationController::class, 'running']);
|
||||
Route::get('/overall', [UtilizationController::class, 'overall']);
|
||||
Route::get('/data', [UtilizationController::class, 'data']);
|
||||
Route::get('/team', [UtilizationController::class, 'team']);
|
||||
Route::get('/team-running', [UtilizationController::class, 'teamRunning']);
|
||||
Route::get('/trend', [UtilizationController::class, 'trend']);
|
||||
});
|
||||
|
||||
// Reports
|
||||
Route::get('/reports/allocations', [ReportController::class, 'allocations']);
|
||||
});
|
||||
|
||||
840
backend/tests/Feature/ActualControllerTest.php
Normal file
840
backend/tests/Feature/ActualControllerTest.php
Normal file
@@ -0,0 +1,840 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Actual;
|
||||
use App\Models\Allocation;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectStatus;
|
||||
use App\Models\ProjectType;
|
||||
use App\Models\Role;
|
||||
use App\Models\TeamMember;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Feature tests for ActualController.
|
||||
*
|
||||
* Tests the actuals tracking API endpoints including CRUD operations,
|
||||
* filtering, and validation rules.
|
||||
*/
|
||||
class ActualControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
}
|
||||
|
||||
protected function loginAsManager(): string
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email' => 'manager@test.com',
|
||||
'password' => bcrypt('password123'),
|
||||
'role' => 'manager',
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/auth/login', [
|
||||
'email' => 'manager@test.com',
|
||||
'password' => 'password123',
|
||||
]);
|
||||
|
||||
return $response->json('access_token');
|
||||
}
|
||||
|
||||
protected function createPrerequisites(): array
|
||||
{
|
||||
$role = Role::factory()->create();
|
||||
$teamMember = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
|
||||
$projectStatus = ProjectStatus::factory()->create(['name' => 'Active', 'is_active' => true]);
|
||||
$projectType = ProjectType::factory()->create();
|
||||
$project = Project::factory()->create([
|
||||
'status_id' => $projectStatus->id,
|
||||
'type_id' => $projectType->id,
|
||||
]);
|
||||
|
||||
return [
|
||||
'role' => $role,
|
||||
'team_member' => $teamMember,
|
||||
'project_status' => $projectStatus,
|
||||
'project_type' => $projectType,
|
||||
'project' => $project,
|
||||
];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// INDEX TESTS - PAGINATION AND FILTERING
|
||||
// ============================================================================
|
||||
|
||||
public function test_index_returns_paginated_actuals_grid(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$prereq = $this->createPrerequisites();
|
||||
|
||||
// Create allocation and actual
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 80,
|
||||
]);
|
||||
|
||||
Actual::factory()->create([
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-02-01',
|
||||
'hours_logged' => 75,
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson('/api/actuals?month=2026-02');
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonStructure([
|
||||
'data',
|
||||
'meta' => [
|
||||
'current_page',
|
||||
'per_page',
|
||||
'total',
|
||||
'last_page',
|
||||
'filters',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertGreaterThan(0, $response->json('meta.total'));
|
||||
}
|
||||
|
||||
public function test_index_filters_by_project(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$prereq = $this->createPrerequisites();
|
||||
|
||||
// Create another project that should be filtered out
|
||||
$otherStatus = ProjectStatus::factory()->create(['name' => 'Other', 'is_active' => true]);
|
||||
$otherProject = Project::factory()->create([
|
||||
'status_id' => $otherStatus->id,
|
||||
'type_id' => $prereq['project_type']->id,
|
||||
]);
|
||||
|
||||
// Create data for both projects
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 40,
|
||||
]);
|
||||
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $otherProject->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 40,
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson('/api/actuals?month=2026-02&project_ids[]=' . $prereq['project']->id);
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
// Only the filtered project should appear in the results
|
||||
$projectIds = collect($response->json('data'))->pluck('project_id')->unique();
|
||||
$this->assertCount(1, $projectIds);
|
||||
$this->assertEquals($prereq['project']->id, $projectIds->first());
|
||||
}
|
||||
|
||||
public function test_index_filters_by_team_member(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$prereq = $this->createPrerequisites();
|
||||
|
||||
// Create another team member
|
||||
$otherMember = TeamMember::factory()->create([
|
||||
'role_id' => $prereq['role']->id,
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
// Create allocations for both members
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 40,
|
||||
]);
|
||||
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $otherMember->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 40,
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson('/api/actuals?month=2026-02&team_member_ids[]=' . $prereq['team_member']->id);
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
// Only the filtered team member should appear
|
||||
$memberIds = collect($response->json('data'))->pluck('team_member_id')->unique();
|
||||
$this->assertCount(1, $memberIds);
|
||||
$this->assertEquals($prereq['team_member']->id, $memberIds->first());
|
||||
}
|
||||
|
||||
public function test_index_searches_by_project_code(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$prereq = $this->createPrerequisites();
|
||||
|
||||
// Update project with a unique code
|
||||
$prereq['project']->update(['code' => 'SEARCH-TEST-001']);
|
||||
|
||||
// Create another project with different code
|
||||
$otherStatus = ProjectStatus::factory()->create(['name' => 'Other', 'is_active' => true]);
|
||||
$otherProject = Project::factory()->create([
|
||||
'code' => 'OTHER-CODE-999',
|
||||
'status_id' => $otherStatus->id,
|
||||
'type_id' => $prereq['project_type']->id,
|
||||
]);
|
||||
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 40,
|
||||
]);
|
||||
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $otherProject->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 40,
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson('/api/actuals?month=2026-02&search=SEARCH-TEST');
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
// Only matching project should appear
|
||||
$projectIds = collect($response->json('data'))->pluck('project_id')->unique();
|
||||
$this->assertCount(1, $projectIds);
|
||||
$this->assertEquals($prereq['project']->id, $projectIds->first());
|
||||
}
|
||||
|
||||
public function test_index_requires_month_parameter(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson('/api/actuals');
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['month']);
|
||||
}
|
||||
|
||||
public function test_index_returns_empty_for_month_with_no_data(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson('/api/actuals?month=2020-01');
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson(['meta' => ['total' => 0]]);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// STORE TESTS - CREATE AND UPDATE ACTUALS
|
||||
// ============================================================================
|
||||
|
||||
public function test_store_creates_new_actual(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$prereq = $this->createPrerequisites();
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->postJson('/api/actuals', [
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-01',
|
||||
'hours' => 40,
|
||||
'notes' => 'Initial time entry',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201)
|
||||
->assertJsonStructure([
|
||||
'data' => [
|
||||
'id',
|
||||
'project_id',
|
||||
'team_member_id',
|
||||
'month',
|
||||
'hours_logged',
|
||||
'notes',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('actuals', [
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'hours_logged' => 40,
|
||||
]);
|
||||
|
||||
// Verify the month was set correctly (SQLite stores dates with time component)
|
||||
$actual = Actual::where('project_id', $prereq['project']->id)
|
||||
->where('team_member_id', $prereq['team_member']->id)
|
||||
->first();
|
||||
$this->assertNotNull($actual);
|
||||
$this->assertEquals('2026-01-01', $actual->month->format('Y-m-d'));
|
||||
}
|
||||
|
||||
public function test_store_adds_hours_to_existing_actual(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$prereq = $this->createPrerequisites();
|
||||
|
||||
// Create existing actual using Carbon for proper date handling
|
||||
$existing = Actual::factory()->create([
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => Carbon::createFromFormat('Y-m', '2026-01')->startOfMonth(),
|
||||
'hours_logged' => 40,
|
||||
'notes' => 'First entry',
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->postJson('/api/actuals', [
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-01',
|
||||
'hours' => 20,
|
||||
'notes' => 'Second entry',
|
||||
]);
|
||||
|
||||
// Should return 200 (update) not 201 (create)
|
||||
$response->assertStatus(200);
|
||||
|
||||
// Hours should be accumulated
|
||||
$this->assertDatabaseHas('actuals', [
|
||||
'id' => $existing->id,
|
||||
'hours_logged' => 60, // 40 + 20
|
||||
]);
|
||||
|
||||
// Notes should be appended
|
||||
$actual = Actual::find($existing->id);
|
||||
$this->assertStringContainsString('First entry', $actual->notes);
|
||||
$this->assertStringContainsString('Second entry', $actual->notes);
|
||||
}
|
||||
|
||||
public function test_store_rejects_future_month(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$prereq = $this->createPrerequisites();
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->postJson('/api/actuals', [
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2030-12', // Far future
|
||||
'hours' => 40,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonPath('errors.month', function ($errors) {
|
||||
return in_array('Cannot log hours for future months', $errors);
|
||||
});
|
||||
}
|
||||
|
||||
public function test_store_rejects_completed_project(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$prereq = $this->createPrerequisites();
|
||||
|
||||
// Create a "Done" project status
|
||||
$doneStatus = ProjectStatus::factory()->create(['name' => 'Done', 'is_active' => false]);
|
||||
$doneProject = Project::factory()->create([
|
||||
'status_id' => $doneStatus->id,
|
||||
'type_id' => $prereq['project_type']->id,
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->postJson('/api/actuals', [
|
||||
'project_id' => $doneProject->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-01',
|
||||
'hours' => 40,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonPath('errors.project_id', function ($errors) {
|
||||
return in_array('Cannot log hours to completed projects', $errors);
|
||||
});
|
||||
}
|
||||
|
||||
public function test_store_rejects_cancelled_project(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$prereq = $this->createPrerequisites();
|
||||
|
||||
// Create a "Cancelled" project status
|
||||
$cancelledStatus = ProjectStatus::factory()->create(['name' => 'Cancelled', 'is_active' => false]);
|
||||
$cancelledProject = Project::factory()->create([
|
||||
'status_id' => $cancelledStatus->id,
|
||||
'type_id' => $prereq['project_type']->id,
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->postJson('/api/actuals', [
|
||||
'project_id' => $cancelledProject->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-01',
|
||||
'hours' => 40,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
}
|
||||
|
||||
public function test_store_rejects_negative_hours(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$prereq = $this->createPrerequisites();
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->postJson('/api/actuals', [
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-01',
|
||||
'hours' => -10,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['hours']);
|
||||
}
|
||||
|
||||
public function test_store_accepts_zero_hours(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$prereq = $this->createPrerequisites();
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->postJson('/api/actuals', [
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-01',
|
||||
'hours' => 0,
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
|
||||
$this->assertDatabaseHas('actuals', [
|
||||
'project_id' => $prereq['project']->id,
|
||||
'hours_logged' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_store_requires_all_fields(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->postJson('/api/actuals', []);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['project_id', 'team_member_id', 'month', 'hours']);
|
||||
}
|
||||
|
||||
public function test_store_validates_uuid_format(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->postJson('/api/actuals', [
|
||||
'project_id' => 'not-a-uuid',
|
||||
'team_member_id' => 'also-not-a-uuid',
|
||||
'month' => '2026-01',
|
||||
'hours' => 40,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['project_id', 'team_member_id']);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// UPDATE TESTS
|
||||
// ============================================================================
|
||||
|
||||
public function test_update_modifies_actual_hours(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$prereq = $this->createPrerequisites();
|
||||
|
||||
$actual = Actual::factory()->create([
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-01-01',
|
||||
'hours_logged' => 40,
|
||||
'notes' => 'Original notes',
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->putJson("/api/actuals/{$actual->id}", [
|
||||
'hours' => 50,
|
||||
'notes' => 'Updated notes',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
$this->assertDatabaseHas('actuals', [
|
||||
'id' => $actual->id,
|
||||
'hours_logged' => 50,
|
||||
]);
|
||||
|
||||
// Notes should be replaced, not appended
|
||||
$actual->refresh();
|
||||
$this->assertEquals('Updated notes', $actual->notes);
|
||||
}
|
||||
|
||||
public function test_update_rejects_nonexistent_actual(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$fakeId = '550e8400-e29b-41d4-a716-446655440000';
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->putJson("/api/actuals/{$fakeId}", [
|
||||
'hours' => 50,
|
||||
]);
|
||||
|
||||
$response->assertStatus(404);
|
||||
}
|
||||
|
||||
public function test_update_rejects_negative_hours(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$prereq = $this->createPrerequisites();
|
||||
|
||||
$actual = Actual::factory()->create([
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-01-01',
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->putJson("/api/actuals/{$actual->id}", [
|
||||
'hours' => -10,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['hours']);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DESTROY TESTS
|
||||
// ============================================================================
|
||||
|
||||
public function test_destroy_deletes_actual(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$prereq = $this->createPrerequisites();
|
||||
|
||||
$actual = Actual::factory()->create([
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-01-01',
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->deleteJson("/api/actuals/{$actual->id}");
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson(['message' => 'Actual deleted successfully']);
|
||||
|
||||
$this->assertDatabaseMissing('actuals', [
|
||||
'id' => $actual->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_destroy_returns_404_for_nonexistent_actual(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$fakeId = '550e8400-e29b-41d4-a716-446655440000';
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->deleteJson("/api/actuals/{$fakeId}");
|
||||
|
||||
$response->assertStatus(404);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SHOW TESTS
|
||||
// ============================================================================
|
||||
|
||||
public function test_show_returns_actual_with_variance(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$prereq = $this->createPrerequisites();
|
||||
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-01-01',
|
||||
'allocated_hours' => 100,
|
||||
]);
|
||||
|
||||
$actual = Actual::factory()->create([
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-01-01',
|
||||
'hours_logged' => 80,
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson("/api/actuals/{$actual->id}");
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonStructure([
|
||||
'data' => [
|
||||
'id',
|
||||
'project_id',
|
||||
'team_member_id',
|
||||
'month',
|
||||
'hours_logged',
|
||||
'variance' => [
|
||||
'allocated_hours',
|
||||
'variance_percentage',
|
||||
'variance_indicator',
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_show_returns_404_for_nonexistent_actual(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$fakeId = '550e8400-e29b-41d4-a716-446655440000';
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson("/api/actuals/{$fakeId}");
|
||||
|
||||
$response->assertStatus(404);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// VARIANCE CALCULATION TESTS IN API RESPONSE
|
||||
// ============================================================================
|
||||
|
||||
public function test_index_includes_correct_variance_calculation(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$prereq = $this->createPrerequisites();
|
||||
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 100,
|
||||
]);
|
||||
|
||||
Actual::factory()->create([
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-02-01',
|
||||
'hours_logged' => 80,
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson('/api/actuals?month=2026-02');
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
$data = collect($response->json('data'))
|
||||
->firstWhere('project_id', $prereq['project']->id);
|
||||
|
||||
// Variance = ((80 - 100) / 100) * 100 = -20%
|
||||
$this->assertEquals(-20.0, $data['variance_percentage']);
|
||||
$this->assertEquals('yellow', $data['variance_indicator']);
|
||||
}
|
||||
|
||||
public function test_index_shows_infinity_for_actual_without_allocation(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$prereq = $this->createPrerequisites();
|
||||
|
||||
// Only actual, no allocation
|
||||
Actual::factory()->create([
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-02-01',
|
||||
'hours_logged' => 50,
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson('/api/actuals?month=2026-02');
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
$data = collect($response->json('data'))
|
||||
->firstWhere('project_id', $prereq['project']->id);
|
||||
|
||||
// When allocated is 0 but actual > 0, variance_display should be infinity
|
||||
$this->assertEquals('∞%', $data['variance_display'] ?? null);
|
||||
$this->assertEquals('red', $data['variance_indicator']);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// INACTIVE PROJECT HANDLING TESTS
|
||||
// ============================================================================
|
||||
|
||||
public function test_index_hides_inactive_projects_by_default(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$prereq = $this->createPrerequisites();
|
||||
|
||||
// Create a "Done" project
|
||||
$doneStatus = ProjectStatus::factory()->create(['name' => 'Done', 'is_active' => false]);
|
||||
$doneProject = Project::factory()->create([
|
||||
'status_id' => $doneStatus->id,
|
||||
'type_id' => $prereq['project_type']->id,
|
||||
]);
|
||||
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 40,
|
||||
]);
|
||||
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $doneProject->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 40,
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson('/api/actuals?month=2026-02');
|
||||
|
||||
$projectIds = collect($response->json('data'))->pluck('project_id')->unique();
|
||||
|
||||
// Done project should not appear
|
||||
$this->assertNotContains($doneProject->id, $projectIds->toArray());
|
||||
}
|
||||
|
||||
public function test_index_shows_inactive_projects_when_flag_set(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$prereq = $this->createPrerequisites();
|
||||
|
||||
// Create a "Done" project
|
||||
$doneStatus = ProjectStatus::factory()->create(['name' => 'Done', 'is_active' => false]);
|
||||
$doneProject = Project::factory()->create([
|
||||
'status_id' => $doneStatus->id,
|
||||
'type_id' => $prereq['project_type']->id,
|
||||
]);
|
||||
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $doneProject->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 40,
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson('/api/actuals?month=2026-02&include_inactive=true');
|
||||
|
||||
$projectIds = collect($response->json('data'))->pluck('project_id')->unique();
|
||||
|
||||
// Done project should appear when include_inactive is true
|
||||
$this->assertContains($doneProject->id, $projectIds->toArray());
|
||||
}
|
||||
|
||||
public function test_index_marks_readonly_flag_for_completed_projects(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$prereq = $this->createPrerequisites();
|
||||
|
||||
// Create a "Done" project
|
||||
$doneStatus = ProjectStatus::factory()->create(['name' => 'Done', 'is_active' => false]);
|
||||
$doneProject = Project::factory()->create([
|
||||
'status_id' => $doneStatus->id,
|
||||
'type_id' => $prereq['project_type']->id,
|
||||
]);
|
||||
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $doneProject->id,
|
||||
'team_member_id' => $prereq['team_member']->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 40,
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson('/api/actuals?month=2026-02&include_inactive=true');
|
||||
|
||||
$doneProjectData = collect($response->json('data'))
|
||||
->firstWhere('project_id', $doneProject->id);
|
||||
|
||||
$this->assertTrue($doneProjectData['is_readonly']);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PAGINATION TESTS
|
||||
// ============================================================================
|
||||
|
||||
public function test_index_respects_per_page_parameter(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$prereq = $this->createPrerequisites();
|
||||
|
||||
// Create multiple team members to generate more rows
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$member = TeamMember::factory()->create([
|
||||
'role_id' => $prereq['role']->id,
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $member->id,
|
||||
'month' => '2026-02-01',
|
||||
]);
|
||||
}
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson('/api/actuals?month=2026-02&per_page=2');
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonPath('meta.per_page', 2);
|
||||
|
||||
$this->assertCount(2, $response->json('data'));
|
||||
}
|
||||
|
||||
public function test_index_respects_page_parameter(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$prereq = $this->createPrerequisites();
|
||||
|
||||
// Create multiple team members
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$member = TeamMember::factory()->create([
|
||||
'role_id' => $prereq['role']->id,
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $prereq['project']->id,
|
||||
'team_member_id' => $member->id,
|
||||
'month' => '2026-02-01',
|
||||
]);
|
||||
}
|
||||
|
||||
$response1 = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson('/api/actuals?month=2026-02&per_page=2&page=1');
|
||||
|
||||
$response2 = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson('/api/actuals?month=2026-02&per_page=2&page=2');
|
||||
|
||||
$this->assertNotEquals(
|
||||
$response1->json('data.0.team_member_id'),
|
||||
$response2->json('data.0.team_member_id')
|
||||
);
|
||||
}
|
||||
}
|
||||
186
backend/tests/Feature/UtilizationTest.php
Normal file
186
backend/tests/Feature/UtilizationTest.php
Normal file
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Allocation;
|
||||
use App\Models\Role;
|
||||
use App\Models\TeamMember;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class UtilizationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function loginAsManager(): string
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email' => 'manager@test.com',
|
||||
'password' => bcrypt('password123'),
|
||||
'role' => 'manager',
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/auth/login', [
|
||||
'email' => 'manager@test.com',
|
||||
'password' => 'password123',
|
||||
]);
|
||||
|
||||
return $response->json('access_token');
|
||||
}
|
||||
|
||||
// 7.1.9 GET /api/utilization/running calculates YTD
|
||||
public function test_get_utilization_running_calculates_ytd(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
|
||||
$role = Role::factory()->create();
|
||||
$member = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
|
||||
|
||||
// Create allocations for Jan, Feb, Mar 2026
|
||||
Allocation::factory()->create([
|
||||
'team_member_id' => $member->id,
|
||||
'month' => '2026-01-01',
|
||||
'allocated_hours' => 140,
|
||||
]);
|
||||
Allocation::factory()->create([
|
||||
'team_member_id' => $member->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 150,
|
||||
]);
|
||||
Allocation::factory()->create([
|
||||
'team_member_id' => $member->id,
|
||||
'month' => '2026-03-01',
|
||||
'allocated_hours' => 160,
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson("/api/utilization/running?team_member_id={$member->id}&month=2026-03");
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonStructure([
|
||||
'capacity_ytd',
|
||||
'allocated_ytd',
|
||||
'utilization',
|
||||
'indicator',
|
||||
'months_included',
|
||||
])
|
||||
->assertJson([
|
||||
'allocated_ytd' => 450.0,
|
||||
'months_included' => 3,
|
||||
]);
|
||||
}
|
||||
|
||||
// 7.1.10 GET /api/utilization/overall calculates monthly
|
||||
public function test_get_utilization_overall_calculates_monthly(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
|
||||
$role = Role::factory()->create();
|
||||
$member = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
|
||||
|
||||
Allocation::factory()->create([
|
||||
'team_member_id' => $member->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 140,
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson("/api/utilization/overall?team_member_id={$member->id}&month=2026-02");
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonStructure([
|
||||
'capacity',
|
||||
'allocated',
|
||||
'utilization',
|
||||
'indicator',
|
||||
])
|
||||
->assertJson([
|
||||
'allocated' => 140.0,
|
||||
]);
|
||||
}
|
||||
|
||||
// 7.1.11 Utilization includes in allocation response
|
||||
public function test_utilization_included_in_team_members_response(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
|
||||
$role = Role::factory()->create();
|
||||
$member = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
|
||||
|
||||
Allocation::factory()->create([
|
||||
'team_member_id' => $member->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 140,
|
||||
]);
|
||||
|
||||
// Get team member with utilization data
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson("/api/team-members/{$member->id}?include_utilization=true&month=2026-02");
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonStructure([
|
||||
'data' => [
|
||||
'id',
|
||||
'name',
|
||||
'utilization' => [
|
||||
'overall',
|
||||
'running',
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_utilization_team_endpoint_returns_aggregate(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
|
||||
$role = Role::factory()->create();
|
||||
$memberA = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
|
||||
$memberB = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
|
||||
|
||||
Allocation::factory()->create([
|
||||
'team_member_id' => $memberA->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 140,
|
||||
]);
|
||||
Allocation::factory()->create([
|
||||
'team_member_id' => $memberB->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 150,
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson('/api/utilization/team?month=2026-02');
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonStructure([
|
||||
'average_utilization',
|
||||
'average_indicator',
|
||||
'member_count',
|
||||
'by_member',
|
||||
])
|
||||
->assertJson([
|
||||
'member_count' => 2,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_utilization_requires_authentication(): void
|
||||
{
|
||||
$response = $this->getJson('/api/utilization/overall?team_member_id=123&month=2026-02');
|
||||
|
||||
$response->assertStatus(401);
|
||||
}
|
||||
|
||||
public function test_utilization_validates_required_parameters(): void
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson('/api/utilization/overall');
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['team_member_id', 'month']);
|
||||
}
|
||||
}
|
||||
339
backend/tests/Unit/Services/ActualsServiceTest.php
Normal file
339
backend/tests/Unit/Services/ActualsServiceTest.php
Normal file
@@ -0,0 +1,339 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Actual;
|
||||
use App\Models\Allocation;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectStatus;
|
||||
use App\Models\Role;
|
||||
use App\Models\TeamMember;
|
||||
use App\Services\ActualsService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for ActualsService.
|
||||
*
|
||||
* Tests variance calculation logic and business rules for actuals tracking.
|
||||
*
|
||||
* @mixin \Tests\TestCase
|
||||
*/
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
// ============================================================================
|
||||
// VARIANCE CALCULATION TESTS
|
||||
// ============================================================================
|
||||
|
||||
test('calculate_variance returns correct percentage', function () {
|
||||
$role = Role::factory()->create();
|
||||
$teamMember = TeamMember::factory()->create(['role_id' => $role->id]);
|
||||
$projectStatus = ProjectStatus::factory()->create(['name' => 'Active']);
|
||||
$project = Project::factory()->create(['status_id' => $projectStatus->id]);
|
||||
|
||||
// Create allocation: 100 hours allocated
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'team_member_id' => $teamMember->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 100,
|
||||
]);
|
||||
|
||||
// Create actual: 80 hours logged
|
||||
Actual::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'team_member_id' => $teamMember->id,
|
||||
'month' => '2026-02-01',
|
||||
'hours_logged' => 80,
|
||||
]);
|
||||
|
||||
$service = app(ActualsService::class);
|
||||
$result = $service->calculateVariance($project->id, $teamMember->id, '2026-02');
|
||||
|
||||
// Variance = ((80 - 100) / 100) * 100 = -20%
|
||||
expect($result['allocated'])->toBe(100.0)
|
||||
->and($result['actual'])->toBe(80.0)
|
||||
->and($result['variance_percentage'])->toBe(-20.0)
|
||||
->and($result['indicator'])->toBe('yellow');
|
||||
});
|
||||
|
||||
test('calculate_variance handles zero allocation', function () {
|
||||
$role = Role::factory()->create();
|
||||
$teamMember = TeamMember::factory()->create(['role_id' => $role->id]);
|
||||
$projectStatus = ProjectStatus::factory()->create(['name' => 'Active']);
|
||||
$project = Project::factory()->create(['status_id' => $projectStatus->id]);
|
||||
|
||||
// No allocation, but actual hours logged
|
||||
Actual::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'team_member_id' => $teamMember->id,
|
||||
'month' => '2026-02-01',
|
||||
'hours_logged' => 40,
|
||||
]);
|
||||
|
||||
$service = app(ActualsService::class);
|
||||
$result = $service->calculateVariance($project->id, $teamMember->id, '2026-02');
|
||||
|
||||
// When allocation is 0 but actual is > 0, variance is 100%
|
||||
expect($result['allocated'])->toBe(0.0)
|
||||
->and($result['actual'])->toBe(40.0)
|
||||
->and($result['variance_percentage'])->toBe(100.0)
|
||||
->and($result['indicator'])->toBe('red');
|
||||
});
|
||||
|
||||
test('calculate_variance handles both zero', function () {
|
||||
$role = Role::factory()->create();
|
||||
$teamMember = TeamMember::factory()->create(['role_id' => $role->id]);
|
||||
$projectStatus = ProjectStatus::factory()->create(['name' => 'Active']);
|
||||
$project = Project::factory()->create(['status_id' => $projectStatus->id]);
|
||||
|
||||
// No allocation, no actual hours
|
||||
$service = app(ActualsService::class);
|
||||
$result = $service->calculateVariance($project->id, $teamMember->id, '2026-02');
|
||||
|
||||
// When both are 0, variance is 0%
|
||||
expect($result['allocated'])->toBe(0.0)
|
||||
->and($result['actual'])->toBe(0.0)
|
||||
->and($result['variance_percentage'])->toBe(0.0)
|
||||
->and($result['indicator'])->toBe('green');
|
||||
});
|
||||
|
||||
test('calculate_variance handles positive_variance', function () {
|
||||
$role = Role::factory()->create();
|
||||
$teamMember = TeamMember::factory()->create(['role_id' => $role->id]);
|
||||
$projectStatus = ProjectStatus::factory()->create(['name' => 'Active']);
|
||||
$project = Project::factory()->create(['status_id' => $projectStatus->id]);
|
||||
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'team_member_id' => $teamMember->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 80,
|
||||
]);
|
||||
|
||||
Actual::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'team_member_id' => $teamMember->id,
|
||||
'month' => '2026-02-01',
|
||||
'hours_logged' => 100,
|
||||
]);
|
||||
|
||||
$service = app(ActualsService::class);
|
||||
$result = $service->calculateVariance($project->id, $teamMember->id, '2026-02');
|
||||
|
||||
// Variance = ((100 - 80) / 80) * 100 = 25%
|
||||
expect($result['allocated'])->toBe(80.0)
|
||||
->and($result['actual'])->toBe(100.0)
|
||||
->and($result['variance_percentage'])->toBe(25.0)
|
||||
->and($result['indicator'])->toBe('red');
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// INDICATOR THRESHOLD TESTS
|
||||
// ============================================================================
|
||||
|
||||
test('get_indicator returns green for small variance', function () {
|
||||
$service = app(ActualsService::class);
|
||||
|
||||
// Green: |variance| <= 5%
|
||||
$reflection = new ReflectionClass($service);
|
||||
$method = $reflection->getMethod('getIndicator');
|
||||
$method->setAccessible(true);
|
||||
|
||||
expect($method->invoke($service, 0.0))->toBe('green')
|
||||
->and($method->invoke($service, 5.0))->toBe('green')
|
||||
->and($method->invoke($service, -5.0))->toBe('green')
|
||||
->and($method->invoke($service, 3.5))->toBe('green')
|
||||
->and($method->invoke($service, -2.1))->toBe('green');
|
||||
});
|
||||
|
||||
test('get_indicator returns yellow for medium variance', function () {
|
||||
$service = app(ActualsService::class);
|
||||
|
||||
// Yellow: 5% < |variance| <= 20%
|
||||
$reflection = new ReflectionClass($service);
|
||||
$method = $reflection->getMethod('getIndicator');
|
||||
$method->setAccessible(true);
|
||||
|
||||
expect($method->invoke($service, 6.0))->toBe('yellow')
|
||||
->and($method->invoke($service, 20.0))->toBe('yellow')
|
||||
->and($method->invoke($service, -10.0))->toBe('yellow')
|
||||
->and($method->invoke($service, -15.5))->toBe('yellow')
|
||||
->and($method->invoke($service, 19.9))->toBe('yellow');
|
||||
});
|
||||
|
||||
test('get_indicator returns red for large variance', function () {
|
||||
$service = app(ActualsService::class);
|
||||
|
||||
// Red: |variance| > 20%
|
||||
$reflection = new ReflectionClass($service);
|
||||
$method = $reflection->getMethod('getIndicator');
|
||||
$method->setAccessible(true);
|
||||
|
||||
expect($method->invoke($service, 21.0))->toBe('red')
|
||||
->and($method->invoke($service, -21.0))->toBe('red')
|
||||
->and($method->invoke($service, 50.0))->toBe('red')
|
||||
->and($method->invoke($service, -100.0))->toBe('red')
|
||||
->and($method->invoke($service, 1000.0))->toBe('red');
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// PROJECT STATUS TESTS
|
||||
// ============================================================================
|
||||
|
||||
test('get_inactive_project_statuses returns expected values', function () {
|
||||
$service = app(ActualsService::class);
|
||||
$statuses = $service->getInactiveProjectStatuses();
|
||||
|
||||
expect($statuses)->toBe(['Done', 'Cancelled', 'Closed'])
|
||||
->and(count($statuses))->toBe(3);
|
||||
});
|
||||
|
||||
test('can_log_to_inactive_projects respects config', function () {
|
||||
$service = app(ActualsService::class);
|
||||
|
||||
// Default config value should be false
|
||||
config(['actuals.allow_actuals_on_inactive_projects' => false]);
|
||||
expect($service->canLogToInactiveProjects())->toBeFalse();
|
||||
|
||||
// When enabled, should return true
|
||||
config(['actuals.allow_actuals_on_inactive_projects' => true]);
|
||||
expect($service->canLogToInactiveProjects())->toBeTrue();
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// EDGE CASE TESTS
|
||||
// ============================================================================
|
||||
|
||||
test('calculate_variance sums multiple allocations', function () {
|
||||
$role = Role::factory()->create();
|
||||
$teamMember = TeamMember::factory()->create(['role_id' => $role->id]);
|
||||
$projectStatus = ProjectStatus::factory()->create(['name' => 'Active']);
|
||||
$project = Project::factory()->create(['status_id' => $projectStatus->id]);
|
||||
|
||||
// Multiple allocations for the same project/member/month
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'team_member_id' => $teamMember->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 40,
|
||||
]);
|
||||
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'team_member_id' => $teamMember->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 60,
|
||||
]);
|
||||
|
||||
Actual::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'team_member_id' => $teamMember->id,
|
||||
'month' => '2026-02-01',
|
||||
'hours_logged' => 100,
|
||||
]);
|
||||
|
||||
$service = app(ActualsService::class);
|
||||
$result = $service->calculateVariance($project->id, $teamMember->id, '2026-02');
|
||||
|
||||
// Total allocated: 100, Actual: 100, Variance: 0%
|
||||
expect($result['allocated'])->toBe(100.0)
|
||||
->and($result['actual'])->toBe(100.0)
|
||||
->and($result['variance_percentage'])->toBe(0.0)
|
||||
->and($result['indicator'])->toBe('green');
|
||||
});
|
||||
|
||||
test('calculate_variance sums multiple actuals', function () {
|
||||
$role = Role::factory()->create();
|
||||
$teamMember = TeamMember::factory()->create(['role_id' => $role->id]);
|
||||
$projectStatus = ProjectStatus::factory()->create(['name' => 'Active']);
|
||||
$project = Project::factory()->create(['status_id' => $projectStatus->id]);
|
||||
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'team_member_id' => $teamMember->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 100,
|
||||
]);
|
||||
|
||||
// Multiple actual entries (simulating multiple time logs)
|
||||
Actual::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'team_member_id' => $teamMember->id,
|
||||
'month' => '2026-02-01',
|
||||
'hours_logged' => 40,
|
||||
]);
|
||||
|
||||
Actual::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'team_member_id' => $teamMember->id,
|
||||
'month' => '2026-02-01',
|
||||
'hours_logged' => 60,
|
||||
]);
|
||||
|
||||
$service = app(ActualsService::class);
|
||||
$result = $service->calculateVariance($project->id, $teamMember->id, '2026-02');
|
||||
|
||||
// Total allocated: 100, Total actual: 100, Variance: 0%
|
||||
expect($result['allocated'])->toBe(100.0)
|
||||
->and($result['actual'])->toBe(100.0)
|
||||
->and($result['variance_percentage'])->toBe(0.0)
|
||||
->and($result['indicator'])->toBe('green');
|
||||
});
|
||||
|
||||
test('calculate_variance handles decimal precision', function () {
|
||||
$role = Role::factory()->create();
|
||||
$teamMember = TeamMember::factory()->create(['role_id' => $role->id]);
|
||||
$projectStatus = ProjectStatus::factory()->create(['name' => 'Active']);
|
||||
$project = Project::factory()->create(['status_id' => $projectStatus->id]);
|
||||
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'team_member_id' => $teamMember->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 33.33,
|
||||
]);
|
||||
|
||||
Actual::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'team_member_id' => $teamMember->id,
|
||||
'month' => '2026-02-01',
|
||||
'hours_logged' => 66.66,
|
||||
]);
|
||||
|
||||
$service = app(ActualsService::class);
|
||||
$result = $service->calculateVariance($project->id, $teamMember->id, '2026-02');
|
||||
|
||||
// Variance = ((66.66 - 33.33) / 33.33) * 100 = 100%
|
||||
expect($result['variance_percentage'])->toBe(100.0)
|
||||
->and($result['indicator'])->toBe('red');
|
||||
});
|
||||
|
||||
test('calculate_variance only_matches_exact_month', function () {
|
||||
$role = Role::factory()->create();
|
||||
$teamMember = TeamMember::factory()->create(['role_id' => $role->id]);
|
||||
$projectStatus = ProjectStatus::factory()->create(['name' => 'Active']);
|
||||
$project = Project::factory()->create(['status_id' => $projectStatus->id]);
|
||||
|
||||
// Allocation in January
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'team_member_id' => $teamMember->id,
|
||||
'month' => '2026-01-01',
|
||||
'allocated_hours' => 100,
|
||||
]);
|
||||
|
||||
// Actual in February
|
||||
Actual::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'team_member_id' => $teamMember->id,
|
||||
'month' => '2026-02-01',
|
||||
'hours_logged' => 80,
|
||||
]);
|
||||
|
||||
$service = app(ActualsService::class);
|
||||
|
||||
// Querying February should only find the actual, not the January allocation
|
||||
$result = $service->calculateVariance($project->id, $teamMember->id, '2026-02');
|
||||
expect($result['allocated'])->toBe(0.0)
|
||||
->and($result['actual'])->toBe(80.0)
|
||||
->and($result['variance_percentage'])->toBe(100.0);
|
||||
});
|
||||
109
backend/tests/Unit/Services/UtilizationFormatterTest.php
Normal file
109
backend/tests/Unit/Services/UtilizationFormatterTest.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
use App\Services\UtilizationFormatter;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* @mixin \Tests\TestCase
|
||||
*/
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
test('7.3.2a UtilizationFormatter getIndicator returns correct values', function () {
|
||||
$formatter = app(UtilizationFormatter::class);
|
||||
|
||||
// Under-utilized (< 70%)
|
||||
expect($formatter->getIndicator(0))->toBe('gray')
|
||||
->and($formatter->getIndicator(50))->toBe('gray')
|
||||
->and($formatter->getIndicator(69.9))->toBe('gray');
|
||||
|
||||
// Low utilization (70-80%)
|
||||
expect($formatter->getIndicator(70))->toBe('blue')
|
||||
->and($formatter->getIndicator(75))->toBe('blue')
|
||||
->and($formatter->getIndicator(79.9))->toBe('blue');
|
||||
|
||||
// Optimal (80-100%)
|
||||
expect($formatter->getIndicator(80))->toBe('green')
|
||||
->and($formatter->getIndicator(90))->toBe('green')
|
||||
->and($formatter->getIndicator(100))->toBe('green');
|
||||
|
||||
// Caution (100-110%)
|
||||
expect($formatter->getIndicator(100.1))->toBe('yellow')
|
||||
->and($formatter->getIndicator(105))->toBe('yellow')
|
||||
->and($formatter->getIndicator(110))->toBe('yellow');
|
||||
|
||||
// Over-allocated (> 110%)
|
||||
expect($formatter->getIndicator(110.1))->toBe('red')
|
||||
->and($formatter->getIndicator(120))->toBe('red')
|
||||
->and($formatter->getIndicator(200))->toBe('red');
|
||||
});
|
||||
|
||||
test('7.3.2b UtilizationFormatter getDisplayColor maps yellow to amber', function () {
|
||||
$formatter = app(UtilizationFormatter::class);
|
||||
|
||||
expect($formatter->getDisplayColor(50))->toBe('gray')
|
||||
->and($formatter->getDisplayColor(75))->toBe('blue')
|
||||
->and($formatter->getDisplayColor(90))->toBe('green')
|
||||
->and($formatter->getDisplayColor(105))->toBe('amber')
|
||||
->and($formatter->getDisplayColor(120))->toBe('red');
|
||||
});
|
||||
|
||||
test('7.3.2c UtilizationFormatter getStatusDescription returns correct descriptions', function () {
|
||||
$formatter = app(UtilizationFormatter::class);
|
||||
|
||||
expect($formatter->getStatusDescription(50))->toBe('Under-utilized')
|
||||
->and($formatter->getStatusDescription(75))->toBe('Low utilization')
|
||||
->and($formatter->getStatusDescription(90))->toBe('Optimal')
|
||||
->and($formatter->getStatusDescription(105))->toBe('High utilization')
|
||||
->and($formatter->getStatusDescription(120))->toBe('Over-allocated');
|
||||
});
|
||||
|
||||
test('7.3.2d UtilizationFormatter formatPercentage formats correctly', function () {
|
||||
$formatter = app(UtilizationFormatter::class);
|
||||
|
||||
expect($formatter->formatPercentage(87.54))->toBe('87.5%')
|
||||
->and($formatter->formatPercentage(87.54, 2))->toBe('87.54%')
|
||||
->and($formatter->formatPercentage(100))->toBe('100.0%');
|
||||
});
|
||||
|
||||
test('7.3.2e UtilizationFormatter formatHours formats correctly', function () {
|
||||
$formatter = app(UtilizationFormatter::class);
|
||||
|
||||
expect($formatter->formatHours(160))->toBe('160.0h')
|
||||
->and($formatter->formatHours(160.5, 2))->toBe('160.50h');
|
||||
});
|
||||
|
||||
test('7.3.2f UtilizationFormatter getTailwindClasses returns correct classes', function () {
|
||||
$formatter = app(UtilizationFormatter::class);
|
||||
|
||||
$classes = $formatter->getTailwindClasses(90);
|
||||
expect($classes['bg'])->toBe('bg-green-100')
|
||||
->and($classes['text'])->toBe('text-green-700')
|
||||
->and($classes['border'])->toBe('border-green-300');
|
||||
});
|
||||
|
||||
test('7.3.2g UtilizationFormatter getDaisyuiBadgeClass returns correct classes', function () {
|
||||
$formatter = app(UtilizationFormatter::class);
|
||||
|
||||
expect($formatter->getDaisyuiBadgeClass(50))->toBe('badge-neutral')
|
||||
->and($formatter->getDaisyuiBadgeClass(75))->toBe('badge-info')
|
||||
->and($formatter->getDaisyuiBadgeClass(90))->toBe('badge-success')
|
||||
->and($formatter->getDaisyuiBadgeClass(105))->toBe('badge-warning')
|
||||
->and($formatter->getDaisyuiBadgeClass(120))->toBe('badge-error');
|
||||
});
|
||||
|
||||
test('7.3.2h UtilizationFormatter formatUtilizationResponse returns complete structure', function () {
|
||||
$formatter = app(UtilizationFormatter::class);
|
||||
|
||||
$response = $formatter->formatUtilizationResponse(87.5, 160, 140);
|
||||
|
||||
expect($response)->toHaveKeys(['capacity', 'allocated', 'utilization', 'indicator', 'display'])
|
||||
->and($response['capacity'])->toBe(160.0)
|
||||
->and($response['allocated'])->toBe(140.0)
|
||||
->and($response['utilization'])->toBe(87.5)
|
||||
->and($response['indicator'])->toBe('green')
|
||||
->and($response['display']['percentage'])->toBe('87.5%')
|
||||
->and($response['display']['color'])->toBe('green')
|
||||
->and($response['display']['status'])->toBe('Optimal')
|
||||
->and($response['display']['badge_class'])->toBe('badge-success');
|
||||
});
|
||||
314
backend/tests/Unit/Services/UtilizationServiceTest.php
Normal file
314
backend/tests/Unit/Services/UtilizationServiceTest.php
Normal file
@@ -0,0 +1,314 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Allocation;
|
||||
use App\Models\Holiday;
|
||||
use App\Models\Role;
|
||||
use App\Models\TeamMember;
|
||||
use App\Services\CapacityService;
|
||||
use App\Services\UtilizationService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* @mixin \Tests\TestCase
|
||||
*/
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
// ============================================================================
|
||||
// 7.1.12 UtilizationService calculates running utilization (YTD)
|
||||
// ============================================================================
|
||||
|
||||
test('7.1.12a UtilizationService calculates running utilization YTD', function () {
|
||||
$role = Role::factory()->create();
|
||||
$member = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
|
||||
|
||||
// Create allocations for Jan, Feb, Mar
|
||||
Allocation::factory()->create([
|
||||
'team_member_id' => $member->id,
|
||||
'month' => '2026-01-01',
|
||||
'allocated_hours' => 140,
|
||||
]);
|
||||
Allocation::factory()->create([
|
||||
'team_member_id' => $member->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 150,
|
||||
]);
|
||||
Allocation::factory()->create([
|
||||
'team_member_id' => $member->id,
|
||||
'month' => '2026-03-01',
|
||||
'allocated_hours' => 160,
|
||||
]);
|
||||
|
||||
$service = app(UtilizationService::class);
|
||||
$result = $service->calculateRunningUtilization($member->id, '2026-03');
|
||||
|
||||
// Expected: YTD allocated = 140 + 150 + 160 = 450
|
||||
// Expected: YTD capacity = varies by working days per month
|
||||
expect($result['allocated_ytd'])->toBe(450.0)
|
||||
->and($result['months_included'])->toBe(3)
|
||||
->and($result['utilization'])->toBeGreaterThan(80.0);
|
||||
});
|
||||
|
||||
test('7.1.12b Running utilization at start of year (January only)', function () {
|
||||
$role = Role::factory()->create();
|
||||
$member = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
|
||||
|
||||
Allocation::factory()->create([
|
||||
'team_member_id' => $member->id,
|
||||
'month' => '2026-01-01',
|
||||
'allocated_hours' => 120,
|
||||
]);
|
||||
|
||||
$service = app(UtilizationService::class);
|
||||
$result = $service->calculateRunningUtilization($member->id, '2026-01');
|
||||
|
||||
// Only January (Jan 2026 has 21 working days = 176 hours capacity)
|
||||
// 120 / 176 = 68.2%
|
||||
expect($result['months_included'])->toBe(1)
|
||||
->and($result['allocated_ytd'])->toBe(120.0)
|
||||
->and($result['utilization'])->toBeGreaterThan(60.0);
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// 7.1.13 UtilizationService calculates overall utilization (monthly)
|
||||
// ============================================================================
|
||||
|
||||
test('7.1.13a UtilizationService calculates overall utilization monthly', function () {
|
||||
$role = Role::factory()->create();
|
||||
$member = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
|
||||
|
||||
Allocation::factory()->create([
|
||||
'team_member_id' => $member->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 140,
|
||||
]);
|
||||
|
||||
$service = app(UtilizationService::class);
|
||||
$result = $service->calculateOverallUtilization($member->id, '2026-02');
|
||||
|
||||
// Feb 2026: 20 working days = 160 hours capacity
|
||||
// 140 / 160 * 100 = 87.5%
|
||||
expect($result['capacity'])->toBe(160.0)
|
||||
->and($result['allocated'])->toBe(140.0)
|
||||
->and($result['utilization'])->toBe(87.5);
|
||||
});
|
||||
|
||||
test('7.1.13b Full utilization 100%', function () {
|
||||
$role = Role::factory()->create();
|
||||
$member = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
|
||||
|
||||
Allocation::factory()->create([
|
||||
'team_member_id' => $member->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 160,
|
||||
]);
|
||||
|
||||
$service = app(UtilizationService::class);
|
||||
$result = $service->calculateOverallUtilization($member->id, '2026-02');
|
||||
|
||||
expect($result['utilization'])->toBe(100.0);
|
||||
});
|
||||
|
||||
test('7.1.13c Over-utilization >100%', function () {
|
||||
$role = Role::factory()->create();
|
||||
$member = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
|
||||
|
||||
Allocation::factory()->create([
|
||||
'team_member_id' => $member->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 180,
|
||||
]);
|
||||
|
||||
$service = app(UtilizationService::class);
|
||||
$result = $service->calculateOverallUtilization($member->id, '2026-02');
|
||||
|
||||
// 180 / 160 * 100 = 112.5%
|
||||
expect($result['utilization'])->toBe(112.5);
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// 7.1.14 UtilizationService handles edge cases
|
||||
// ============================================================================
|
||||
|
||||
test('7.1.14a Zero capacity returns zero utilization', function () {
|
||||
$role = Role::factory()->create();
|
||||
$member = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
|
||||
|
||||
// Create a holiday for every working day in February to make capacity zero
|
||||
foreach (range(1, 28) as $day) {
|
||||
$date = sprintf('2026-02-%02d', $day);
|
||||
$carbon = \Carbon\Carbon::parse($date);
|
||||
if (! $carbon->isWeekend()) {
|
||||
Holiday::create(['date' => $date, 'name' => "Holiday $day", 'description' => 'Test']);
|
||||
}
|
||||
}
|
||||
|
||||
Allocation::factory()->create([
|
||||
'team_member_id' => $member->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 100,
|
||||
]);
|
||||
|
||||
$service = app(UtilizationService::class);
|
||||
$result = $service->calculateOverallUtilization($member->id, '2026-02');
|
||||
|
||||
expect($result['capacity'])->toBe(0.0)
|
||||
->and($result['utilization'])->toBe(0.0);
|
||||
});
|
||||
|
||||
test('7.1.14b No allocations returns zero utilization', function () {
|
||||
$role = Role::factory()->create();
|
||||
$member = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
|
||||
|
||||
$service = app(UtilizationService::class);
|
||||
$result = $service->calculateOverallUtilization($member->id, '2026-02');
|
||||
|
||||
expect($result['allocated'])->toBe(0.0)
|
||||
->and($result['utilization'])->toBe(0.0);
|
||||
});
|
||||
|
||||
test('7.1.14c Team utilization excludes inactive members', function () {
|
||||
$role = Role::factory()->create();
|
||||
$activeMember = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
|
||||
$inactiveMember = TeamMember::factory()->create(['role_id' => $role->id, 'active' => false]);
|
||||
|
||||
Allocation::factory()->create([
|
||||
'team_member_id' => $activeMember->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 140,
|
||||
]);
|
||||
Allocation::factory()->create([
|
||||
'team_member_id' => $inactiveMember->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 160,
|
||||
]);
|
||||
|
||||
$service = app(UtilizationService::class);
|
||||
$result = $service->calculateTeamUtilization('2026-02');
|
||||
|
||||
// Only active member counted
|
||||
expect($result['member_count'])->toBe(1);
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// 7.1.15 Color coding logic
|
||||
// ============================================================================
|
||||
|
||||
test('7.1.15a Low utilization (< 70%) is gray', function () {
|
||||
$service = app(UtilizationService::class);
|
||||
|
||||
expect($service->getUtilizationIndicator(0))->toBe('gray')
|
||||
->and($service->getUtilizationIndicator(50))->toBe('gray')
|
||||
->and($service->getUtilizationIndicator(69.9))->toBe('gray');
|
||||
});
|
||||
|
||||
test('7.1.15b Low utilization (70-80%) is blue', function () {
|
||||
$service = app(UtilizationService::class);
|
||||
|
||||
expect($service->getUtilizationIndicator(70))->toBe('blue')
|
||||
->and($service->getUtilizationIndicator(75))->toBe('blue')
|
||||
->and($service->getUtilizationIndicator(79.9))->toBe('blue');
|
||||
});
|
||||
|
||||
test('7.1.15c Optimal utilization (80-100%) is green', function () {
|
||||
$service = app(UtilizationService::class);
|
||||
|
||||
expect($service->getUtilizationIndicator(80))->toBe('green')
|
||||
->and($service->getUtilizationIndicator(90))->toBe('green')
|
||||
->and($service->getUtilizationIndicator(100))->toBe('green');
|
||||
});
|
||||
|
||||
test('7.1.15d High utilization (100-110%) is yellow', function () {
|
||||
$service = app(UtilizationService::class);
|
||||
|
||||
expect($service->getUtilizationIndicator(100.1))->toBe('yellow')
|
||||
->and($service->getUtilizationIndicator(105))->toBe('yellow')
|
||||
->and($service->getUtilizationIndicator(110))->toBe('yellow');
|
||||
});
|
||||
|
||||
test('7.1.15e Over-utilization (> 110%) is red', function () {
|
||||
$service = app(UtilizationService::class);
|
||||
|
||||
expect($service->getUtilizationIndicator(110.1))->toBe('red')
|
||||
->and($service->getUtilizationIndicator(120))->toBe('red')
|
||||
->and($service->getUtilizationIndicator(200))->toBe('red');
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Additional coverage tests
|
||||
// ============================================================================
|
||||
|
||||
test('7.1.16 getUtilizationData combines overall and running', function () {
|
||||
$role = Role::factory()->create();
|
||||
$member = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
|
||||
|
||||
Allocation::factory()->create([
|
||||
'team_member_id' => $member->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 140,
|
||||
]);
|
||||
|
||||
$service = app(UtilizationService::class);
|
||||
$result = $service->getUtilizationData($member->id, '2026-02');
|
||||
|
||||
expect($result)->toHaveKeys(['overall', 'running'])
|
||||
->and($result['overall'])->toHaveKeys(['capacity', 'allocated', 'utilization', 'indicator'])
|
||||
->and($result['running'])->toHaveKeys(['capacity_ytd', 'allocated_ytd', 'utilization', 'indicator', 'months_included']);
|
||||
});
|
||||
|
||||
test('7.1.17 getUtilizationTrend returns monthly data', function () {
|
||||
$role = Role::factory()->create();
|
||||
$member = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
|
||||
|
||||
Allocation::factory()->create([
|
||||
'team_member_id' => $member->id,
|
||||
'month' => '2026-01-01',
|
||||
'allocated_hours' => 140,
|
||||
]);
|
||||
Allocation::factory()->create([
|
||||
'team_member_id' => $member->id,
|
||||
'month' => '2026-02-01',
|
||||
'allocated_hours' => 150,
|
||||
]);
|
||||
Allocation::factory()->create([
|
||||
'team_member_id' => $member->id,
|
||||
'month' => '2026-03-01',
|
||||
'allocated_hours' => 160,
|
||||
]);
|
||||
|
||||
$service = app(UtilizationService::class);
|
||||
$result = $service->getUtilizationTrend($member->id, '2026-01', '2026-03');
|
||||
|
||||
expect($result)->toHaveCount(3)
|
||||
->and($result[0])->toHaveKeys(['month', 'utilization', 'indicator', 'capacity', 'allocated'])
|
||||
->and($result[0]['month'])->toBe('2026-01')
|
||||
->and($result[2]['month'])->toBe('2026-03');
|
||||
});
|
||||
|
||||
test('7.1.18 calculateTeamRunningUtilization calculates YTD team average', function () {
|
||||
$role = Role::factory()->create();
|
||||
$memberA = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
|
||||
$memberB = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
|
||||
|
||||
// Jan 2026 has 21 working days = 176 hours capacity
|
||||
// Member A: 140/176 = 79.5% utilization
|
||||
Allocation::factory()->create([
|
||||
'team_member_id' => $memberA->id,
|
||||
'month' => '2026-01-01',
|
||||
'allocated_hours' => 140,
|
||||
]);
|
||||
|
||||
// Member B: 150/176 = 85.2% utilization
|
||||
Allocation::factory()->create([
|
||||
'team_member_id' => $memberB->id,
|
||||
'month' => '2026-01-01',
|
||||
'allocated_hours' => 150,
|
||||
]);
|
||||
|
||||
$service = app(UtilizationService::class);
|
||||
$result = $service->calculateTeamRunningUtilization('2026-01');
|
||||
|
||||
// Average: (79.5 + 85.2) / 2 = 82.35
|
||||
expect($result['member_count'])->toBe(2)
|
||||
->and($result['average_utilization'])->toBe(82.4);
|
||||
});
|
||||
Reference in New Issue
Block a user