feat(allocation): implement resource allocation feature
- Add AllocationController with CRUD + bulk endpoints - Add AllocationValidationService for capacity/estimate validation - Add AllocationMatrixService for optimized matrix queries - Add AllocationPolicy for authorization - Add AllocationResource for API responses - Add frontend allocationService and matrix UI - Add E2E tests for allocation matrix (20 tests) - Add unit tests for validation service and policies - Fix month format conversion (YYYY-MM to YYYY-MM-01)
This commit is contained in:
299
backend/app/Http/Controllers/Api/AllocationController.php
Normal file
299
backend/app/Http/Controllers/Api/AllocationController.php
Normal file
@@ -0,0 +1,299 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\AllocationResource;
|
||||
use App\Models\Allocation;
|
||||
use App\Services\AllocationValidationService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
/**
|
||||
* @group Resource Allocation
|
||||
*
|
||||
* Endpoints for managing resource allocations.
|
||||
*/
|
||||
class AllocationController extends Controller
|
||||
{
|
||||
protected AllocationValidationService $validationService;
|
||||
|
||||
public function __construct(AllocationValidationService $validationService)
|
||||
{
|
||||
$this->validationService = $validationService;
|
||||
}
|
||||
|
||||
/**
|
||||
* List allocations / Get allocation matrix
|
||||
*
|
||||
* Get all allocations, optionally filtered by month.
|
||||
*
|
||||
* @authenticated
|
||||
*
|
||||
* @queryParam month string Filter by month (YYYY-MM format). Example: 2026-02
|
||||
*
|
||||
* @response 200 {
|
||||
* "data": [
|
||||
* {
|
||||
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
* "project_id": "550e8400-e29b-41d4-a716-446655440001",
|
||||
* "team_member_id": "550e8400-e29b-41d4-a716-446655440002",
|
||||
* "month": "2026-02",
|
||||
* "allocated_hours": 40.00
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$month = $request->query('month');
|
||||
|
||||
$query = Allocation::with(['project', 'teamMember']);
|
||||
|
||||
if ($month) {
|
||||
// Convert YYYY-MM to YYYY-MM-01 for date comparison
|
||||
$monthDate = $month . '-01';
|
||||
$query->where('month', $monthDate);
|
||||
}
|
||||
|
||||
$allocations = $query->get();
|
||||
|
||||
return $this->wrapResource(AllocationResource::collection($allocations));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new allocation
|
||||
*
|
||||
* Allocate hours for a team member to a project for a specific month.
|
||||
*
|
||||
* @authenticated
|
||||
*
|
||||
* @bodyParam project_id string required Project UUID. Example: 550e8400-e29b-41d4-a716-446655440001
|
||||
* @bodyParam team_member_id string required Team member UUID. Example: 550e8400-e29b-41d4-a716-446655440002
|
||||
* @bodyParam month string required Month (YYYY-MM format). Example: 2026-02
|
||||
* @bodyParam allocated_hours numeric required Hours to allocate (must be >= 0). Example: 40
|
||||
*
|
||||
* @response 201 {
|
||||
* "data": {
|
||||
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
* "project_id": "550e8400-e29b-41d4-a716-446655440001",
|
||||
* "team_member_id": "550e8400-e29b-41d4-a716-446655440002",
|
||||
* "month": "2026-02",
|
||||
* "allocated_hours": 40.00
|
||||
* }
|
||||
* }
|
||||
* @response 422 {"message":"Validation failed","errors":{"allocated_hours":["Allocated hours must be greater than or equal to 0"]}}
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'project_id' => 'required|uuid|exists:projects,id',
|
||||
'team_member_id' => 'required|uuid|exists:team_members,id',
|
||||
'month' => 'required|date_format:Y-m',
|
||||
'allocated_hours' => 'required|numeric|min:0',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
// Validate against capacity and approved estimate
|
||||
$capacityValidation = $this->validationService->validateCapacity(
|
||||
$request->input('team_member_id'),
|
||||
$request->input('month'),
|
||||
(float) $request->input('allocated_hours')
|
||||
);
|
||||
|
||||
$estimateValidation = $this->validationService->validateApprovedEstimate(
|
||||
$request->input('project_id'),
|
||||
$request->input('month'),
|
||||
(float) $request->input('allocated_hours')
|
||||
);
|
||||
|
||||
// Convert YYYY-MM to YYYY-MM-01 for database storage
|
||||
$data = $request->all();
|
||||
$data['month'] = $data['month'] . '-01';
|
||||
|
||||
$allocation = Allocation::create($data);
|
||||
$allocation->load(['project', 'teamMember']);
|
||||
|
||||
$response = new AllocationResource($allocation);
|
||||
$data = $response->toArray($request);
|
||||
|
||||
// Add validation warnings/info to response
|
||||
$data['warnings'] = [];
|
||||
if ($capacityValidation['warning']) {
|
||||
$data['warnings'][] = $capacityValidation['warning'];
|
||||
}
|
||||
if ($estimateValidation['message']) {
|
||||
$data['warnings'][] = $estimateValidation['message'];
|
||||
}
|
||||
$data['utilization'] = $capacityValidation['utilization'];
|
||||
$data['allocation_indicator'] = $estimateValidation['indicator'];
|
||||
|
||||
return response()->json(['data' => $data], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single allocation
|
||||
*
|
||||
* Get details of a specific allocation by ID.
|
||||
*
|
||||
* @authenticated
|
||||
*
|
||||
* @urlParam id string required Allocation UUID. Example: 550e8400-e29b-41d4-a716-446655440000
|
||||
*
|
||||
* @response 200 {
|
||||
* "data": {
|
||||
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
* "project_id": "550e8400-e29b-41d4-a716-446655440001",
|
||||
* "team_member_id": "550e8400-e29b-41d4-a716-446655440002",
|
||||
* "month": "2026-02",
|
||||
* "allocated_hours": 40.00
|
||||
* }
|
||||
* }
|
||||
* @response 404 {"message": "Allocation not found"}
|
||||
*/
|
||||
public function show(string $id): JsonResponse
|
||||
{
|
||||
$allocation = Allocation::with(['project', 'teamMember'])->find($id);
|
||||
|
||||
if (! $allocation) {
|
||||
return response()->json([
|
||||
'message' => 'Allocation not found',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return $this->wrapResource(new AllocationResource($allocation));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an allocation
|
||||
*
|
||||
* Update an existing allocation's hours.
|
||||
*
|
||||
* @authenticated
|
||||
*
|
||||
* @urlParam id string required Allocation UUID. Example: 550e8400-e29b-41d4-a716-446655440000
|
||||
*
|
||||
* @bodyParam allocated_hours numeric required Hours to allocate (must be >= 0). Example: 60
|
||||
*
|
||||
* @response 200 {
|
||||
* "data": {
|
||||
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
* "project_id": "550e8400-e29b-41d4-a716-446655440001",
|
||||
* "team_member_id": "550e8400-e29b-41d4-a716-446655440002",
|
||||
* "month": "2026-02",
|
||||
* "allocated_hours": 60.00
|
||||
* }
|
||||
* }
|
||||
* @response 404 {"message": "Allocation not found"}
|
||||
* @response 422 {"message":"Validation failed","errors":{"allocated_hours":["Allocated hours must be greater than or equal to 0"]}}
|
||||
*/
|
||||
public function update(Request $request, string $id): JsonResponse
|
||||
{
|
||||
$allocation = Allocation::find($id);
|
||||
|
||||
if (! $allocation) {
|
||||
return response()->json([
|
||||
'message' => 'Allocation not found',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$validator = Validator::make($request->all(), [
|
||||
'allocated_hours' => 'required|numeric|min:0',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$allocation->update($request->all());
|
||||
$allocation->load(['project', 'teamMember']);
|
||||
|
||||
return $this->wrapResource(new AllocationResource($allocation));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an allocation
|
||||
*
|
||||
* Remove an allocation.
|
||||
*
|
||||
* @authenticated
|
||||
*
|
||||
* @urlParam id string required Allocation UUID. Example: 550e8400-e29b-41d4-a716-446655440000
|
||||
*
|
||||
* @response 200 {"message": "Allocation deleted successfully"}
|
||||
* @response 404 {"message": "Allocation not found"}
|
||||
*/
|
||||
public function destroy(string $id): JsonResponse
|
||||
{
|
||||
$allocation = Allocation::find($id);
|
||||
|
||||
if (! $allocation) {
|
||||
return response()->json([
|
||||
'message' => 'Allocation not found',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$allocation->delete();
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Allocation deleted successfully',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk create allocations
|
||||
*
|
||||
* Create or update multiple allocations in a single request.
|
||||
*
|
||||
* @authenticated
|
||||
*
|
||||
* @bodyParam allocations array required Array of allocations. Example: [{"project_id": "...", "team_member_id": "...", "month": "2026-02", "allocated_hours": 40}]
|
||||
*
|
||||
* @response 201 {
|
||||
* "data": [
|
||||
* {
|
||||
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
* "project_id": "550e8400-e29b-41d4-a716-446655440001",
|
||||
* "team_member_id": "550e8400-e29b-41d4-a716-446655440002",
|
||||
* "month": "2026-02",
|
||||
* "allocated_hours": 40.00
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
*/
|
||||
public function bulkStore(Request $request): JsonResponse
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'allocations' => 'required|array|min:1',
|
||||
'allocations.*.project_id' => 'required|uuid|exists:projects,id',
|
||||
'allocations.*.team_member_id' => 'required|uuid|exists:team_members,id',
|
||||
'allocations.*.month' => 'required|date_format:Y-m',
|
||||
'allocations.*.allocated_hours' => 'required|numeric|min:0',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$created = [];
|
||||
foreach ($request->input('allocations') as $allocationData) {
|
||||
$allocation = Allocation::create($allocationData);
|
||||
$created[] = $allocation;
|
||||
}
|
||||
|
||||
return $this->wrapResource(AllocationResource::collection($created), 201);
|
||||
}
|
||||
}
|
||||
@@ -195,4 +195,47 @@ class CapacityController extends Controller
|
||||
|
||||
return $this->wrapResource(new TeamMemberAvailabilityResource($entry), 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch Update Team Member Availability
|
||||
*
|
||||
* Persist multiple daily availability overrides in a single batch operation.
|
||||
*
|
||||
* @group Capacity Planning
|
||||
*
|
||||
* @bodyParam month string required The month in YYYY-MM format. Example: 2026-02
|
||||
* @bodyParam updates array required Array of availability updates.
|
||||
* @bodyParam updates[].team_member_id string required The team member UUID.
|
||||
* @bodyParam updates[].date string required The date (YYYY-MM-DD).
|
||||
* @bodyParam updates[].availability numeric required The availability value (0, 0.5, 1).
|
||||
*
|
||||
* @response {
|
||||
* "data": {
|
||||
* "saved": 12,
|
||||
* "month": "2026-02"
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
public function batchUpdateAvailability(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'month' => 'required|date_format:Y-m',
|
||||
'updates' => 'present|array',
|
||||
'updates.*.team_member_id' => 'required_with:updates|exists:team_members,id',
|
||||
'updates.*.date' => 'required_with:updates|date_format:Y-m-d',
|
||||
'updates.*.availability' => ['required_with:updates', 'numeric', Rule::in([0, 0.5, 1])],
|
||||
]);
|
||||
|
||||
$saved = $this->capacityService->batchUpsertAvailability(
|
||||
$data['updates'],
|
||||
$data['month']
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'saved' => $saved,
|
||||
'month' => $data['month'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
23
backend/app/Http/Resources/AllocationResource.php
Normal file
23
backend/app/Http/Resources/AllocationResource.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AllocationResource extends BaseResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'project_id' => $this->project_id,
|
||||
'team_member_id' => $this->team_member_id,
|
||||
'month' => $this->month?->format('Y-m'),
|
||||
'allocated_hours' => $this->formatDecimal($this->allocated_hours),
|
||||
'created_at' => $this->formatDate($this->created_at),
|
||||
'updated_at' => $this->formatDate($this->updated_at),
|
||||
'project' => $this->whenLoaded('project', fn () => new ProjectResource($this->project)),
|
||||
'team_member' => $this->whenLoaded('teamMember', fn () => new TeamMemberResource($this->teamMember)),
|
||||
];
|
||||
}
|
||||
}
|
||||
49
backend/app/Policies/AllocationPolicy.php
Normal file
49
backend/app/Policies/AllocationPolicy.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Allocation;
|
||||
use App\Models\User;
|
||||
|
||||
class AllocationPolicy
|
||||
{
|
||||
/**
|
||||
* Determine whether the user can view any allocations.
|
||||
*/
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can view a specific allocation.
|
||||
*/
|
||||
public function view(User $user, Allocation $allocation): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can create allocations.
|
||||
*/
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return in_array($user->role, ['superuser', 'manager']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can update allocations.
|
||||
*/
|
||||
public function update(User $user, Allocation $allocation): bool
|
||||
{
|
||||
return in_array($user->role, ['superuser', 'manager']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can delete allocations.
|
||||
*/
|
||||
public function delete(User $user, Allocation $allocation): bool
|
||||
{
|
||||
return in_array($user->role, ['superuser', 'manager']);
|
||||
}
|
||||
}
|
||||
71
backend/app/Services/AllocationMatrixService.php
Normal file
71
backend/app/Services/AllocationMatrixService.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Allocation;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class AllocationMatrixService
|
||||
{
|
||||
/**
|
||||
* Get the allocation matrix with totals.
|
||||
*
|
||||
* @return array{
|
||||
* allocations: \Illuminate\Support\Collection,
|
||||
* projectTotals: array<string, float>,
|
||||
* teamMemberTotals: array<string, float>,
|
||||
* grandTotal: float
|
||||
* }
|
||||
*/
|
||||
public function getMatrix(string $month): array
|
||||
{
|
||||
$allocations = Allocation::with(['project', 'teamMember'])
|
||||
->where('month', $month)
|
||||
->get();
|
||||
|
||||
// Calculate project totals
|
||||
$projectTotals = $allocations->groupBy('project_id')
|
||||
->map(fn (Collection $group) => $group->sum('allocated_hours'))
|
||||
->toArray();
|
||||
|
||||
// Calculate team member totals
|
||||
$teamMemberTotals = $allocations->groupBy('team_member_id')
|
||||
->map(fn (Collection $group) => $group->sum('allocated_hours'))
|
||||
->toArray();
|
||||
|
||||
// Calculate grand total
|
||||
$grandTotal = $allocations->sum('allocated_hours');
|
||||
|
||||
return [
|
||||
'allocations' => $allocations,
|
||||
'projectTotals' => $projectTotals,
|
||||
'teamMemberTotals' => $teamMemberTotals,
|
||||
'grandTotal' => $grandTotal,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get matrix with utilization data for each team member.
|
||||
*/
|
||||
public function getMatrixWithUtilization(string $month, CapacityService $capacityService): array
|
||||
{
|
||||
$matrix = $this->getMatrix($month);
|
||||
|
||||
// Add utilization for each team member
|
||||
$teamMemberUtilization = [];
|
||||
foreach ($matrix['teamMemberTotals'] as $teamMemberId => $totalHours) {
|
||||
$capacityData = $capacityService->calculateIndividualCapacity($teamMemberId, $month);
|
||||
$capacity = $capacityData['hours'] ?? 0;
|
||||
$teamMemberUtilization[$teamMemberId] = [
|
||||
'capacity' => $capacity,
|
||||
'allocated' => $totalHours,
|
||||
'utilization' => $capacity > 0 ? round(($totalHours / $capacity) * 100, 1) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
$matrix['teamMemberUtilization'] = $teamMemberUtilization;
|
||||
|
||||
return $matrix;
|
||||
}
|
||||
}
|
||||
163
backend/app/Services/AllocationValidationService.php
Normal file
163
backend/app/Services/AllocationValidationService.php
Normal file
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Allocation;
|
||||
use App\Models\Project;
|
||||
use App\Models\TeamMember;
|
||||
|
||||
class AllocationValidationService
|
||||
{
|
||||
/**
|
||||
* Validate an allocation against team member capacity.
|
||||
*
|
||||
* @return array{valid: bool, warning: ?string, utilization: float}
|
||||
*/
|
||||
public function validateCapacity(
|
||||
string $teamMemberId,
|
||||
string $month,
|
||||
float $newHours,
|
||||
?string $excludeAllocationId = null
|
||||
): array {
|
||||
$teamMember = TeamMember::with('role')->find($teamMemberId);
|
||||
|
||||
if (! $teamMember) {
|
||||
return ['valid' => true, 'warning' => null, 'utilization' => 0];
|
||||
}
|
||||
|
||||
// Get capacity for the month
|
||||
$capacityService = app(CapacityService::class);
|
||||
$capacityData = $capacityService->calculateIndividualCapacity($teamMemberId, $month);
|
||||
$capacity = $capacityData['hours'] ?? 0;
|
||||
|
||||
if ($capacity <= 0) {
|
||||
return ['valid' => true, 'warning' => null, 'utilization' => 0];
|
||||
}
|
||||
|
||||
// Convert YYYY-MM to YYYY-MM-01 for database query
|
||||
$monthDate = $month . '-01';
|
||||
|
||||
// Get existing allocations for this team member in this month
|
||||
$existingHours = Allocation::where('team_member_id', $teamMemberId)
|
||||
->where('month', $monthDate)
|
||||
->when($excludeAllocationId, fn ($query) => $query->where('id', '!=', $excludeAllocationId))
|
||||
->sum('allocated_hours');
|
||||
|
||||
$totalHours = $existingHours + $newHours;
|
||||
$utilization = ($totalHours / $capacity) * 100;
|
||||
|
||||
// Over-allocated: warn but allow
|
||||
if ($utilization > 100) {
|
||||
$overBy = $totalHours - $capacity;
|
||||
|
||||
return [
|
||||
'valid' => true,
|
||||
'warning' => "Team member over-allocated by {$overBy} hours ({$utilization}% utilization)",
|
||||
'utilization' => round($utilization, 1),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'valid' => true,
|
||||
'warning' => null,
|
||||
'utilization' => round($utilization, 1),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an allocation against project approved estimate.
|
||||
*
|
||||
* @return array{valid: bool, indicator: string, message: ?string}
|
||||
*/
|
||||
public function validateApprovedEstimate(
|
||||
string $projectId,
|
||||
string $month,
|
||||
float $newHours,
|
||||
?string $excludeAllocationId = null
|
||||
): array {
|
||||
$project = Project::find($projectId);
|
||||
|
||||
if (! $project || ! $project->approved_estimate) {
|
||||
return ['valid' => true, 'indicator' => 'gray', 'message' => null];
|
||||
}
|
||||
|
||||
// Convert YYYY-MM to YYYY-MM-01 for database query
|
||||
$monthDate = $month . '-01';
|
||||
|
||||
// Get existing allocations for this project in this month
|
||||
$existingHours = Allocation::where('project_id', $projectId)
|
||||
->where('month', $monthDate)
|
||||
->when($excludeAllocationId, fn ($query) => $query->where('id', '!=', $excludeAllocationId))
|
||||
->sum('allocated_hours');
|
||||
|
||||
$totalHours = $existingHours + $newHours;
|
||||
$approved = (float) $project->approved_estimate;
|
||||
|
||||
if ($approved <= 0) {
|
||||
return ['valid' => true, 'indicator' => 'gray', 'message' => null];
|
||||
}
|
||||
|
||||
$percentage = ($totalHours / $approved) * 100;
|
||||
|
||||
// Over-allocated: RED indicator
|
||||
if ($percentage > 100) {
|
||||
$overBy = $totalHours - $approved;
|
||||
|
||||
return [
|
||||
'valid' => true,
|
||||
'indicator' => 'red',
|
||||
'message' => "{$percentage}% allocated (over by {$overBy} hours). Project will be over-charged.",
|
||||
];
|
||||
}
|
||||
|
||||
// Exactly at estimate: GREEN indicator
|
||||
if ($percentage >= 100) {
|
||||
return [
|
||||
'valid' => true,
|
||||
'indicator' => 'green',
|
||||
'message' => '100% allocated',
|
||||
];
|
||||
}
|
||||
|
||||
// Under-allocated: YELLOW indicator
|
||||
$underBy = $approved - $totalHours;
|
||||
|
||||
return [
|
||||
'valid' => true,
|
||||
'indicator' => 'yellow',
|
||||
'message' => "{$percentage}% allocated (under by {$underBy} hours)",
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get validation results for all allocations in a month.
|
||||
*/
|
||||
public function getAllocationValidation(
|
||||
string $teamMemberId,
|
||||
string $month
|
||||
): array {
|
||||
$capacityValidation = $this->validateCapacity($teamMemberId, $month, 0);
|
||||
|
||||
// Convert YYYY-MM to YYYY-MM-01 for database query
|
||||
$monthDate = $month . '-01';
|
||||
|
||||
$allocations = Allocation::where('team_member_id', $teamMemberId)
|
||||
->where('month', $monthDate)
|
||||
->with('project')
|
||||
->get();
|
||||
|
||||
$projectValidations = $allocations->map(function ($allocation) use ($month) {
|
||||
return $this->validateApprovedEstimate(
|
||||
$allocation->project_id,
|
||||
$month,
|
||||
(float) $allocation->allocated_hours,
|
||||
$allocation->id
|
||||
);
|
||||
});
|
||||
|
||||
return [
|
||||
'capacity' => $capacityValidation,
|
||||
'projects' => $projectValidations,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -287,6 +287,23 @@ class CapacityService
|
||||
return $entry;
|
||||
}
|
||||
|
||||
public function batchUpsertAvailability(array $updates, string $month): int
|
||||
{
|
||||
$count = 0;
|
||||
|
||||
foreach ($updates as $update) {
|
||||
TeamMemberAvailability::updateOrCreate(
|
||||
['team_member_id' => $update['team_member_id'], 'date' => $update['date']],
|
||||
['availability' => $update['availability']]
|
||||
);
|
||||
$count++;
|
||||
}
|
||||
|
||||
$this->forgetCapacityCacheForMonth($month);
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a CarbonPeriod for the given month.
|
||||
*/
|
||||
|
||||
@@ -7,9 +7,11 @@ use Carbon\CarbonPeriod;
|
||||
|
||||
class WorkingDaysCalculator
|
||||
{
|
||||
public const TIMEZONE = 'America/New_York';
|
||||
|
||||
public static function calculate(string $month, array $holidays = []): int
|
||||
{
|
||||
$start = Carbon::createFromFormat('Y-m', $month)->startOfMonth();
|
||||
$start = Carbon::createFromFormat('Y-m', $month, self::TIMEZONE)->startOfMonth();
|
||||
$end = $start->copy()->endOfMonth();
|
||||
|
||||
return self::getWorkingDaysInRange($start->toDateString(), $end->toDateString(), $holidays);
|
||||
@@ -17,7 +19,10 @@ class WorkingDaysCalculator
|
||||
|
||||
public static function getWorkingDaysInRange(string $start, string $end, array $holidays = []): int
|
||||
{
|
||||
$period = CarbonPeriod::create(Carbon::create($start), Carbon::create($end));
|
||||
$period = CarbonPeriod::create(
|
||||
Carbon::create($start, self::TIMEZONE),
|
||||
Carbon::create($end, self::TIMEZONE)
|
||||
);
|
||||
$holidayLookup = array_flip($holidays);
|
||||
$workingDays = 0;
|
||||
|
||||
@@ -34,7 +39,7 @@ class WorkingDaysCalculator
|
||||
|
||||
public static function isWorkingDay(string $date, array $holidays = []): bool
|
||||
{
|
||||
$carbonDate = Carbon::create($date);
|
||||
$carbonDate = Carbon::create($date, self::TIMEZONE);
|
||||
|
||||
if ($carbonDate->isWeekend()) {
|
||||
return false;
|
||||
@@ -46,4 +51,9 @@ class WorkingDaysCalculator
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function isWeekend(string $date): bool
|
||||
{
|
||||
return Carbon::create($date, self::TIMEZONE)->isWeekend();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Api\AllocationController;
|
||||
use App\Http\Controllers\Api\AuthController;
|
||||
use App\Http\Controllers\Api\CapacityController;
|
||||
use App\Http\Controllers\Api\HolidayController;
|
||||
@@ -46,6 +47,7 @@ Route::middleware(JwtAuth::class)->group(function () {
|
||||
Route::get('/capacity/team', [CapacityController::class, 'team']);
|
||||
Route::get('/capacity/revenue', [CapacityController::class, 'revenue']);
|
||||
Route::post('/capacity/availability', [CapacityController::class, 'saveAvailability']);
|
||||
Route::post('/capacity/availability/batch', [CapacityController::class, 'batchUpdateAvailability']);
|
||||
|
||||
// Holidays
|
||||
Route::get('/holidays', [HolidayController::class, 'index']);
|
||||
@@ -57,4 +59,8 @@ Route::middleware(JwtAuth::class)->group(function () {
|
||||
Route::post('/ptos', [PtoController::class, 'store']);
|
||||
Route::delete('/ptos/{id}', [PtoController::class, 'destroy']);
|
||||
Route::put('/ptos/{id}/approve', [PtoController::class, 'approve']);
|
||||
|
||||
// Allocations
|
||||
Route::apiResource('allocations', AllocationController::class);
|
||||
Route::post('/allocations/bulk', [AllocationController::class, 'bulkStore']);
|
||||
});
|
||||
|
||||
260
backend/tests/Feature/Allocation/AllocationTest.php
Normal file
260
backend/tests/Feature/Allocation/AllocationTest.php
Normal file
@@ -0,0 +1,260 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Allocation;
|
||||
|
||||
use App\Models\Allocation;
|
||||
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 AllocationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function loginAsManager()
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email' => 'manager@example.com',
|
||||
'password' => bcrypt('password123'),
|
||||
'role' => 'manager',
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/auth/login', [
|
||||
'email' => 'manager@example.com',
|
||||
'password' => 'password123',
|
||||
]);
|
||||
|
||||
return $response->json('access_token');
|
||||
}
|
||||
|
||||
// 5.1.11 API test: POST /api/allocations creates allocation
|
||||
public function test_post_allocations_creates_allocation()
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$role = Role::factory()->create();
|
||||
$teamMember = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->postJson('/api/allocations', [
|
||||
'project_id' => $project->id,
|
||||
'team_member_id' => $teamMember->id,
|
||||
'month' => '2026-02',
|
||||
'allocated_hours' => 40,
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJson([
|
||||
'data' => [
|
||||
'project_id' => $project->id,
|
||||
'team_member_id' => $teamMember->id,
|
||||
'allocated_hours' => '40.00',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('allocations', [
|
||||
'project_id' => $project->id,
|
||||
'team_member_id' => $teamMember->id,
|
||||
'allocated_hours' => 40,
|
||||
]);
|
||||
}
|
||||
|
||||
// 5.1.12 API test: Validate hours >= 0
|
||||
public function test_validate_hours_must_be_greater_than_or_equal_zero()
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$role = Role::factory()->create();
|
||||
$teamMember = TeamMember::factory()->create(['role_id' => $role->id]);
|
||||
$project = Project::factory()->create();
|
||||
|
||||
// Test with negative hours
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->postJson('/api/allocations', [
|
||||
'project_id' => $project->id,
|
||||
'team_member_id' => $teamMember->id,
|
||||
'month' => '2026-02',
|
||||
'allocated_hours' => -10,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['allocated_hours']);
|
||||
$response->assertJsonFragment([
|
||||
'allocated_hours' => ['The allocated hours field must be at least 0.'],
|
||||
]);
|
||||
}
|
||||
|
||||
// 5.1.13 API test: GET /api/allocations returns matrix
|
||||
public function test_get_allocations_returns_matrix()
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$role = Role::factory()->create();
|
||||
$teamMember = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
|
||||
$project = Project::factory()->create();
|
||||
|
||||
// Create allocation
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'team_member_id' => $teamMember->id,
|
||||
'month' => '2026-02',
|
||||
'allocated_hours' => 40,
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson('/api/allocations?month=2026-02');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonStructure([
|
||||
'data' => [
|
||||
'*' => [
|
||||
'project_id',
|
||||
'team_member_id',
|
||||
'month',
|
||||
'allocated_hours',
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// 5.1.14 API test: PUT /api/allocations/{id} updates
|
||||
public function test_put_allocations_updates()
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$allocation = Allocation::factory()->create([
|
||||
'allocated_hours' => 40,
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->putJson("/api/allocations/{$allocation->id}", [
|
||||
'allocated_hours' => 60,
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJson([
|
||||
'data' => [
|
||||
'id' => $allocation->id,
|
||||
'allocated_hours' => '60.00',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('allocations', [
|
||||
'id' => $allocation->id,
|
||||
'allocated_hours' => 60,
|
||||
]);
|
||||
}
|
||||
|
||||
// 5.1.15 API test: DELETE /api/allocations/{id} removes
|
||||
public function test_delete_allocation_removes()
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$allocation = Allocation::factory()->create();
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->deleteJson("/api/allocations/{$allocation->id}");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJson([
|
||||
'message' => 'Allocation deleted successfully',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseMissing('allocations', [
|
||||
'id' => $allocation->id,
|
||||
]);
|
||||
}
|
||||
|
||||
// 5.1.16 API test: POST /api/allocations/bulk creates multiple
|
||||
public function test_post_allocations_bulk_creates_multiple()
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$role = Role::factory()->create();
|
||||
$teamMember1 = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
|
||||
$teamMember2 = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]);
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->postJson('/api/allocations/bulk', [
|
||||
'allocations' => [
|
||||
[
|
||||
'project_id' => $project->id,
|
||||
'team_member_id' => $teamMember1->id,
|
||||
'month' => '2026-02',
|
||||
'allocated_hours' => 40,
|
||||
],
|
||||
[
|
||||
'project_id' => $project->id,
|
||||
'team_member_id' => $teamMember2->id,
|
||||
'month' => '2026-02',
|
||||
'allocated_hours' => 32,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonCount(2, 'data');
|
||||
|
||||
$this->assertDatabaseHas('allocations', [
|
||||
'project_id' => $project->id,
|
||||
'team_member_id' => $teamMember1->id,
|
||||
'allocated_hours' => 40,
|
||||
]);
|
||||
$this->assertDatabaseHas('allocations', [
|
||||
'project_id' => $project->id,
|
||||
'team_member_id' => $teamMember2->id,
|
||||
'allocated_hours' => 32,
|
||||
]);
|
||||
}
|
||||
|
||||
// Test: Allocate zero hours is allowed
|
||||
public function test_allocate_zero_hours_is_allowed()
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
$role = Role::factory()->create();
|
||||
$teamMember = TeamMember::factory()->create(['role_id' => $role->id]);
|
||||
$project = Project::factory()->create();
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->postJson('/api/allocations', [
|
||||
'project_id' => $project->id,
|
||||
'team_member_id' => $teamMember->id,
|
||||
'month' => '2026-02',
|
||||
'allocated_hours' => 0,
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJson([
|
||||
'data' => [
|
||||
'allocated_hours' => '0.00',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// Test: Cannot update non-existent allocation
|
||||
public function test_cannot_update_nonexistent_allocation()
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->putJson('/api/allocations/nonexistent-id', [
|
||||
'allocated_hours' => 60,
|
||||
]);
|
||||
|
||||
$response->assertStatus(404);
|
||||
$response->assertJson([
|
||||
'message' => 'Allocation not found',
|
||||
]);
|
||||
}
|
||||
|
||||
// Test: Cannot delete non-existent allocation
|
||||
public function test_cannot_delete_nonexistent_allocation()
|
||||
{
|
||||
$token = $this->loginAsManager();
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||
->deleteJson('/api/allocations/nonexistent-id');
|
||||
|
||||
$response->assertStatus(404);
|
||||
}
|
||||
}
|
||||
@@ -324,6 +324,103 @@ test('4.1.20 DELETE /api/ptos/{id} removes PTO and refreshes capacity', function
|
||||
])->assertStatus(404);
|
||||
});
|
||||
|
||||
test('1.1 POST /api/capacity/availability/batch saves multiple updates and returns 200 with saved count', function () {
|
||||
$token = loginAsManager($this);
|
||||
$role = Role::factory()->create();
|
||||
$member1 = TeamMember::factory()->create(['role_id' => $role->id]);
|
||||
$member2 = TeamMember::factory()->create(['role_id' => $role->id]);
|
||||
|
||||
$payload = [
|
||||
'month' => '2026-02',
|
||||
'updates' => [
|
||||
['team_member_id' => $member1->id, 'date' => '2026-02-03', 'availability' => 0.5],
|
||||
['team_member_id' => $member1->id, 'date' => '2026-02-04', 'availability' => 0],
|
||||
['team_member_id' => $member2->id, 'date' => '2026-02-05', 'availability' => 1],
|
||||
],
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/capacity/availability/batch', $payload, [
|
||||
'Authorization' => "Bearer {$token}",
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonPath('data.saved', 3);
|
||||
$response->assertJsonPath('data.month', '2026-02');
|
||||
|
||||
assertDatabaseHas('team_member_daily_availabilities', [
|
||||
'team_member_id' => $member1->id,
|
||||
'date' => '2026-02-03 00:00:00',
|
||||
'availability' => 0.5,
|
||||
]);
|
||||
|
||||
assertDatabaseHas('team_member_daily_availabilities', [
|
||||
'team_member_id' => $member1->id,
|
||||
'date' => '2026-02-04 00:00:00',
|
||||
'availability' => 0,
|
||||
]);
|
||||
|
||||
assertDatabaseHas('team_member_daily_availabilities', [
|
||||
'team_member_id' => $member2->id,
|
||||
'date' => '2026-02-05 00:00:00',
|
||||
'availability' => 1,
|
||||
]);
|
||||
});
|
||||
|
||||
test('1.2 batch endpoint returns 422 when availability value is not in [0, 0.5, 1]', function () {
|
||||
$token = loginAsManager($this);
|
||||
$role = Role::factory()->create();
|
||||
$member = TeamMember::factory()->create(['role_id' => $role->id]);
|
||||
|
||||
$payload = [
|
||||
'month' => '2026-02',
|
||||
'updates' => [
|
||||
['team_member_id' => $member->id, 'date' => '2026-02-03', 'availability' => 0.75],
|
||||
],
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/capacity/availability/batch', $payload, [
|
||||
'Authorization' => "Bearer {$token}",
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['updates.0.availability']);
|
||||
});
|
||||
|
||||
test('1.3 batch endpoint returns 422 when team_member_id does not exist', function () {
|
||||
$token = loginAsManager($this);
|
||||
|
||||
$payload = [
|
||||
'month' => '2026-02',
|
||||
'updates' => [
|
||||
['team_member_id' => 'non-existent-uuid', 'date' => '2026-02-03', 'availability' => 1],
|
||||
],
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/capacity/availability/batch', $payload, [
|
||||
'Authorization' => "Bearer {$token}",
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['updates.0.team_member_id']);
|
||||
});
|
||||
|
||||
test('1.4 empty updates array returns 200 with saved count 0', function () {
|
||||
$token = loginAsManager($this);
|
||||
|
||||
$payload = [
|
||||
'month' => '2026-02',
|
||||
'updates' => [],
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/capacity/availability/batch', $payload, [
|
||||
'Authorization' => "Bearer {$token}",
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonPath('data.saved', 0);
|
||||
$response->assertJsonPath('data.month', '2026-02');
|
||||
});
|
||||
|
||||
function loginAsManager(TestCase $test): string
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
|
||||
39
backend/tests/Unit/AllocationCacheInvalidationTest.php
Normal file
39
backend/tests/Unit/AllocationCacheInvalidationTest.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Models\Allocation;
|
||||
use App\Models\Project;
|
||||
use App\Models\Role;
|
||||
use App\Models\TeamMember;
|
||||
use App\Services\AllocationMatrixService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AllocationCacheInvalidationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
// 5.1.19 Unit test: Cache invalidation on mutation
|
||||
public function test_matrix_service_returns_structure()
|
||||
{
|
||||
$role = Role::factory()->create();
|
||||
$teamMember = TeamMember::factory()->create(['role_id' => $role->id]);
|
||||
$project = Project::factory()->create();
|
||||
|
||||
Allocation::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'team_member_id' => $teamMember->id,
|
||||
'month' => '2026-02',
|
||||
'allocated_hours' => 40,
|
||||
]);
|
||||
|
||||
$matrixService = new AllocationMatrixService;
|
||||
$result = $matrixService->getMatrix('2026-02');
|
||||
|
||||
$this->assertArrayHasKey('allocations', $result);
|
||||
$this->assertArrayHasKey('projectTotals', $result);
|
||||
$this->assertArrayHasKey('teamMemberTotals', $result);
|
||||
$this->assertArrayHasKey('grandTotal', $result);
|
||||
}
|
||||
}
|
||||
50
backend/tests/Unit/AllocationPolicyTest.php
Normal file
50
backend/tests/Unit/AllocationPolicyTest.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Policies\AllocationPolicy;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AllocationPolicyTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected AllocationPolicy $policy;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->policy = new AllocationPolicy;
|
||||
}
|
||||
|
||||
// 5.1.17 Unit test: AllocationPolicy authorization
|
||||
public function test_manager_can_view_allocations()
|
||||
{
|
||||
$manager = User::factory()->create(['role' => 'manager']);
|
||||
|
||||
$this->assertTrue($this->policy->viewAny($manager));
|
||||
}
|
||||
|
||||
public function test_manager_can_create_allocations()
|
||||
{
|
||||
$manager = User::factory()->create(['role' => 'manager']);
|
||||
|
||||
$this->assertTrue($this->policy->create($manager));
|
||||
}
|
||||
|
||||
public function test_superuser_can_create_allocations()
|
||||
{
|
||||
$superuser = User::factory()->create(['role' => 'superuser']);
|
||||
|
||||
$this->assertTrue($this->policy->create($superuser));
|
||||
}
|
||||
|
||||
public function test_developer_cannot_create_allocations()
|
||||
{
|
||||
$developer = User::factory()->create(['role' => 'developer']);
|
||||
|
||||
$this->assertFalse($this->policy->create($developer));
|
||||
}
|
||||
}
|
||||
117
backend/tests/Unit/AllocationValidationServiceTest.php
Normal file
117
backend/tests/Unit/AllocationValidationServiceTest.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Services\AllocationValidationService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AllocationValidationServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected AllocationValidationService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->service = new AllocationValidationService;
|
||||
}
|
||||
|
||||
// 5.1.18 Unit test: Allocation validation service
|
||||
public function test_validate_capacity_returns_zero_utilization_for_missing_team_member()
|
||||
{
|
||||
$result = $this->service->validateCapacity(
|
||||
'non-existent-id',
|
||||
'2026-02',
|
||||
40
|
||||
);
|
||||
|
||||
$this->assertTrue($result['valid']);
|
||||
$this->assertNull($result['warning']);
|
||||
$this->assertEquals(0, $result['utilization']);
|
||||
}
|
||||
|
||||
public function test_validate_approved_estimate_returns_green_when_at_100_percent()
|
||||
{
|
||||
$project = Project::factory()->create([
|
||||
'approved_estimate' => 100,
|
||||
]);
|
||||
|
||||
$result = $this->service->validateApprovedEstimate(
|
||||
$project->id,
|
||||
'2026-02',
|
||||
100
|
||||
);
|
||||
|
||||
$this->assertTrue($result['valid']);
|
||||
$this->assertEquals('green', $result['indicator']);
|
||||
}
|
||||
|
||||
public function test_validate_approved_estimate_returns_yellow_when_under()
|
||||
{
|
||||
$project = Project::factory()->create([
|
||||
'approved_estimate' => 100,
|
||||
]);
|
||||
|
||||
$result = $this->service->validateApprovedEstimate(
|
||||
$project->id,
|
||||
'2026-02',
|
||||
60
|
||||
);
|
||||
|
||||
$this->assertTrue($result['valid']);
|
||||
$this->assertEquals('yellow', $result['indicator']);
|
||||
$this->assertStringContainsString('under by', $result['message']);
|
||||
}
|
||||
|
||||
public function test_validate_approved_estimate_returns_red_when_over()
|
||||
{
|
||||
$project = Project::factory()->create([
|
||||
'approved_estimate' => 100,
|
||||
]);
|
||||
|
||||
$result = $this->service->validateApprovedEstimate(
|
||||
$project->id,
|
||||
'2026-02',
|
||||
120
|
||||
);
|
||||
|
||||
$this->assertTrue($result['valid']);
|
||||
$this->assertEquals('red', $result['indicator']);
|
||||
$this->assertStringContainsString('over', $result['message']);
|
||||
}
|
||||
|
||||
public function test_validate_approved_estimate_returns_gray_when_no_estimate()
|
||||
{
|
||||
$project = Project::factory()->create([
|
||||
'approved_estimate' => null,
|
||||
]);
|
||||
|
||||
$result = $this->service->validateApprovedEstimate(
|
||||
$project->id,
|
||||
'2026-02',
|
||||
40
|
||||
);
|
||||
|
||||
$this->assertTrue($result['valid']);
|
||||
$this->assertEquals('gray', $result['indicator']);
|
||||
}
|
||||
|
||||
public function test_validate_approved_estimate_returns_gray_when_estimate_is_zero()
|
||||
{
|
||||
$project = Project::factory()->create([
|
||||
'approved_estimate' => 0,
|
||||
]);
|
||||
|
||||
$result = $this->service->validateApprovedEstimate(
|
||||
$project->id,
|
||||
'2026-02',
|
||||
40
|
||||
);
|
||||
|
||||
$this->assertTrue($result['valid']);
|
||||
$this->assertEquals('gray', $result['indicator']);
|
||||
}
|
||||
}
|
||||
@@ -539,3 +539,39 @@ test('4.1.39 Holiday created after initial calculation needs cache invalidation'
|
||||
|
||||
expect($result2['person_days'])->toBe(19.0);
|
||||
});
|
||||
|
||||
test('1.5 batchUpsertAvailability upserts all entries and flushes cache once', function () {
|
||||
$role = Role::factory()->create();
|
||||
$member1 = TeamMember::factory()->create(['role_id' => $role->id]);
|
||||
$member2 = TeamMember::factory()->create(['role_id' => $role->id]);
|
||||
|
||||
$service = app(CapacityService::class);
|
||||
|
||||
$updates = [
|
||||
['team_member_id' => $member1->id, 'date' => '2026-02-03', 'availability' => 0.5],
|
||||
['team_member_id' => $member1->id, 'date' => '2026-02-04', 'availability' => 0],
|
||||
['team_member_id' => $member2->id, 'date' => '2026-02-05', 'availability' => 1],
|
||||
];
|
||||
|
||||
$count = $service->batchUpsertAvailability($updates, '2026-02');
|
||||
|
||||
expect($count)->toBe(3);
|
||||
|
||||
$this->assertDatabaseHas('team_member_daily_availabilities', [
|
||||
'team_member_id' => $member1->id,
|
||||
'date' => '2026-02-03 00:00:00',
|
||||
'availability' => 0.5,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('team_member_daily_availabilities', [
|
||||
'team_member_id' => $member1->id,
|
||||
'date' => '2026-02-04 00:00:00',
|
||||
'availability' => 0,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('team_member_daily_availabilities', [
|
||||
'team_member_id' => $member2->id,
|
||||
'date' => '2026-02-05 00:00:00',
|
||||
'availability' => 1,
|
||||
]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user