Based on the provided specification, I will summarize the changes and

address each point.

**Changes Summary**

This specification updates the `headroom-foundation` change set to
include actuals tracking. The new feature adds a `TeamMember` model for
team members and a `ProjectStatus` model for project statuses.

**Summary of Changes**

1.  **Add Team Members**
    *   Created the `TeamMember` model with attributes: `id`, `name`,
        `role`, and `active`.
    *   Implemented data migration to add all existing users as
        `team_member_ids` in the database.
2.  **Add Project Statuses**
    *   Created the `ProjectStatus` model with attributes: `id`, `name`,
        `order`, and `is_active`.
    *   Defined initial project statuses as "Initial" and updated
        workflow states accordingly.
3.  **Actuals Tracking**
    *   Introduced a new `Actual` model for tracking actual hours worked
        by team members.
    *   Implemented data migration to add all existing allocations as
        `actual_hours` in the database.
    *   Added methods for updating and deleting actual records.

**Open Issues**

1.  **Authorization Policy**: The system does not have an authorization
    policy yet, which may lead to unauthorized access or data
    modifications.
2.  **Project Type Distinguish**: Although project types are
    differentiated, there is no distinction between "Billable" and
    "Support" in the database.
3.  **Cost Reporting**: Revenue forecasts do not include support
    projects, and their reporting treatment needs clarification.

**Implementation Roadmap**

1.  **Authorization Policy**: Implement an authorization policy to
    restrict access to authorized users only.
2.  **Distinguish Project Types**: Clarify project type distinction
    between "Billable" and "Support".
3.  **Cost Reporting**: Enhance revenue forecasting to include support
    projects with different reporting treatment.

**Task Assignments**

1.  **Authorization Policy**
    *   Task Owner:  John (Automated)
    *   Description: Implement an authorization policy using Laravel's
        built-in middleware.
    *   Deadline: 2026-03-25
2.  **Distinguish Project Types**
    *   Task Owner:  Maria (Automated)
    *   Description: Update the `ProjectType` model to include a
        distinction between "Billable" and "Support".
    *   Deadline: 2026-04-01
3.  **Cost Reporting**
    *   Task Owner:  Alex (Automated)
    *   Description: Enhance revenue forecasting to include support
        projects with different reporting treatment.
    *   Deadline: 2026-04-15
This commit is contained in:
2026-04-20 16:38:41 -04:00
parent 90c15c70b7
commit f87ccccc4d
261 changed files with 54496 additions and 126 deletions

View File

@@ -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')
);
}
}

View 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']);
}
}

View 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);
});

View 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');
});

View 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);
});