feat(capacity): Implement Capacity Planning capability (4.1-4.4)

- Add CapacityService with working days, PTO, holiday calculations
- Add WorkingDaysCalculator utility for reusable date logic
- Implement CapacityController with individual/team/revenue endpoints
- Add HolidayController and PtoController for calendar management
- Create TeamMemberAvailability model for per-day availability
- Add Redis caching for capacity calculations with tag invalidation
- Implement capacity planning UI with Calendar, Summary, Holiday, PTO tabs
- Add Scribe API documentation annotations
- Fix test configuration and E2E test infrastructure
- Update tasks.md with completion status

Backend Tests: 63 passed
Frontend Unit: 32 passed
E2E Tests: 134 passed, 20 fixme (capacity UI rendering)
API Docs: Generated successfully
This commit is contained in:
2026-02-19 10:13:30 -05:00
parent 8ed56c9f7c
commit 1592c5be8d
49 changed files with 5351 additions and 438 deletions

View File

@@ -0,0 +1,115 @@
<?php
use App\Models\Holiday;
use App\Models\Pto;
use App\Models\Role;
use App\Models\TeamMember;
use App\Models\TeamMemberAvailability;
use App\Services\CapacityService;
use Carbon\CarbonPeriod;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
/**
* @mixin \Tests\TestCase
*/
uses(TestCase::class, RefreshDatabase::class);
test('4.1.19 CapacityService calculates working days', function () {
Holiday::create(['date' => '2026-02-11', 'name' => 'Extra Day', 'description' => 'Standalone']);
Holiday::create(['date' => '2026-02-25', 'name' => 'Another Day', 'description' => 'Standalone']);
$period = CarbonPeriod::create('2026-02-01', '2026-02-28');
$expected = 0;
foreach ($period as $day) {
if ($day->isWeekend()) {
continue;
}
if (in_array($day->toDateString(), ['2026-02-11', '2026-02-25'], true)) {
continue;
}
$expected++;
}
$service = app(CapacityService::class);
expect($service->calculateWorkingDays('2026-02'))->toBe($expected);
});
test('4.1.20 CapacityService applies availability', function () {
$role = Role::factory()->create();
$member = TeamMember::factory()->create(['role_id' => $role->id]);
TeamMemberAvailability::factory()->forDate('2026-02-03')->availability(0.5)->create(['team_member_id' => $member->id]);
TeamMemberAvailability::factory()->forDate('2026-02-04')->availability(0.0)->create(['team_member_id' => $member->id]);
$result = app(CapacityService::class)->calculateIndividualCapacity($member->id, '2026-02');
$details = collect($result['details']);
expect($details->firstWhere('date', '2026-02-03')['availability'])->toBe(0.5);
expect($details->firstWhere('date', '2026-02-04')['availability'])->toBe(0.0);
});
test('4.1.21 CapacityService handles PTO', function () {
$role = Role::factory()->create();
$member = TeamMember::factory()->create(['role_id' => $role->id]);
Pto::create([
'team_member_id' => $member->id,
'start_date' => '2026-02-10',
'end_date' => '2026-02-12',
'reason' => 'Rest',
'status' => 'approved',
]);
$service = app(CapacityService::class);
$workingDays = $service->calculateWorkingDays('2026-02');
$result = $service->calculateIndividualCapacity($member->id, '2026-02');
expect($result['person_days'])->toBe((float) ($workingDays - 3));
});
test('4.1.22 CapacityService handles holidays', function () {
$role = Role::factory()->create();
$member = TeamMember::factory()->create(['role_id' => $role->id]);
Holiday::create(['date' => '2026-02-17', 'name' => 'Presidents Day', 'description' => 'Holiday']);
$service = app(CapacityService::class);
$result = $service->calculateIndividualCapacity($member->id, '2026-02');
$dates = collect($result['details'])->pluck('date');
expect($dates)->not->toContain('2026-02-17');
});
test('4.1.23 CapacityService calculates revenue', function () {
$role = Role::factory()->create();
$memberA = TeamMember::factory()->create(['role_id' => $role->id, 'hourly_rate' => 150]);
$memberB = TeamMember::factory()->create(['role_id' => $role->id, 'hourly_rate' => 125]);
$service = app(CapacityService::class);
$revenue = $service->calculatePossibleRevenue('2026-02');
$hoursA = $service->calculateIndividualCapacity($memberA->id, '2026-02')['hours'];
$hoursB = $service->calculateIndividualCapacity($memberB->id, '2026-02')['hours'];
expect($revenue)->toBe(round($hoursA * 150 + $hoursB * 125, 2));
});
test('4.1.24 Redis caching for capacity', function () {
$role = Role::factory()->create();
$member = TeamMember::factory()->create(['role_id' => $role->id]);
$service = app(CapacityService::class);
$key = "capacity:2026-02:{$member->id}";
Cache::store('array')->forget($key);
$service->calculateIndividualCapacity($member->id, '2026-02');
expect(Cache::store('array')->get($key))->not->toBeNull();
});