milestones()->with('tasks')->get(); if ($milestones->isNotEmpty()) { $totalMilestoneWeight = (float) $milestones->sum('weight_percentage'); if ($totalMilestoneWeight > 0) { $totalProgress = 0.0; foreach ($milestones as $milestone) { $tasks = $milestone->tasks; $milestoneProgress = 0.0; if ($tasks->isNotEmpty()) { $milestoneProgress = $tasks->avg('completion_percentage') ?? 0; } elseif ($milestone->actual_date !== null) { $milestoneProgress = 100; } $totalProgress += ($milestoneProgress * (float) $milestone->weight_percentage); } return round($totalProgress / $totalMilestoneWeight, 2); } } // 2. Direct task-based progress $query = Task::where('project_id', $project->id); if (method_exists(Task::class, 'materials')) { $query->with(['materials']); } $tasks = $query->get(); if ($tasks->isEmpty()) { return 0.0; } $totalWeightedProgress = 0.0; $totalWeight = 0.0; foreach ($tasks as $task) { $taskWeight = $task->weight ?? 1.0; $totalWeight += $taskWeight; $plannedBoq = $task->relationLoaded('materials') ? $task->materials->sum('planned_quantity') : 0; $consumedQty = $task->relationLoaded('materials') ? $task->materials->sum('consumed_quantity') : 0; if ($plannedBoq > 0) { $materialProgress = min(1.0, $consumedQty / $plannedBoq); $totalWeightedProgress += ($materialProgress * 100) * $taskWeight; } else { $taskProgress = $task->status === 'completed' || $task->status->value === 'completed' ? 100 : ($task->completion_percentage ?? $task->progress_percentage ?? 0); $totalWeightedProgress += $taskProgress * $taskWeight; } } return $totalWeight > 0 ? round($totalWeightedProgress / $totalWeight, 2) : 0.0; } /** * Generate Earned Value Management (EVM) S-Curve timeline data. * Computes Planned Value (PV), Earned Value (EV), Schedule Variance (SV), and Schedule Performance Index (SPI). * Incorporates real-time accomplishment dates from tasks and milestones. */ public function getEvmAnalysisData(Project $project): array { $milestones = $project->milestones()->with('tasks')->get(); $tasks = Task::where('project_id', $project->id)->get(); $now = now()->startOfDay(); $currentEv = $this->calculateProjectProgress($project); // Collect all potential start and end dates from project, tasks, and milestones $allStartDates = collect(); $allEndDates = collect(); if ($project->start_date) { $allStartDates->push(\Carbon\Carbon::parse($project->start_date)->startOfDay()); } if ($project->target_end_date) { $allEndDates->push(\Carbon\Carbon::parse($project->target_end_date)->startOfDay()); } if ($project->end_date) { $allEndDates->push(\Carbon\Carbon::parse($project->end_date)->startOfDay()); } foreach ($tasks as $task) { if ($task->actual_start_date) $allStartDates->push(\Carbon\Carbon::parse($task->actual_start_date)->startOfDay()); if ($task->start_date) $allStartDates->push(\Carbon\Carbon::parse($task->start_date)->startOfDay()); if ($task->actual_end_date) $allEndDates->push(\Carbon\Carbon::parse($task->actual_end_date)->startOfDay()); if ($task->end_date) $allEndDates->push(\Carbon\Carbon::parse($task->end_date)->startOfDay()); if ($task->created_at) $allStartDates->push($task->created_at->copy()->startOfDay()); if ($task->status === 'completed' && $task->updated_at) $allEndDates->push($task->updated_at->copy()->startOfDay()); } foreach ($milestones as $milestone) { if ($milestone->planned_date) $allEndDates->push(\Carbon\Carbon::parse($milestone->planned_date)->startOfDay()); if ($milestone->actual_date) { $allEndDates->push(\Carbon\Carbon::parse($milestone->actual_date)->startOfDay()); $allStartDates->push(\Carbon\Carbon::parse($milestone->actual_date)->startOfDay()); } } // Determine effective start date: adapt if work started earlier than project planned start $plannedStartDate = $project->start_date ? \Carbon\Carbon::parse($project->start_date)->startOfDay() : ($allStartDates->isNotEmpty() ? $allStartDates->min() : $now->copy()->subMonths(1)); $startDate = $allStartDates->isNotEmpty() ? $allStartDates->min() : $plannedStartDate; // Target baseline end date $targetEndDate = $project->target_end_date ? \Carbon\Carbon::parse($project->target_end_date)->startOfDay() : ($project->end_date ? \Carbon\Carbon::parse($project->end_date)->startOfDay() : ($allEndDates->isNotEmpty() ? $allEndDates->max() : now()->addMonths(2)->startOfDay())); if ($startDate->gte($targetEndDate)) { $targetEndDate = (clone $startDate)->addDays(30); } // Determine actual completion date if finished $latestActualCompletionDate = $allEndDates->filter(fn($d) => $d->lte($now))->max() ?? $now; $hasMilestones = $milestones->isNotEmpty() && $milestones->sum('weight_percentage') > 0; $totalMilestoneWeight = $hasMilestones ? (float) $milestones->sum('weight_percentage') : 0; $totalTaskWeight = $tasks->sum(fn($t) => $t->weight ?? 1.0); if ($totalTaskWeight <= 0) { $totalTaskWeight = max(1, $tasks->count()); } // Build key dates list to ensure start, target end, now, and regular intervals are present $keyDates = collect([$startDate, $targetEndDate]); $keyDates->push($now); if ($plannedStartDate->between($startDate, $targetEndDate)) { $keyDates->push($plannedStartDate); } if ($currentEv >= 100 && $latestActualCompletionDate->between($startDate, $targetEndDate)) { $keyDates->push($latestActualCompletionDate); } // Add 12 evenly distributed sample intervals $totalDays = max(1, $startDate->diffInDays($targetEndDate)); $stepDays = max(1, (int)ceil($totalDays / 12)); $cursor = clone $startDate; while ($cursor->lte($targetEndDate)) { $keyDates->push($cursor->copy()); $cursor->addDays($stepDays); } // Unique sorted date collection $sortedDates = $keyDates->map(fn($d) => $d->format('Y-m-d'))->unique()->sort()->values(); $timeSeries = []; foreach ($sortedDates as $dateStr) { $currentDate = \Carbon\Carbon::parse($dateStr)->startOfDay(); // 1. Calculate Baseline Planned Value (PV) up to currentDate $plannedWeightedSum = 0.0; if ($hasMilestones) { foreach ($milestones as $milestone) { $mWeight = (float) $milestone->weight_percentage; $mTasks = $milestone->tasks; if ($mTasks->isNotEmpty()) { $mPlannedSum = 0.0; foreach ($mTasks as $task) { $tStart = $task->start_date ? \Carbon\Carbon::parse($task->start_date)->startOfDay() : $plannedStartDate; $tEnd = $task->end_date ? \Carbon\Carbon::parse($task->end_date)->startOfDay() : ($milestone->planned_date ? \Carbon\Carbon::parse($milestone->planned_date)->startOfDay() : $targetEndDate); if ($currentDate->lt($tStart)) { $pct = 0; } elseif ($currentDate->gte($tEnd)) { $pct = 100; } else { $taskDuration = max(1, $tStart->diffInDays($tEnd)); $elapsed = $tStart->diffInDays($currentDate); $pct = min(100, max(0, ($elapsed / $taskDuration) * 100)); } $mPlannedSum += $pct; } $plannedWeightedSum += (($mPlannedSum / $mTasks->count()) * $mWeight); } else { $mPlannedDate = $milestone->planned_date ? \Carbon\Carbon::parse($milestone->planned_date)->startOfDay() : $targetEndDate; $pct = $currentDate->gte($mPlannedDate) ? 100 : 0; $plannedWeightedSum += ($pct * $mWeight); } } $pv = round($plannedWeightedSum / $totalMilestoneWeight, 2); } else { foreach ($tasks as $task) { $tWeight = $task->weight ?? 1.0; $tStart = $task->start_date ? \Carbon\Carbon::parse($task->start_date)->startOfDay() : $plannedStartDate; $tEnd = $task->end_date ? \Carbon\Carbon::parse($task->end_date)->startOfDay() : $targetEndDate; if ($currentDate->lt($tStart)) { $taskPlannedPct = 0; } elseif ($currentDate->gte($tEnd)) { $taskPlannedPct = 100; } else { $taskDuration = max(1, $tStart->diffInDays($tEnd)); $elapsed = $tStart->diffInDays($currentDate); $taskPlannedPct = min(100, max(0, ($elapsed / $taskDuration) * 100)); } $plannedWeightedSum += ($taskPlannedPct * $tWeight); } $pv = round($plannedWeightedSum / $totalTaskWeight, 2); } // 2. Calculate Real-Time Earned Value (EV) accomplishments up to currentDate $ev = null; if ($currentEv >= 100) { // If project is 100% completed early: if ($currentDate->lt($startDate)) { $ev = 0.0; } elseif ($currentDate->gte($latestActualCompletionDate)) { $ev = 100.0; } else { // Prorated accomplishment between start and completion date $progressDuration = max(1, $startDate->diffInDays($latestActualCompletionDate)); $elapsedProgress = $startDate->diffInDays($currentDate); $ev = round(min(100, max(0, ($elapsedProgress / $progressDuration) * 100)), 2); } } elseif ($currentDate->lte($now)) { if ($currentDate->equalTo($now)) { $ev = $currentEv; } elseif ($currentDate->equalTo($startDate) && $currentEv <= 0) { $ev = 0.0; } else { if ($hasMilestones) { $earnedWeightedSum = 0.0; foreach ($milestones as $milestone) { $mWeight = (float) $milestone->weight_percentage; $mTasks = $milestone->tasks; if ($mTasks->isNotEmpty()) { $mTaskAccomplishmentSum = 0.0; foreach ($mTasks as $task) { $taskAccomplishment = $this->calculateTaskAccomplishmentAtDate($task, $currentDate, $startDate, $targetEndDate); $mTaskAccomplishmentSum += $taskAccomplishment; } $earnedWeightedSum += (($mTaskAccomplishmentSum / $mTasks->count()) * $mWeight); } else { $mActualDate = $milestone->actual_date ? \Carbon\Carbon::parse($milestone->actual_date)->startOfDay() : null; $mPct = ($mActualDate && $currentDate->gte($mActualDate)) ? 100 : 0; $earnedWeightedSum += ($mPct * $mWeight); } } $ev = round($earnedWeightedSum / $totalMilestoneWeight, 2); } else { $earnedWeightedSum = 0.0; foreach ($tasks as $task) { $tWeight = $task->weight ?? 1.0; $taskAccomplishment = $this->calculateTaskAccomplishmentAtDate($task, $currentDate, $startDate, $targetEndDate); $earnedWeightedSum += ($taskAccomplishment * $tWeight); } $ev = round($earnedWeightedSum / $totalTaskWeight, 2); } // Cap historical EV by currentEv $ev = min($currentEv, $ev); } } $sv = $ev !== null ? round($ev - $pv, 2) : null; $spi = ($ev !== null && $pv > 0) ? round($ev / $pv, 2) : ($ev !== null ? 1.0 : null); $timeSeries[] = [ 'date' => $currentDate->format('M d'), 'full_date' => $dateStr, 'is_today' => $currentDate->equalTo($now), 'planned_pv' => $pv, 'actual_ev' => $ev, 'schedule_variance' => $sv, 'spi' => $spi, ]; } // Current real-time overall EV vs PV as of today $todayPoint = collect($timeSeries)->firstWhere('is_today', true); $currentPv = $todayPoint ? $todayPoint['planned_pv'] : (end($timeSeries)['planned_pv'] ?? 0); $currentSv = round($currentEv - $currentPv, 2); $currentSpi = $currentPv > 0 ? round($currentEv / $currentPv, 2) : ($currentEv > 0 ? 1.0 : 1.0); // Dynamic Projected Finish Date based on SPI and Actual Early Completion if ($currentEv >= 100) { $projectedEndDate = $latestActualCompletionDate; $daysVariance = $targetEndDate->diffInDays($projectedEndDate, false); $status = $daysVariance < 0 ? 'Completed Ahead of Schedule' : ($daysVariance === 0 ? 'Completed On Schedule' : 'Completed with Delay'); } elseif ($currentSpi > 0 && $currentSpi < 1.0) { $daysRemaining = max(0, $now->diffInDays($targetEndDate, false)); $adjustedDays = (int)ceil($daysRemaining / $currentSpi); $projectedEndDate = $now->copy()->addDays($adjustedDays); $daysVariance = $targetEndDate->diffInDays($projectedEndDate, false); $status = $currentSv > -5 ? 'On Track' : 'Delayed'; } else { // Ahead of schedule or on track $daysRemaining = max(0, $now->diffInDays($targetEndDate, false)); $adjustedDays = $currentSpi > 1.0 ? (int)ceil($daysRemaining / $currentSpi) : $daysRemaining; $projectedEndDate = $now->copy()->addDays($adjustedDays); $daysVariance = $targetEndDate->diffInDays($projectedEndDate, false); $status = 'Ahead of Schedule'; } // Build Milestone Turnover Coordination dataset $milestonesData = $milestones->map(function ($milestone) use ($now) { $mTasks = $milestone->tasks; $tasksCount = $mTasks->count(); $completedTasksCount = $mTasks->filter(function ($t) { return $t->status === 'completed' || (is_object($t->status) && $t->status->value === 'completed') || (float)$t->completion_percentage >= 100; })->count(); $avgTaskProgress = $tasksCount > 0 ? round($mTasks->avg('completion_percentage') ?? 0, 1) : ($milestone->actual_date ? 100.0 : 0.0); $isTurnovered = $milestone->actual_date !== null || ($tasksCount > 0 && $completedTasksCount === $tasksCount); // Determine Turnover Status $plannedDate = $milestone->planned_date ? \Carbon\Carbon::parse($milestone->planned_date)->startOfDay() : null; $actualDate = $milestone->actual_date ? \Carbon\Carbon::parse($milestone->actual_date)->startOfDay() : null; if ($actualDate) { if ($plannedDate && $actualDate->lt($plannedDate)) { $turnoverStatus = 'Turnovered Ahead'; $turnoverStatusColor = 'emerald'; } elseif ($plannedDate && $actualDate->equalTo($plannedDate)) { $turnoverStatus = 'Turnovered On Time'; $turnoverStatusColor = 'emerald'; } else { $turnoverStatus = 'Turnovered with Delay'; $turnoverStatusColor = 'amber'; } } elseif ($avgTaskProgress >= 100) { $turnoverStatus = 'Ready for Turnover'; $turnoverStatusColor = 'blue'; } elseif ($plannedDate && $plannedDate->lt($now)) { $turnoverStatus = 'Overdue Turnover'; $turnoverStatusColor = 'rose'; } elseif ($avgTaskProgress > 0) { $turnoverStatus = 'In Progress'; $turnoverStatusColor = 'indigo'; } else { $turnoverStatus = 'Upcoming'; $turnoverStatusColor = 'slate'; } return [ 'id' => $milestone->id, 'name' => $milestone->name, 'description' => $milestone->description, 'weight_percentage' => (float)$milestone->weight_percentage, 'planned_date' => $plannedDate ? $plannedDate->format('M d, Y') : null, 'actual_date' => $actualDate ? $actualDate->format('M d, Y') : null, 'tasks_count' => $tasksCount, 'completed_tasks_count' => $completedTasksCount, 'progress_percentage' => $avgTaskProgress, 'is_turnovered' => $isTurnovered, 'turnover_status' => $turnoverStatus, 'turnover_status_color' => $turnoverStatusColor, 'days_variance' => $plannedDate && $actualDate ? $plannedDate->diffInDays($actualDate, false) : ($plannedDate && $plannedDate->lt($now) && !$isTurnovered ? $plannedDate->diffInDays($now, false) : 0), ]; })->values()->toArray(); return [ 'timeSeries' => $timeSeries, 'summary' => [ 'planned_pv' => $currentPv, 'actual_ev' => $currentEv, 'schedule_variance' => $currentSv, 'spi' => $currentSpi, 'status' => $status, 'target_end_date' => $targetEndDate->format('M d, Y'), 'projected_end_date' => $projectedEndDate->format('M d, Y'), 'days_variance' => $daysVariance, 'is_completed' => $currentEv >= 100, ], 'milestones' => $milestonesData, ]; } /** * Determine task completion % achieved as of a given historical date. */ protected function calculateTaskAccomplishmentAtDate(Task $task, \Carbon\Carbon $currentDate, \Carbon\Carbon $projectStart, \Carbon\Carbon $projectTargetEnd): float { $actualEndDate = $task->actual_end_date ? \Carbon\Carbon::parse($task->actual_end_date) : null; $actualStartDate = $task->actual_start_date ? \Carbon\Carbon::parse($task->actual_start_date) : null; $plannedStartDate = $task->start_date ? \Carbon\Carbon::parse($task->start_date) : $projectStart; $plannedEndDate = $task->end_date ? \Carbon\Carbon::parse($task->end_date) : $projectTargetEnd; // 1. If task has a verified actual completion date if ($actualEndDate && $currentDate->gte($actualEndDate)) { return 100.0; } // 2. If task status is completed and updatedAt is before/on currentDate $isCompletedStatus = $task->status === 'completed' || (is_object($task->status) && $task->status->value === 'completed'); if ($isCompletedStatus && $task->updated_at && $currentDate->gte($task->updated_at->copy()->startOfDay())) { return 100.0; } // 3. If work started on or before currentDate $effectiveStart = $actualStartDate ?? $plannedStartDate; if ($currentDate->lt($effectiveStart)) { return 0.0; } $effectiveEnd = $actualEndDate ?? $plannedEndDate; $currentCompletion = (float)($isCompletedStatus ? 100 : ($task->completion_percentage ?? $task->progress_percentage ?? 0)); if ($currentCompletion <= 0) { return 0.0; } if ($currentDate->gte(now()->startOfDay())) { return $currentCompletion; } $durationDays = max(1, $effectiveStart->diffInDays($effectiveEnd)); $elapsedDays = $effectiveStart->diffInDays($currentDate); return min($currentCompletion, round(($elapsedDays / $durationDays) * $currentCompletion, 2)); } }