feat(backend): enhance allocation and project management
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.
This commit is contained in:
@@ -5,7 +5,10 @@ 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;
|
||||
@@ -19,8 +22,12 @@ class AllocationController extends Controller
|
||||
{
|
||||
protected AllocationValidationService $validationService;
|
||||
|
||||
public function __construct(AllocationValidationService $validationService)
|
||||
{
|
||||
public function __construct(
|
||||
AllocationValidationService $validationService,
|
||||
protected VarianceCalculator $varianceCalculator,
|
||||
protected CapacityService $capacityService,
|
||||
protected AllocationMatrixService $allocationMatrixService
|
||||
) {
|
||||
$this->validationService = $validationService;
|
||||
}
|
||||
|
||||
@@ -40,7 +47,10 @@ class AllocationController extends Controller
|
||||
* "project_id": "550e8400-e29b-41d4-a716-446655440001",
|
||||
* "team_member_id": "550e8400-e29b-41d4-a716-446655440002",
|
||||
* "month": "2026-02",
|
||||
* "allocated_hours": 40.00
|
||||
* "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" }
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
@@ -53,57 +63,42 @@ class AllocationController extends Controller
|
||||
|
||||
if ($month) {
|
||||
// Convert YYYY-MM to YYYY-MM-01 for date comparison
|
||||
$monthDate = $month . '-01';
|
||||
$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
|
||||
);
|
||||
});
|
||||
// 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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
@@ -112,7 +107,7 @@ class AllocationController extends Controller
|
||||
* @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 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
|
||||
*
|
||||
@@ -146,7 +141,7 @@ class AllocationController extends Controller
|
||||
// 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,
|
||||
@@ -163,26 +158,48 @@ class AllocationController extends Controller
|
||||
|
||||
// Convert YYYY-MM to YYYY-MM-01 for database storage
|
||||
$data = $request->all();
|
||||
$data['month'] = $data['month'] . '-01';
|
||||
$data['month'] = $data['month'].'-01';
|
||||
|
||||
$allocation = Allocation::create($data);
|
||||
$allocation->load(['project', 'teamMember']);
|
||||
|
||||
$response = new AllocationResource($allocation);
|
||||
$data = $response->toArray($request);
|
||||
$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
|
||||
$data['warnings'] = [];
|
||||
$responseData['warnings'] = [];
|
||||
if ($capacityValidation['warning']) {
|
||||
$data['warnings'][] = $capacityValidation['warning'];
|
||||
$responseData['warnings'][] = $capacityValidation['warning'];
|
||||
}
|
||||
if ($estimateValidation['message']) {
|
||||
$data['warnings'][] = $estimateValidation['message'];
|
||||
$responseData['warnings'][] = $estimateValidation['message'];
|
||||
}
|
||||
$data['utilization'] = $capacityValidation['utilization'];
|
||||
$data['allocation_indicator'] = $estimateValidation['indicator'];
|
||||
|
||||
return response()->json(['data' => $data], 201);
|
||||
return response()->json(['data' => $responseData], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -300,7 +317,8 @@ class AllocationController extends Controller
|
||||
/**
|
||||
* Bulk create allocations
|
||||
*
|
||||
* Create or update multiple allocations in a single request.
|
||||
* Create multiple allocations in a single request.
|
||||
* Supports partial success - valid items are created, invalid items are reported.
|
||||
*
|
||||
* @authenticated
|
||||
*
|
||||
@@ -308,24 +326,23 @@ class AllocationController extends Controller
|
||||
*
|
||||
* @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
|
||||
* }
|
||||
* ]
|
||||
* { "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|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',
|
||||
'allocations.*.project_id' => 'required',
|
||||
'allocations.*.month' => 'required',
|
||||
'allocations.*.allocated_hours' => 'required|numeric',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
@@ -335,12 +352,57 @@ class AllocationController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
$created = [];
|
||||
foreach ($request->input('allocations') as $allocationData) {
|
||||
$allocation = Allocation::create($allocationData);
|
||||
$created[] = $allocation;
|
||||
$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 $this->wrapResource(AllocationResource::collection($created), 201);
|
||||
return response()->json([
|
||||
'data' => $data,
|
||||
'failed' => $failed,
|
||||
'summary' => [
|
||||
'created' => $created,
|
||||
'failed' => $failedCount,
|
||||
],
|
||||
], 201);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,7 +177,7 @@ class ProjectController extends Controller
|
||||
|
||||
try {
|
||||
$project = $this->projectService->update($project, $request->only([
|
||||
'code', 'title', 'type_id',
|
||||
'code', 'title', 'type_id', 'status_id', 'approved_estimate',
|
||||
]));
|
||||
|
||||
return $this->wrapResource(new ProjectResource($project));
|
||||
|
||||
@@ -3,14 +3,18 @@
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\ProjectMonthPlanResource;
|
||||
use App\Models\ProjectMonthPlan;
|
||||
use App\Services\ReconciliationCalculator;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
class ProjectMonthPlanController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private ReconciliationCalculator $reconciliationCalculator
|
||||
) {}
|
||||
|
||||
/**
|
||||
* GET /api/project-month-plans?year=2026
|
||||
* Returns month-plan grid payload by project/month for the year.
|
||||
@@ -18,7 +22,7 @@ class ProjectMonthPlanController extends Controller
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$year = $request->query('year', date('Y'));
|
||||
|
||||
|
||||
$startDate = "{$year}-01-01";
|
||||
$endDate = "{$year}-12-01";
|
||||
|
||||
@@ -27,19 +31,28 @@ class ProjectMonthPlanController extends Controller
|
||||
->get()
|
||||
->groupBy('project_id');
|
||||
|
||||
// Get all active projects for the year
|
||||
$projects = \App\Models\Project::where('active', true)->get();
|
||||
// Get all projects for the year with status relationship
|
||||
$projects = \App\Models\Project::with('status')->get();
|
||||
|
||||
// Build grid payload
|
||||
$data = $projects->map(function ($project) use ($plans, $year) {
|
||||
$projectPlans = $plans->get($project->id, collect());
|
||||
|
||||
$planByMonth = $projectPlans->mapWithKeys(function ($plan) {
|
||||
$monthKey = $plan->month?->format('Y-m-01');
|
||||
|
||||
if ($monthKey === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [$monthKey => $plan];
|
||||
});
|
||||
|
||||
$months = [];
|
||||
for ($month = 1; $month <= 12; $month++) {
|
||||
$monthDate = sprintf('%04d-%02d-01', $year, $month);
|
||||
$plan = $projectPlans->firstWhere('month', $monthDate);
|
||||
|
||||
$months[$monthDate] = $plan
|
||||
$plan = $planByMonth->get($monthDate);
|
||||
|
||||
$months[$monthDate] = $plan
|
||||
? [
|
||||
'id' => $plan->id,
|
||||
'planned_hours' => $plan->planned_hours,
|
||||
@@ -50,32 +63,22 @@ class ProjectMonthPlanController extends Controller
|
||||
|
||||
return [
|
||||
'project_id' => $project->id,
|
||||
'project_code' => $project->code,
|
||||
'project_name' => $project->title,
|
||||
'project_status' => $project->status?->name,
|
||||
'approved_estimate' => $project->approved_estimate,
|
||||
'months' => $months,
|
||||
];
|
||||
});
|
||||
|
||||
// Calculate reconciliation status for each project
|
||||
$data->each(function (&$project) {
|
||||
$project['plan_sum'] = collect($project['months'])
|
||||
->filter(fn ($m) => $m !== null && $m['planned_hours'] !== null)
|
||||
->sum('planned_hours');
|
||||
|
||||
$approved = $project['approved_estimate'] ?? 0;
|
||||
if ($approved > 0) {
|
||||
if ($project['plan_sum'] > $approved) {
|
||||
$project['reconciliation_status'] = 'OVER';
|
||||
} elseif ($project['plan_sum'] < $approved) {
|
||||
$project['reconciliation_status'] = 'UNDER';
|
||||
} elseif ($project['plan_sum'] == $approved) {
|
||||
$project['reconciliation_status'] = 'MATCH';
|
||||
} else {
|
||||
$project['reconciliation_status'] = 'UNDER';
|
||||
}
|
||||
} else {
|
||||
$project['reconciliation_status'] = 'UNDER'; // No estimate = under
|
||||
}
|
||||
// Calculate reconciliation status for each project using the service
|
||||
$reconciliationResults = $this->reconciliationCalculator->calculateForProjects($projects, (int) $year);
|
||||
|
||||
$data = $data->map(function ($project) use ($reconciliationResults) {
|
||||
$project['plan_sum'] = $reconciliationResults[$project['project_id']]['plan_sum'] ?? 0;
|
||||
$project['reconciliation_status'] = $reconciliationResults[$project['project_id']]['status'] ?? 'UNDER';
|
||||
|
||||
return $project;
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
@@ -115,7 +118,7 @@ class ProjectMonthPlanController extends Controller
|
||||
|
||||
foreach ($items as $item) {
|
||||
$projectId = $item['project_id'];
|
||||
$month = $item['month'] . '-01'; // Convert YYYY-MM to YYYY-MM-01
|
||||
$month = $item['month'].'-01'; // Convert YYYY-MM to YYYY-MM-01
|
||||
$plannedHours = $item['planned_hours']; // Can be null to clear
|
||||
|
||||
$plan = ProjectMonthPlan::firstOrNew([
|
||||
@@ -130,8 +133,8 @@ class ProjectMonthPlanController extends Controller
|
||||
} elseif ($plannedHours !== null) {
|
||||
$plan->planned_hours = $plannedHours;
|
||||
$plan->save();
|
||||
|
||||
if (!$plan->wasRecentlyCreated) {
|
||||
|
||||
if (! $plan->wasRecentlyCreated) {
|
||||
$updated++;
|
||||
} else {
|
||||
$created++;
|
||||
|
||||
41
backend/app/Http/Controllers/Api/RolesController.php
Normal file
41
backend/app/Http/Controllers/Api/RolesController.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\RoleResource;
|
||||
use App\Models\Role;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* @group Roles
|
||||
*
|
||||
* Endpoints for managing roles.
|
||||
*/
|
||||
class RolesController extends Controller
|
||||
{
|
||||
/**
|
||||
* List all roles
|
||||
*
|
||||
* Get a list of all available roles for team members.
|
||||
*
|
||||
* @authenticated
|
||||
*
|
||||
* @response 200 {
|
||||
* "data": [
|
||||
* {
|
||||
* "id": 1,
|
||||
* "name": "Frontend Dev",
|
||||
* "description": "Frontend Developer - specializes in UI/UX and client-side development"
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$roles = Role::orderBy('name')->get(['id', 'name', 'description']);
|
||||
|
||||
return $this->wrapResource(RoleResource::collection($roles));
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ class ProjectResource extends BaseResource
|
||||
'id' => $this->id,
|
||||
'code' => $this->code,
|
||||
'title' => $this->title,
|
||||
'status_id' => $this->status_id,
|
||||
'type_id' => $this->type_id,
|
||||
'status' => $this->whenLoaded('status', fn () => new ProjectStatusResource($this->status)),
|
||||
'type' => $this->whenLoaded('type', fn () => new ProjectTypeResource($this->type)),
|
||||
'approved_estimate' => $this->formatEstimate($this->approved_estimate),
|
||||
|
||||
@@ -12,6 +12,7 @@ class TeamMemberResource extends BaseResource
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'role_id' => $this->role_id,
|
||||
'role' => $this->whenLoaded('role', fn () => new RoleResource($this->role)),
|
||||
'hourly_rate' => $this->formatDecimal($this->hourly_rate),
|
||||
'active' => $this->active,
|
||||
|
||||
Reference in New Issue
Block a user