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