test(backend): add comprehensive tests for reporting and allocation

Add test coverage for:
- ReportTest: 9 tests for reporting API (payload, view types, filters, validation)
- ProjectMonthPlanTest: CRUD operations for monthly planning
- UntrackedAllocationTest: untracked allocation handling
- ReconciliationCalculatorTest: plan vs estimate reconciliation logic
- VarianceCalculatorTest: variance and status calculations

All tests passing (157 total).
This commit is contained in:
2026-03-08 18:22:40 -04:00
parent 2a93245970
commit 7fa5b9061c
5 changed files with 954 additions and 0 deletions

View File

@@ -0,0 +1,134 @@
<?php
namespace Tests\Feature;
use App\Models\Project;
use App\Models\ProjectMonthPlan;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ProjectMonthPlanTest extends TestCase
{
use RefreshDatabase;
protected function loginAsManager()
{
$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');
}
public function test_bulk_update_creates_new_plan_records(): void
{
$token = $this->loginAsManager();
$project = Project::factory()->create();
$response = $this->withHeader('Authorization', "Bearer {$token}")
->putJson('/api/project-month-plans/bulk', [
'year' => 2026,
'items' => [
[
'project_id' => $project->id,
'month' => '2026-01',
'planned_hours' => 100,
],
],
]);
$response->assertStatus(200);
}
public function test_validation_rejects_invalid_data(): void
{
$token = $this->loginAsManager();
$response = $this->withHeader('Authorization', "Bearer {$token}")
->putJson('/api/project-month-plans/bulk', [
'year' => 2026,
'items' => [
[
'project_id' => 'invalid-uuid',
'month' => '2026-01',
'planned_hours' => 100,
],
],
]);
$response->assertStatus(422);
}
public function test_index_returns_existing_plan_for_month(): void
{
$token = $this->loginAsManager();
$project = Project::factory()->create();
ProjectMonthPlan::create([
'project_id' => $project->id,
'month' => '2026-02-01',
'planned_hours' => 50,
]);
$projectData = $this->fetchProjectPlan($token, $project);
$this->assertNotNull($projectData['months']['2026-02-01']);
}
public function test_index_returns_null_for_missing_month(): void
{
$token = $this->loginAsManager();
$project = Project::factory()->create();
$projectData = $this->fetchProjectPlan($token, $project);
$this->assertNull($projectData['months']['2026-03-01']);
}
public function test_bulk_update_roundtrip_populates_month(): void
{
$token = $this->loginAsManager();
$project = Project::factory()->create();
$this->withHeader('Authorization', "Bearer {$token}")
->putJson('/api/project-month-plans/bulk', [
'year' => 2026,
'items' => [
[
'project_id' => $project->id,
'month' => '2026-02',
'planned_hours' => 72,
],
],
])
->assertStatus(200);
$projectData = $this->fetchProjectPlan($token, $project);
$this->assertNotNull($projectData['months']['2026-02-01']);
$this->assertEquals(72, $projectData['plan_sum']);
}
private function fetchProjectPlan(string $token, Project $project): array
{
$response = $this->withHeader('Authorization', "Bearer {$token}")
->getJson('/api/project-month-plans?year=2026');
$response->assertStatus(200);
$projectData = collect($response->json('data'))->firstWhere('project_id', $project->id);
$this->assertNotNull($projectData);
return $projectData;
}
}

View File

