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:
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