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 */ 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 */ 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 */ 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, ]; } }