Files
headroom/backend/app/Http/Controllers/Api/AllocationController.php
Santhosh Janardhanan b7bbfb45c0 docs(openspec): add reporting API contract documentation
Add comprehensive API documentation for the reporting endpoint:
- Request/response structure
- View type inference (did/is/will)
- Blank vs explicit zero semantics
- Status values and error responses

Related to enhanced-allocation change.
2026-03-08 18:22:27 -04:00

347 lines
11 KiB
PHP

<?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();
// Compute allocation_indicator for each allocation based on project totals
$allocations->each(function ($allocation) {
$allocation->allocation_indicator = $this->computeAllocationIndicator(
$allocation->project_id,
$allocation->month
);
});
return $this->wrapResource(AllocationResource::collection($allocations));
}
/**
* Compute allocation indicator based on project totals.
*/
private function computeAllocationIndicator(string $projectId, string $month): string
{
// Convert month to date format if needed
$monthDate = strlen($month) === 7 ? $month . '-01' : $month;
// Get total allocated for this project in this month
$totalAllocated = Allocation::where('project_id', $projectId)
->where('month', $monthDate)
->sum('allocated_hours');
// Get project approved estimate
$project = \App\Models\Project::find($projectId);
$approvedEstimate = $project?->approved_estimate;
// Handle no estimate
if (! $approvedEstimate || $approvedEstimate <= 0) {
return 'gray';
}
$percentage = ($totalAllocated / $approvedEstimate) * 100;
// Check in correct order: over first, then at capacity, then under
if ($percentage > 100) {
return 'red';
} elseif ($percentage >= 100) {
return 'green';
} else {
return 'yellow';
}
}
/**
* 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' => 'nullable|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 (skip for untracked)
$teamMemberId = $request->input('team_member_id');
$capacityValidation = ['valid' => true, 'warning' => null, 'utilization' => 0];
if ($teamMemberId) {
$capacityValidation = $this->validationService->validateCapacity(
$teamMemberId,
$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);
}
}