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)),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user