Files
headroom/backend/app/Http/Controllers/Api/ReportController.php
Santhosh Janardhanan 2a93245970 feat(backend): add reporting and variance calculation services
Implement core reporting infrastructure:
- ReconciliationCalculator: plan vs estimate reconciliation
- VarianceCalculator: project and member variance calculations
- ReportController: GET /api/reports/allocations endpoint
- Support did/is/will view type inference based on date ranges
- Distinguish blank plans from explicit zero values

Part of enhanced-allocation change.
2026-03-08 18:22:34 -04:00

362 lines
13 KiB
PHP

<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Allocation;
use App\Models\Project;
use App\Models\ProjectMonthPlan;
use App\Models\TeamMember;
use App\Services\ReconciliationCalculator;
use App\Services\VarianceCalculator;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
/**
* @group Reports
*
* Endpoints for generating management reports with did/is/will views.
*/
class ReportController extends Controller
{
public function __construct(
protected ReconciliationCalculator $reconciliationCalculator,
protected VarianceCalculator $varianceCalculator
) {
}
/**
* Get allocation report
*
* Returns aggregated allocation data with lifecycle totals, month plans,
* execution hours, and variances. View type (did/is/will) is inferred
* from the date range relative to current month.
*
* @authenticated
*
* @queryParam start_date string required Start date (YYYY-MM-DD). Example: 2026-01-01
* @queryParam end_date string required End date (YYYY-MM-DD). Example: 2026-03-31
* @queryParam project_ids array optional Filter by project IDs. Example: ["uuid1", "uuid2"]
* @queryParam member_ids array optional Filter by team member IDs. Example: ["uuid1", "uuid2"]
*
* @response 200 {
* "period": { "start": "2026-01-01", "end": "2026-03-31" },
* "view_type": "is",
* "projects": [...],
* "members": [...],
* "aggregates": {
* "total_planned": 7200,
* "total_allocated": 7100,
* "total_variance": -100,
* "status": "MATCH"
* }
* }
*/
public function allocations(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'start_date' => 'required|date_format:Y-m-d',
'end_date' => 'required|date_format:Y-m-d|after_or_equal:start_date',
'project_ids' => 'nullable|array',
'project_ids.*' => 'uuid|exists:projects,id',
'member_ids' => 'nullable|array',
'member_ids.*' => 'uuid|exists:team_members,id',
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed',
'errors' => $validator->errors(),
], 422);
}
$startDate = Carbon::parse($request->input('start_date'));
$endDate = Carbon::parse($request->input('end_date'));
$viewType = $this->determineViewType($startDate, $endDate);
// Get projects with optional filtering
$projectsQuery = Project::query();
if ($request->has('project_ids')) {
$projectsQuery->whereIn('id', $request->input('project_ids'));
}
$projects = $projectsQuery->get();
// Get team members with optional filtering
$membersQuery = TeamMember::query();
if ($request->has('member_ids')) {
$membersQuery->whereIn('id', $request->input('member_ids'));
}
$members = $membersQuery->get();
// Get all plans for the period
$plans = ProjectMonthPlan::whereBetween('month', [$startDate->format('Y-m-d'), $endDate->format('Y-m-d')])
->when($request->has('project_ids'), fn ($q) => $q->whereIn('project_id', $request->input('project_ids')))
->get()
->groupBy('project_id');
// Get all allocations for the period
$allocations = Allocation::whereBetween('month', [$startDate->format('Y-m-d'), $endDate->format('Y-m-d')])
->when($request->has('project_ids'), fn ($q) => $q->whereIn('project_id', $request->input('project_ids')))
->when($request->has('member_ids'), fn ($q) => $q->whereIn('team_member_id', $request->input('member_ids')))
->get();
// Build project report data
$projectData = $this->buildProjectData($projects, $plans, $allocations, $startDate, $endDate);
// Build member report data
$memberData = $this->buildMemberData($members, $allocations, $startDate, $endDate);
// Calculate aggregates
$aggregates = $this->calculateAggregates($projectData);
return response()->json([
'period' => [
'start' => $startDate->format('Y-m-d'),
'end' => $endDate->format('Y-m-d'),
],
'view_type' => $viewType,
'projects' => $projectData,
'members' => $memberData,
'aggregates' => $aggregates,
]);
}
/**
* Determine view type based on date range relative to current month.
*/
private function determineViewType(Carbon $startDate, Carbon $endDate): string
{
$now = Carbon::now();
$currentMonthStart = $now->copy()->startOfMonth();
$currentMonthEnd = $now->copy()->endOfMonth();
$rangeStart = $startDate->copy()->startOfMonth();
$rangeEnd = $endDate->copy()->endOfMonth();
// All dates are in the past -> 'did'
if ($rangeEnd->lt($currentMonthStart)) {
return 'did';
}
// All dates are in the future -> 'will'
if ($rangeStart->gt($currentMonthEnd)) {
return 'will';
}
// Includes current month -> 'is'
return 'is';
}
/**
* Build project report data with lifecycle totals and period execution.
*
* @return array<int, array{
* id: string,
* code: string,
* title: string,
* approved_estimate: float,
* lifecycle_status: string,
* plan_sum: float,
* period_planned: float,
* period_allocated: float,
* period_variance: float,
* period_status: string,
* months: array
* }>
*/
private function buildProjectData($projects, $plans, $allocations, Carbon $startDate, Carbon $endDate): array
{
$projectData = [];
foreach ($projects as $project) {
$projectPlans = $plans->get($project->id, collect());
// Calculate lifecycle reconciliation (all plans for this project)
$allProjectPlans = ProjectMonthPlan::where('project_id', $project->id)->get();
$lifecycleStatus = $this->reconciliationCalculator->calculateStatus($project, $allProjectPlans);
$planSum = $this->reconciliationCalculator->calculatePlanSum($project, $allProjectPlans);
// Calculate period metrics (only within date range)
$periodPlans = $projectPlans->filter(fn ($p) =>
Carbon::parse($p->month)->between($startDate, $endDate)
);
$periodPlanned = $periodPlans->sum('planned_hours');
// Get allocations for this project in the period
$projectAllocations = $allocations->where('project_id', $project->id);
$periodAllocated = $projectAllocations->sum('allocated_hours');
$periodVariance = $periodAllocated - $periodPlanned;
$periodStatus = $this->varianceCalculator->determineStatus($periodVariance);
// Build monthly breakdown
$months = $this->buildProjectMonthBreakdown($projectPlans, $projectAllocations, $startDate, $endDate);
$projectData[] = [
'id' => $project->id,
'code' => $project->code,
'title' => $project->title,
'approved_estimate' => (float) $project->approved_estimate,
'lifecycle_status' => $lifecycleStatus,
'plan_sum' => $planSum,
'period_planned' => $periodPlanned,
'period_allocated' => $periodAllocated,
'period_variance' => $periodVariance,
'period_status' => $periodStatus,
'months' => $months,
];
}
return $projectData;
}
/**
* Build monthly breakdown for a project.
*
* @return array<int, array{
* month: string,
* planned_hours: float|null,
* is_blank: bool,
* allocated_hours: float,
* variance: float,
* status: string
* }>
*/
private function buildProjectMonthBreakdown($projectPlans, $projectAllocations, Carbon $startDate, Carbon $endDate): array
{
$months = [];
$current = $startDate->copy()->startOfMonth();
while ($current->lte($endDate)) {
$monthKey = $current->format('Y-m');
// Get plan for this month
$plan = $projectPlans->first(fn ($p) =>
Carbon::parse($p->month)->format('Y-m') === $monthKey
);
$plannedHours = $plan?->planned_hours;
$isBlank = $plannedHours === null;
// Get allocations for this month
$monthAllocations = $projectAllocations->filter(fn ($a) =>
Carbon::parse($a->month)->format('Y-m') === $monthKey
);
$allocatedHours = $monthAllocations->sum('allocated_hours');
$variance = $allocatedHours - ($plannedHours ?? 0);
$status = $this->varianceCalculator->determineStatus($variance);
$months[] = [
'month' => $monthKey,
'planned_hours' => $isBlank ? null : (float) $plannedHours,
'is_blank' => $isBlank,
'allocated_hours' => $allocatedHours,
'variance' => $variance,
'status' => $status,
];
$current->addMonth();
}
return $months;
}
/**
* Build member report data with capacity and utilization.
*
* @return array<int, array{
* id: string,
* name: string,
* period_allocated: float,
* period_untracked: float,
* total_hours: float,
* projects: array
* }>
*/
private function buildMemberData($members, $allocations, Carbon $startDate, Carbon $endDate): array
{
$memberData = [];
foreach ($members as $member) {
// Get allocations for this member in the period (excluding untracked)
$memberAllocations = $allocations->filter(fn ($a) =>
$a->team_member_id === $member->id
);
$periodAllocated = $memberAllocations->sum('allocated_hours');
// Group by project
$projects = $memberAllocations
->groupBy('project_id')
->map(fn ($allocs, $projectId) => [
'project_id' => $projectId,
'project_code' => $allocs->first()->project->code ?? null,
'project_title' => $allocs->first()->project->title ?? null,
'total_hours' => $allocs->sum('allocated_hours'),
])
->values()
->toArray();
$memberData[] = [
'id' => $member->id,
'name' => $member->name,
'period_allocated' => $periodAllocated,
'projects' => $projects,
];
}
// Add untracked row
$untrackedAllocations = $allocations->whereNull('team_member_id');
if ($untrackedAllocations->isNotEmpty()) {
$untrackedProjects = $untrackedAllocations
->groupBy('project_id')
->map(fn ($allocs, $projectId) => [
'project_id' => $projectId,
'project_code' => $allocs->first()->project->code ?? null,
'project_title' => $allocs->first()->project->title ?? null,
'total_hours' => $allocs->sum('allocated_hours'),
])
->values()
->toArray();
$memberData[] = [
'id' => null,
'name' => 'Untracked',
'period_allocated' => $untrackedAllocations->sum('allocated_hours'),
'projects' => $untrackedProjects,
];
}
return $memberData;
}
/**
* Calculate aggregate metrics across all projects.
*
* @return array{
* total_planned: float,
* total_allocated: float,
* total_variance: float,
* status: string
* }
*/
private function calculateAggregates(array $projectData): array
{
$totalPlanned = array_sum(array_column($projectData, 'period_planned'));
$totalAllocated = array_sum(array_column($projectData, 'period_allocated'));
$totalVariance = $totalAllocated - $totalPlanned;
$status = $this->varianceCalculator->determineStatus($totalVariance);
return [
'total_planned' => $totalPlanned,
'total_allocated' => $totalAllocated,
'total_variance' => $totalVariance,
'status' => $status,
];
}
}