@@ -0,0 +1,367 @@
<?php
namespace Tests\Feature;
use App\Models\Allocation;
use App\Models\Project;
use App\Models\ProjectMonthPlan;
use App\Models\Role;
use App\Models\TeamMember;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ReportTest 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');
}
// Task 4.1: Reporting payload includes lifecycle total, month plan, month execution, and variances
public function test_reporting_payload_includes_all_required_data(): void
{
$token = $this->loginAsManager();
// Create project with approved estimate
$project = Project::factory()->create([
'approved_estimate' => 3000,
]);
// Create month plans
ProjectMonthPlan::create([
'project_id' => $project->id,
'month' => '2026-01-01',
'planned_hours' => 1200,
]);
ProjectMonthPlan::create([
'project_id' => $project->id,
'month' => '2026-02-01',
'planned_hours' => 1400,
]);
// Create team member
$role = Role::factory()->create();
$member = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
// Create allocation
Allocation::factory()->create([
'project_id' => $project->id,
'team_member_id' => $member->id,
'month' => '2026-01-01',
'allocated_hours' => 1300,
]);
$response = $this->withHeader('Authorization', "Bearer {$token}")
->getJson('/api/reports/allocations?start_date=2026-01-01&end_date=2026-02-28');
$response->assertStatus(200);
// Verify top-level structure
$response->assertJsonStructure([
'period' => ['start', 'end'],
'view_type',
'projects' => [
'*' => [
'id',
'code',
'title',
'approved_estimate',
'lifecycle_status',
'plan_sum',
'period_planned',
'period_allocated',
'period_variance',
'period_status',
'months' => [
'*' => [
'month',
'planned_hours',
'is_blank',
'allocated_hours',
'variance',
'status',
],
],
],
],
'members' => [
'*' => [
'id',
'name',
'period_allocated',
'projects' => [
'*' => [
'project_id',
'project_code',
'project_title',
'total_hours',
],
],
],
],
'aggregates' => [
'total_planned',
'total_allocated',
'total_variance',
'status',
],
]);
// Verify data values
$data = $response->json();
$projectData = collect($data['projects'])->firstWhere('id', $project->id);
$this->assertEquals(3000.0, $projectData['approved_estimate']);
$this->assertEquals(2600.0, $projectData['plan_sum']); // 1200 + 1400
$this->assertEquals(2600.0, $projectData['period_planned']);
$this->assertEquals(1300.0, $projectData['period_allocated']);
$this->assertEquals(-1300.0, $projectData['period_variance']);
$this->assertEquals('UNDER', $projectData['period_status']);
$this->assertEquals('UNDER', $projectData['lifecycle_status']);
}
// Task 4.2: Historical/current/future month slices are consistent
public function test_view_type_did_for_past_months(): void
{
$token = $this->loginAsManager();
Project::factory()->create();
$now = Carbon::now();
$pastStart = $now->copy()->subMonths(3)->startOfMonth();
$pastEnd = $now->copy()->subMonth()->endOfMonth();
$response = $this->withHeader('Authorization', "Bearer {$token}")
->getJson("/api/reports/allocations?start_date={$pastStart->format('Y-m-d')}&end_date={$pastEnd->format('Y-m-d')}");
$response->assertStatus(200);
$response->assertJsonPath('view_type', 'did');
}
public function test_view_type_is_for_current_month(): void
{
$token = $this->loginAsManager();
Project::factory()->create();
$now = Carbon::now();
$currentStart = $now->copy()->startOfMonth();
$currentEnd = $now->copy()->endOfMonth();
$response = $this->withHeader('Authorization', "Bearer {$token}")
->getJson("/api/reports/allocations?start_date={$currentStart->format('Y-m-d')}&end_date={$currentEnd->format('Y-m-d')}");
$response->assertStatus(200);
$response->assertJsonPath('view_type', 'is');
}
public function test_view_type_will_for_future_months(): void
{
$token = $this->loginAsManager();
Project::factory()->create();
$now = Carbon::now();
$futureStart = $now->copy()->addMonth()->startOfMonth();
$futureEnd = $now->copy()->addMonths(3)->endOfMonth();
$response = $this->withHeader('Authorization', "Bearer {$token}")
->getJson("/api/reports/allocations?start_date={$futureStart->format('Y-m-d')}&end_date={$futureEnd->format('Y-m-d')}");
$response->assertStatus(200);
$response->assertJsonPath('view_type', 'will');
}
// Task 4.4: Distinguish blank plan vs explicit zero
public function test_distinguishes_blank_plan_from_explicit_zero(): void
{
$token = $this->loginAsManager();
$project = Project::factory()->create();
$role = Role::factory()->create();
$member = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
// January: explicit plan of 100
ProjectMonthPlan::create([
'project_id' => $project->id,
'month' => '2026-01-01',
'planned_hours' => 100,
]);
// February: explicit plan of 0
ProjectMonthPlan::create([
'project_id' => $project->id,
'month' => '2026-02-01',
'planned_hours' => 0,
]);
// March: blank (no plan entry)
Allocation::factory()->create([
'project_id' => $project->id,
'team_member_id' => $member->id,
'month' => '2026-01-01',
'allocated_hours' => 80,
]);
Allocation::factory()->create([
'project_id' => $project->id,
'team_member_id' => $member->id,
'month' => '2026-02-01',
'allocated_hours' => 50,
]);
Allocation::factory()->create([
'project_id' => $project->id,
'team_member_id' => $member->id,
'month' => '2026-03-01',
'allocated_hours' => 30,
]);
$response = $this->withHeader('Authorization', "Bearer {$token}")
->getJson('/api/reports/allocations?start_date=2026-01-01&end_date=2026-03-31');
$response->assertStatus(200);
$data = $response->json();
$projectData = collect($data['projects'])->firstWhere('id', $project->id);
$january = collect($projectData['months'])->firstWhere('month', '2026-01');
$february = collect($projectData['months'])->firstWhere('month', '2026-02');
$march = collect($projectData['months'])->firstWhere('month', '2026-03');
// January: explicit 100
$this->assertEquals(100.0, $january['planned_hours']);
$this->assertFalse($january['is_blank']);
// February: explicit 0
$this->assertEquals(0.0, $february['planned_hours']);
$this->assertFalse($february['is_blank']);
// March: blank
$this->assertNull($march['planned_hours']);
$this->assertTrue($march['is_blank']);
}
public function test_filters_by_project_ids(): void
{
$token = $this->loginAsManager();
$project1 = Project::factory()->create();
$project2 = Project::factory()->create();
$role = Role::factory()->create();
$member = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
ProjectMonthPlan::create([
'project_id' => $project1->id,
'month' => '2026-01-01',
'planned_hours' => 100,
]);
ProjectMonthPlan::create([
'project_id' => $project2->id,
'month' => '2026-01-01',
'planned_hours' => 200,
]);
Allocation::factory()->create([
'project_id' => $project1->id,
'team_member_id' => $member->id,
'month' => '2026-01-01',
'allocated_hours' => 80,
]);
Allocation::factory()->create([
'project_id' => $project2->id,
'team_member_id' => $member->id,
'month' => '2026-01-01',
'allocated_hours' => 150,
]);
$response = $this->withHeader('Authorization', "Bearer {$token}")
->getJson("/api/reports/allocations?start_date=2026-01-01&end_date=2026-01-31&project_ids[]={$project1->id}");
$response->assertStatus(200);
$data = $response->json();
$this->assertCount(1, $data['projects']);
$this->assertEquals($project1->id, $data['projects'][0]['id']);
$this->assertEquals(100.0, $data['aggregates']['total_planned']);
}
public function test_includes_untracked_allocations(): void
{
$token = $this->loginAsManager();
$project = Project::factory()->create();
$role = Role::factory()->create();
$member = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
ProjectMonthPlan::create([
'project_id' => $project->id,
'month' => '2026-01-01',
'planned_hours' => 200,
]);
// Tracked allocation
Allocation::factory()->create([
'project_id' => $project->id,
'team_member_id' => $member->id,
'month' => '2026-01-01',
'allocated_hours' => 100,
]);
// Untracked allocation
Allocation::factory()->create([
'project_id' => $project->id,
'team_member_id' => null,
'month' => '2026-01-01',
'allocated_hours' => 50,
]);
$response = $this->withHeader('Authorization', "Bearer {$token}")
->getJson('/api/reports/allocations?start_date=2026-01-01&end_date=2026-01-31');
$response->assertStatus(200);
$data = $response->json();
// Project should have 150 total (100 tracked + 50 untracked)
$projectData = collect($data['projects'])->firstWhere('id', $project->id);
$this->assertEquals(150.0, $projectData['period_allocated']);
// Members should include untracked row
$untrackedRow = collect($data['members'])->firstWhere('name', 'Untracked');
$this->assertNotNull($untrackedRow);
$this->assertEquals(50.0, $untrackedRow['period_allocated']);
}
public function test_validates_required_date_parameters(): void
{
$token = $this->loginAsManager();
$response = $this->withHeader('Authorization', "Bearer {$token}")
->getJson('/api/reports/allocations');
$response->assertStatus(422);
$response->assertJsonValidationErrors(['start_date', 'end_date']);
}
public function test_validates_end_date_after_start_date(): void
{
$token = $this->loginAsManager();
$response = $this->withHeader('Authorization', "Bearer {$token}")
->getJson('/api/reports/allocations?start_date=2026-03-01&end_date=2026-01-01');
$response->assertStatus(422);
$response->assertJsonValidationErrors(['end_date']);
}
}

