Update controllers and services for allocation fidelity: - AllocationController: variance data in responses, bulk operations - ProjectController: include plan data in responses - ProjectMonthPlanController: planning grid API - AllocationMatrixService: support untracked allocations - ProjectResource/TeamMemberResource: include reconciliation data Improved test coverage for allocation flows.
409 lines
14 KiB
PHP
409 lines
14 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\AllocationMatrixService;
|
|
use App\Services\AllocationValidationService;
|
|
use App\Services\CapacityService;
|
|
use App\Services\VarianceCalculator;
|
|
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,
|
|
protected VarianceCalculator $varianceCalculator,
|
|
protected CapacityService $capacityService,
|
|
protected AllocationMatrixService $allocationMatrixService
|
|
) {
|
|
$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,
|
|
* "is_untracked": false,
|
|
* "row_variance": { "allocated_total": 80, "planned_month": 100, "variance": -20, "status": "UNDER" },
|
|
* "column_variance": { "allocated": 80, "capacity": 160, "variance": -80, "status": "UNDER" }
|
|
* }
|
|
* ]
|
|
* }
|
|
*/
|
|
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 variance indicators for each allocation if month is specified
|
|
if ($month) {
|
|
$allocations->each(function ($allocation) use ($month) {
|
|
// Add untracked flag
|
|
$allocation->is_untracked = $allocation->team_member_id === null;
|
|
|
|
// Add row variance (project level)
|
|
$rowVariance = $this->varianceCalculator->calculateRowVariance(
|
|
$allocation->project_id,
|
|
$month
|
|
);
|
|
$allocation->row_variance = $rowVariance;
|
|
|
|
// Add column variance only for tracked allocations
|
|
if ($allocation->team_member_id !== null) {
|
|
$columnVariance = $this->varianceCalculator->calculateColumnVariance(
|
|
$allocation->team_member_id,
|
|
$month,
|
|
$this->capacityService
|
|
);
|
|
$allocation->column_variance = $columnVariance;
|
|
} else {
|
|
$allocation->column_variance = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
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 optional Team member UUID (null for untracked). 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);
|
|
$responseData = $response->toArray($request);
|
|
|
|
// Add variance data
|
|
$month = $request->input('month');
|
|
$responseData['is_untracked'] = $teamMemberId === null;
|
|
|
|
// Row variance (project level)
|
|
$rowVariance = $this->varianceCalculator->calculateRowVariance(
|
|
$allocation->project_id,
|
|
$month
|
|
);
|
|
$responseData['row_variance'] = $rowVariance;
|
|
|
|
// Column variance (member level) - only for tracked allocations
|
|
if ($teamMemberId) {
|
|
$columnVariance = $this->varianceCalculator->calculateColumnVariance(
|
|
$teamMemberId,
|
|
$month,
|
|
$this->capacityService
|
|
);
|
|
$responseData['column_variance'] = $columnVariance;
|
|
$responseData['utilization'] = $capacityValidation['utilization'];
|
|
} else {
|
|
$responseData['column_variance'] = null;
|
|
}
|
|
|
|
// Add validation warnings/info to response
|
|
$responseData['warnings'] = [];
|
|
if ($capacityValidation['warning']) {
|
|
$responseData['warnings'][] = $capacityValidation['warning'];
|
|
}
|
|
if ($estimateValidation['message']) {
|
|
$responseData['warnings'][] = $estimateValidation['message'];
|
|
}
|
|
|
|
return response()->json(['data' => $responseData], 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 multiple allocations in a single request.
|
|
* Supports partial success - valid items are created, invalid items are reported.
|
|
*
|
|
* @authenticated
|
|
*
|
|
* @bodyParam allocations array required Array of allocations. Example: [{"project_id": "...", "team_member_id": "...", "month": "2026-02", "allocated_hours": 40}]
|
|
*
|
|
* @response 201 {
|
|
* "data": [
|
|
* { "index": 0, "id": "...", "status": "created" }
|
|
* ],
|
|
* "failed": [
|
|
* { "index": 1, "errors": { "allocated_hours": ["..."] } }
|
|
* ],
|
|
* "summary": { "created": 1, "failed": 1 }
|
|
* }
|
|
*/
|
|
public function bulkStore(Request $request): JsonResponse
|
|
{
|
|
// Basic validation only - individual item validation happens in the loop
|
|
// This allows partial success even if some items have invalid data
|
|
$validator = Validator::make($request->all(), [
|
|
'allocations' => 'required|array|min:1',
|
|
'allocations.*.project_id' => 'required',
|
|
'allocations.*.month' => 'required',
|
|
'allocations.*.allocated_hours' => 'required|numeric',
|
|
]);
|
|
|
|
if ($validator->fails()) {
|
|
return response()->json([
|
|
'message' => 'Validation failed',
|
|
'errors' => $validator->errors(),
|
|
], 422);
|
|
}
|
|
|
|
$data = [];
|
|
$failed = [];
|
|
$created = 0;
|
|
$failedCount = 0;
|
|
|
|
foreach ($request->input('allocations') as $index => $allocationData) {
|
|
// Convert YYYY-MM to YYYY-MM-01 for database storage
|
|
$allocationData['month'] = $allocationData['month'].'-01';
|
|
|
|
// Validate each item individually (for partial bulk success)
|
|
$itemValidator = Validator::make($allocationData, [
|
|
'project_id' => 'required|uuid|exists:projects,id',
|
|
'team_member_id' => 'nullable|uuid|exists:team_members,id',
|
|
'month' => 'required|date',
|
|
'allocated_hours' => 'required|numeric|min:0',
|
|
]);
|
|
|
|
if ($itemValidator->fails()) {
|
|
$failed[] = [
|
|
'index' => $index,
|
|
'errors' => $itemValidator->errors()->toArray(),
|
|
];
|
|
$failedCount++;
|
|
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
$allocation = Allocation::create($allocationData);
|
|
$data[] = [
|
|
'index' => $index,
|
|
'id' => $allocation->id,
|
|
'status' => 'created',
|
|
];
|
|
$created++;
|
|
} catch (\Exception $e) {
|
|
$failed[] = [
|
|
'index' => $index,
|
|
'errors' => ['allocation' => ['Failed to create allocation: '.$e->getMessage()]],
|
|
];
|
|
$failedCount++;
|
|
}
|
|
}
|
|
|
|
return response()->json([
|
|
'data' => $data,
|
|
'failed' => $failed,
|
|
'summary' => [
|
|
'created' => $created,
|
|
'failed' => $failedCount,
|
|
],
|
|
], 201);
|
|
}
|
|
}
|