View File

@@ -0,0 +1,113 @@
<?php
namespace Tests\Feature;
use App\Models\Project;
use App\Models\Role;
use App\Models\TeamMember;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class UntrackedAllocationTest extends TestCase
{
use RefreshDatabase;
protected function loginAsManager()
{
$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');
}
public function test_create_allocation_accepts_null_team_member_id(): void
{
$token = $this->loginAsManager();
$project = Project::factory()->create();
$response = $this->withHeader('Authorization', "Bearer {$token}")
->postJson('/api/allocations', [
'project_id' => $project->id,
'team_member_id' => null,
'month' => '2026-02',
'allocated_hours' => 40,
]);
$response->assertStatus(201);
$this->assertDatabaseHas('allocations', [
'project_id' => $project->id,
'team_member_id' => null,
'allocated_hours' => 40,
]);
}
public function test_bulk_create_accepts_mixed_tracked_and_untracked(): void
{
$token = $this->loginAsManager();
$project = Project::factory()->create();
$role = Role::factory()->create();
$teamMember = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
$response = $this->withHeader('Authorization', "Bearer {$token}")
->postJson('/api/allocations/bulk', [
'allocations' => [
[
'project_id' => $project->id,
'team_member_id' => $teamMember->id,
'month' => '2026-02',
'allocated_hours' => 40,
],
[
'project_id' => $project->id,
'team_member_id' => null,
'month' => '2026-02',
'allocated_hours' => 30,
],
],
]);
$response->assertStatus(201);
$this->assertDatabaseCount('allocations', 2);
}
public function test_partial_bulk_persists_valid_rows(): void
{
$token = $this->loginAsManager();
$project = Project::factory()->create();
$role = Role::factory()->create();
$teamMember = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
$response = $this->withHeader('Authorization', "Bearer {$token}")
->postJson('/api/allocations/bulk', [
'allocations' => [
[
'project_id' => $project->id,
'team_member_id' => $teamMember->id,
'month' => '2026-02',
'allocated_hours' => 40,
],
[
'project_id' => 'invalid-uuid',
'team_member_id' => $teamMember->id,
'month' => '2026-02',
'allocated_hours' => 20,
],
],
]);
$response->assertStatus(201);
$response->assertJsonPath('summary.created', 1);
$response->assertJsonPath('summary.failed', 1);
$this->assertDatabaseCount('allocations', 1);
}
}

View File

@@ -0,0 +1,180 @@
<?php
namespace Tests\Unit;
use App\Models\Project;
use App\Models\ProjectMonthPlan;
use App\Services\ReconciliationCalculator;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ReconciliationCalculatorTest extends TestCase
{
use RefreshDatabase;
private ReconciliationCalculator $calculator;
protected function setUp(): void
{
parent::setUp();
$this->calculator = new ReconciliationCalculator;
}
// 1.1: Unit test - reconciliation status OVER
public function test_returns_over_when_plan_sum_exceeds_approved_estimate(): void
{
$project = Project::factory()->create([
'approved_estimate' => 1000,
]);
ProjectMonthPlan::create([
'project_id' => $project->id,
'month' => '2026-01-01',
'planned_hours' => 600,
]);
ProjectMonthPlan::create([
'project_id' => $project->id,
'month' => '2026-02-01',
'planned_hours' => 600,
]);
$status = $this->calculator->calculateStatus($project);
$this->assertEquals('OVER', $status);
}
// 1.2: Unit test - reconciliation status UNDER
public function test_returns_under_when_plan_sum_is_less_than_approved_estimate(): void
{
$project = Project::factory()->create([
'approved_estimate' => 1000,
]);
ProjectMonthPlan::create([
'project_id' => $project->id,
'month' => '2026-01-01',
'planned_hours' => 400,
]);
$status = $this->calculator->calculateStatus($project);
$this->assertEquals('UNDER', $status);
}
// 1.3: Unit test - reconciliation status MATCH
public function test_returns_match_when_plan_sum_equals_approved_estimate(): void
{
$project = Project::factory()->create([
'approved_estimate' => 1000,
]);
ProjectMonthPlan::create([
'project_id' => $project->id,
'month' => '2026-01-01',
'planned_hours' => 500,
]);
ProjectMonthPlan::create([
'project_id' => $project->id,
'month' => '2026-02-01',
'planned_hours' => 500,
]);
$status = $this->calculator->calculateStatus($project);
$this->assertEquals('MATCH', $status);
}
// Additional test: decimal-safe MATCH
public function test_returns_match_with_decimal_precision(): void
{
$project = Project::factory()->create([
'approved_estimate' => 100.50,
]);
ProjectMonthPlan::create([
'project_id' => $project->id,
'month' => '2026-01-01',
'planned_hours' => 50.25,
]);
ProjectMonthPlan::create([
'project_id' => $project->id,
'month' => '2026-02-01',
'planned_hours' => 50.25,
]);
$status = $this->calculator->calculateStatus($project);
$this->assertEquals('MATCH', $status);
}
// Test: blank/null planned_hours are excluded from sum
public function test_excludes_null_planned_hours_from_sum(): void
{
$project = Project::factory()->create([
'approved_estimate' => 500,
]);
// Create plan with null planned_hours (blank cell)
ProjectMonthPlan::create([
'project_id' => $project->id,
'month' => '2026-01-01',
'planned_hours' => null,
]);
ProjectMonthPlan::create([
'project_id' => $project->id,
'month' => '2026-02-01',
'planned_hours' => 500,
]);
$status = $this->calculator->calculateStatus($project);
$planSum = $this->calculator->calculatePlanSum($project);
$this->assertEquals(500, $planSum);
$this->assertEquals('MATCH', $status);
}
// Test: no approved estimate returns UNDER
public function test_returns_under_when_no_approved_estimate(): void
{
$project = Project::factory()->create([
'approved_estimate' => 0,
]);
$status = $this->calculator->calculateStatus($project);
$this->assertEquals('UNDER', $status);
}
// Test: calculateForProjects returns array with correct structure
public function test_calculate_for_projects_returns_correct_structure(): void
{
$project1 = Project::factory()->create(['approved_estimate' => 1000]);
$project2 = Project::factory()->create(['approved_estimate' => 500]);
ProjectMonthPlan::create([
'project_id' => $project1->id,
'month' => '2026-01-01',
'planned_hours' => 1000,
]);
ProjectMonthPlan::create([
'project_id' => $project2->id,
'month' => '2026-01-01',
'planned_hours' => 300,
]);
$projects = Project::whereIn('id', [$project1->id, $project2->id])->get();
$results = $this->calculator->calculateForProjects($projects, 2026);
$this->assertArrayHasKey($project1->id, $results);
$this->assertArrayHasKey($project2->id, $results);
$this->assertEquals(1000, $results[$project1->id]['plan_sum']);
$this->assertEquals('MATCH', $results[$project1->id]['status']);
$this->assertEquals(300, $results[$project2->id]['plan_sum']);
$this->assertEquals('UNDER', $results[$project2->id]['status']);
}
}

View File

@@ -0,0 +1,160 @@
<?php
namespace Tests\Unit;
use App\Models\Allocation;
use App\Models\Project;
use App\Models\ProjectMonthPlan;
use App\Models\Role;
use App\Models\TeamMember;
use App\Services\VarianceCalculator;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class VarianceCalculatorTest extends TestCase
{
use RefreshDatabase;
private VarianceCalculator $calculator;
protected function setUp(): void
{
parent::setUp();
$this->calculator = new VarianceCalculator;
}
// 2.1: Unit test - row variance uses selected month planned value
public function test_row_variance_uses_planned_month_value(): void
{
$project = Project::factory()->create();
// Create month plan for Jan 2026
ProjectMonthPlan::create([
'project_id' => $project->id,
'month' => '2026-01-01',
'planned_hours' => 100,
]);
// Create allocation totaling 120 hours using factory for proper date handling
$allocation = Allocation::factory()->create([
'project_id' => $project->id,
'team_member_id' => null, // untracked
'month' => '2026-01-01',
'allocated_hours' => 120,
]);
$result = $this->calculator->calculateRowVariance($project->id, '2026-01');
$this->assertEquals(120, $result['allocated_total']);
$this->assertEquals(100, $result['planned_month']);
$this->assertEquals(20, $result['variance']);
$this->assertEquals('OVER', $result['status']);
}
// 2.2: Unit test - blank month plan treated as zero for row variance
public function test_blank_month_plan_treated_as_zero(): void
{
$project = Project::factory()->create();
// No month plan created (blank)
// Create allocation of 50 hours
Allocation::factory()->create([
'project_id' => $project->id,
'team_member_id' => null,
'month' => '2026-01-01',
'allocated_hours' => 50,
]);
$result = $this->calculator->calculateRowVariance($project->id, '2026-01');
// Blank plan = 0, so variance = 50 - 0 = 50
$this->assertEquals(50, $result['allocated_total']);
$this->assertEquals(0, $result['planned_month']);
$this->assertEquals(50, $result['variance']);
$this->assertEquals('OVER', $result['status']);
}
// 2.3: Unit test - column variance uses member month capacity
public function test_column_variance_uses_member_capacity(): void
{
$role = Role::factory()->create();
$teamMember = TeamMember::factory()->create(['role_id' => $role->id]);
// Create allocation for member
Allocation::factory()->create([
'project_id' => Project::factory()->create()->id,
'team_member_id' => $teamMember->id,
'month' => '2026-01-01',
'allocated_hours' => 120,
]);
// Mock capacity service - we'll test basic logic here
$mockCapacityService = $this->createMock(\App\Services\CapacityService::class);
$mockCapacityService->method('calculateIndividualCapacity')
->willReturn(['hours' => 160]);
$result = $this->calculator->calculateColumnVariance($teamMember->id, '2026-01', $mockCapacityService);
$this->assertEquals(120, $result['allocated']);
$this->assertEquals(160, $result['capacity']);
$this->assertEquals(-40, $result['variance']);
$this->assertEquals('UNDER', $result['status']);
}
// Additional test: MATCH status when variance is zero
public function test_determine_status_returns_match_when_variance_is_zero(): void
{
$status = $this->calculator->determineStatus(0);
$this->assertEquals('MATCH', $status);
}
// Additional test: get planned hours returns zero for non-existent plan
public function test_get_planned_hours_returns_zero_for_no_plan(): void
{
$project = Project::factory()->create();
$plannedHours = $this->calculator->getPlannedHoursForMonth($project->id, '2026-01');
$this->assertEquals(0, $plannedHours);
}
// Additional test: untracked allocation is included in row variance
public function test_untracked_allocation_included_in_row_variance(): void
{
$project = Project::factory()->create();
// Create month plan
ProjectMonthPlan::create([
'project_id' => $project->id,
'month' => '2026-01-01',
'planned_hours' => 100,
]);
// Create tracked allocation
$role = Role::factory()->create();
$teamMember = TeamMember::factory()->create(['role_id' => $role->id]);
Allocation::factory()->create([
'project_id' => $project->id,
'team_member_id' => $teamMember->id,
'month' => '2026-01-01',
'allocated_hours' => 60,
]);
// Create untracked allocation (team_member_id = null)
Allocation::factory()->create([
'project_id' => $project->id,
'team_member_id' => null,
'month' => '2026-01-01',
'allocated_hours' => 60,
]);
$result = $this->calculator->calculateRowVariance($project->id, '2026-01');
// Total should include both tracked and untracked
$this->assertEquals(120, $result['allocated_total']);
$this->assertEquals(20, $result['variance']);
$this->assertEquals('OVER', $result['status']);
}
}