Implement revised retention workflow, overdue late penalty calculation, payment proof verification, and dynamic ledger reconciliation
This commit is contained in:
@@ -15,10 +15,17 @@ class ProjectProgressController extends Controller
|
||||
$projectUlid = $request->query('project');
|
||||
$project = $projectUlid ? \Modules\ProjectManagement\Models\Project::where('ulid', $projectUlid)->firstOrFail() : null;
|
||||
|
||||
$evmData = null;
|
||||
if ($project) {
|
||||
$progressService = new \Modules\ProjectProgress\Services\ProjectProgressService();
|
||||
$evmData = $progressService->getEvmAnalysisData($project);
|
||||
}
|
||||
|
||||
return \Inertia\Inertia::render('ProjectManagement::Projects/Modules/Progress', [
|
||||
'project' => $project,
|
||||
'projects' => \Modules\ProjectManagement\Models\Project::with('parentProject:id,ulid,name,code')->get(['id', 'ulid', 'name', 'code', 'project_type', 'parent_project_id']),
|
||||
'currentTab' => 'progress',
|
||||
'evmData' => $evmData,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,19 +3,41 @@
|
||||
namespace Modules\ProjectProgress\Services;
|
||||
|
||||
use Modules\ProjectManagement\Models\Project;
|
||||
use Modules\TaskManagement\Models\Task;
|
||||
use Modules\ProjectManagement\Models\Task;
|
||||
|
||||
class ProjectProgressService
|
||||
{
|
||||
/**
|
||||
* Calculate project progress dynamically using actual material consumed vs BOQ planned quantity.
|
||||
* Formula: sum((Actual Material Consumed / Planned BOQ) * Task Weight)
|
||||
* Calculate project progress dynamically using milestones or actual material/task completions.
|
||||
*/
|
||||
public function calculateProjectProgress(Project $project): float
|
||||
{
|
||||
$tasks = Task::where('project_id', $project->id)
|
||||
->with(['materials'])
|
||||
->get();
|
||||
// 1. Check if project has milestones with weights
|
||||
$milestones = $project->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;
|
||||
@@ -28,19 +50,372 @@ class ProjectProgressService
|
||||
$taskWeight = $task->weight ?? 1.0;
|
||||
$totalWeight += $taskWeight;
|
||||
|
||||
$plannedBoq = $task->materials->sum('planned_quantity');
|
||||
$consumedQty = $task->materials->sum('consumed_quantity');
|
||||
$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 {
|
||||
// Fallback to task completion percentage if no BOQ material planned
|
||||
$taskProgress = $task->status === 'completed' ? 100 : ($task->progress_percentage ?? 0);
|
||||
$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));
|
||||
}
|
||||
}
|
||||
|
||||
92
Modules/ProjectProgress/tests/Feature/EvmCalculationTest.php
Normal file
92
Modules/ProjectProgress/tests/Feature/EvmCalculationTest.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\ProjectProgress\Tests\Feature;
|
||||
|
||||
use Tests\TestCase;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Modules\ProjectManagement\Models\Project;
|
||||
use Modules\ProjectManagement\Models\Task;
|
||||
use Modules\ProjectProgress\Services\ProjectProgressService;
|
||||
|
||||
class EvmCalculationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_evm_s_curve_data_generation()
|
||||
{
|
||||
$project = Project::factory()->create([
|
||||
'start_date' => now()->subDays(10)->format('Y-m-d'),
|
||||
'target_end_date' => now()->addDays(20)->format('Y-m-d'),
|
||||
]);
|
||||
|
||||
Task::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'status' => 'completed',
|
||||
'completion_percentage' => 100,
|
||||
'start_date' => now()->subDays(10)->format('Y-m-d'),
|
||||
'end_date' => now()->subDays(2)->format('Y-m-d'),
|
||||
]);
|
||||
|
||||
Task::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'status' => 'in_progress',
|
||||
'completion_percentage' => 50,
|
||||
'start_date' => now()->subDays(5)->format('Y-m-d'),
|
||||
'end_date' => now()->addDays(15)->format('Y-m-d'),
|
||||
]);
|
||||
|
||||
$service = new ProjectProgressService();
|
||||
$evmData = $service->getEvmAnalysisData($project);
|
||||
|
||||
$this->assertIsArray($evmData);
|
||||
$this->assertArrayHasKey('timeSeries', $evmData);
|
||||
$this->assertArrayHasKey('summary', $evmData);
|
||||
|
||||
$summary = $evmData['summary'];
|
||||
$this->assertGreaterThan(0, $summary['actual_ev']);
|
||||
$this->assertNotNull($summary['spi']);
|
||||
$this->assertNotNull($summary['schedule_variance']);
|
||||
}
|
||||
|
||||
public function test_evm_with_milestones_and_accomplishment_dates()
|
||||
{
|
||||
$project = Project::factory()->create([
|
||||
'start_date' => now()->subDays(30)->format('Y-m-d'),
|
||||
'target_end_date' => now()->addDays(30)->format('Y-m-d'),
|
||||
]);
|
||||
|
||||
$m1 = $project->milestones()->create([
|
||||
'name' => 'Substructure',
|
||||
'weight_percentage' => 40,
|
||||
'planned_date' => now()->subDays(10)->format('Y-m-d'),
|
||||
'actual_date' => now()->subDays(12)->format('Y-m-d'),
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$m2 = $project->milestones()->create([
|
||||
'name' => 'Superstructure',
|
||||
'weight_percentage' => 60,
|
||||
'planned_date' => now()->addDays(20)->format('Y-m-d'),
|
||||
'actual_date' => null,
|
||||
'sort_order' => 2,
|
||||
]);
|
||||
|
||||
Task::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'milestone_id' => $m2->id,
|
||||
'status' => 'in_progress',
|
||||
'completion_percentage' => 50,
|
||||
'start_date' => now()->subDays(5)->format('Y-m-d'),
|
||||
'actual_start_date' => now()->subDays(5)->format('Y-m-d'),
|
||||
'end_date' => now()->addDays(20)->format('Y-m-d'),
|
||||
]);
|
||||
|
||||
$service = new ProjectProgressService();
|
||||
$evmData = $service->getEvmAnalysisData($project);
|
||||
|
||||
$this->assertIsArray($evmData);
|
||||
$this->assertNotEmpty($evmData['timeSeries']);
|
||||
// Milestone 1 (40%) completed + Milestone 2 (50% of 60% = 30%) -> Total 70%
|
||||
$this->assertEquals(70.0, $evmData['summary']['actual_ev']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\ProjectProgress\Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Modules\ProjectManagement\Models\Project;
|
||||
use Modules\ProjectManagement\Models\Task;
|
||||
use Modules\ProjectProgress\Services\ProjectProgressService;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProgressMonitoringFeatureTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected User $user;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$role = Role::firstOrCreate(['name' => 'Super Admin']);
|
||||
Permission::firstOrCreate(['name' => 'projects.access']);
|
||||
|
||||
$this->user = User::factory()->create([
|
||||
'status' => 'active',
|
||||
'user_type' => 'admin',
|
||||
]);
|
||||
$this->user->assignRole($role);
|
||||
}
|
||||
|
||||
public function test_progress_monitoring_page_renders_with_inertia_props_when_project_selected(): void
|
||||
{
|
||||
$project = Project::factory()->create([
|
||||
'start_date' => now()->subDays(20)->format('Y-m-d'),
|
||||
'target_end_date' => now()->addDays(20)->format('Y-m-d'),
|
||||
]);
|
||||
|
||||
Task::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'status' => 'completed',
|
||||
'completion_percentage' => 100,
|
||||
'start_date' => now()->subDays(20)->format('Y-m-d'),
|
||||
'actual_end_date' => now()->subDays(5)->format('Y-m-d'),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->get(route('projects.progress.index', ['project' => $project->ulid]));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->component('ProjectManagement::Projects/Modules/Progress', false)
|
||||
->has('project')
|
||||
->has('projects')
|
||||
->where('currentTab', 'progress')
|
||||
->has('evmData', fn (Assert $evm) => $evm
|
||||
->has('timeSeries')
|
||||
->has('milestones')
|
||||
->has('summary', fn (Assert $summary) => $summary
|
||||
->has('planned_pv')
|
||||
->has('actual_ev')
|
||||
->has('schedule_variance')
|
||||
->has('spi')
|
||||
->has('status')
|
||||
->has('target_end_date')
|
||||
->has('projected_end_date')
|
||||
->has('days_variance')
|
||||
->has('is_completed')
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public function test_progress_monitoring_page_without_project_param_renders_project_selection_grid(): void
|
||||
{
|
||||
Project::factory()->count(3)->create();
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->get(route('projects.progress.index'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->component('ProjectManagement::Projects/Modules/Progress', false)
|
||||
->where('project', null)
|
||||
->where('evmData', null)
|
||||
->has('projects', 3)
|
||||
);
|
||||
}
|
||||
|
||||
public function test_project_overview_page_provides_evm_data_prop(): void
|
||||
{
|
||||
$project = Project::factory()->create([
|
||||
'start_date' => now()->subDays(15)->format('Y-m-d'),
|
||||
'target_end_date' => now()->addDays(15)->format('Y-m-d'),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->get(route('projects.show', $project->ulid));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->component('ProjectManagement::Projects/Overview', false)
|
||||
->has('evmData')
|
||||
->has('evmData.timeSeries')
|
||||
->has('evmData.summary')
|
||||
->has('evmData.milestones')
|
||||
);
|
||||
}
|
||||
|
||||
public function test_evm_calculation_handles_ahead_of_schedule_status(): void
|
||||
{
|
||||
$project = Project::factory()->create([
|
||||
'start_date' => now()->subDays(30)->format('Y-m-d'),
|
||||
'target_end_date' => now()->addDays(30)->format('Y-m-d'),
|
||||
]);
|
||||
|
||||
// Task completed way ahead of schedule
|
||||
Task::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'status' => 'completed',
|
||||
'completion_percentage' => 100,
|
||||
'start_date' => now()->subDays(30)->format('Y-m-d'),
|
||||
'actual_end_date' => now()->subDays(20)->format('Y-m-d'),
|
||||
'end_date' => now()->addDays(20)->format('Y-m-d'),
|
||||
]);
|
||||
|
||||
$service = new ProjectProgressService();
|
||||
$evmData = $service->getEvmAnalysisData($project);
|
||||
|
||||
$summary = $evmData['summary'];
|
||||
$this->assertEquals(100.0, $summary['actual_ev']);
|
||||
$this->assertGreaterThan(0, $summary['schedule_variance']);
|
||||
$this->assertGreaterThanOrEqual(1.0, $summary['spi']);
|
||||
$this->assertEquals('Completed Ahead of Schedule', $summary['status']);
|
||||
$this->assertTrue($summary['is_completed']);
|
||||
}
|
||||
|
||||
public function test_evm_calculation_handles_delayed_status_and_forecasted_end_date(): void
|
||||
{
|
||||
$project = Project::factory()->create([
|
||||
'start_date' => now()->subDays(30)->format('Y-m-d'),
|
||||
'target_end_date' => now()->addDays(10)->format('Y-m-d'),
|
||||
]);
|
||||
|
||||
// Only 10% completed while planned is ~75%
|
||||
Task::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'status' => 'in_progress',
|
||||
'completion_percentage' => 10,
|
||||
'start_date' => now()->subDays(30)->format('Y-m-d'),
|
||||
'end_date' => now()->addDays(10)->format('Y-m-d'),
|
||||
]);
|
||||
|
||||
$service = new ProjectProgressService();
|
||||
$evmData = $service->getEvmAnalysisData($project);
|
||||
|
||||
$summary = $evmData['summary'];
|
||||
$this->assertLessThan(0, $summary['schedule_variance']);
|
||||
$this->assertLessThan(1.0, $summary['spi']);
|
||||
$this->assertEquals('Delayed', $summary['status']);
|
||||
$this->assertGreaterThan(0, $summary['days_variance']);
|
||||
}
|
||||
|
||||
public function test_evm_calculation_handles_empty_project_gracefully(): void
|
||||
{
|
||||
$project = Project::factory()->create([
|
||||
'start_date' => now()->format('Y-m-d'),
|
||||
'target_end_date' => now()->addDays(30)->format('Y-m-d'),
|
||||
]);
|
||||
|
||||
$service = new ProjectProgressService();
|
||||
$evmData = $service->getEvmAnalysisData($project);
|
||||
|
||||
$this->assertIsArray($evmData);
|
||||
$this->assertNotEmpty($evmData['timeSeries']);
|
||||
$this->assertEquals(0.0, $evmData['summary']['actual_ev']);
|
||||
}
|
||||
|
||||
public function test_task_and_scheduling_coordinated_with_milestone_turnover(): void
|
||||
{
|
||||
$project = Project::factory()->create([
|
||||
'start_date' => now()->subDays(30)->format('Y-m-d'),
|
||||
'target_end_date' => now()->addDays(30)->format('Y-m-d'),
|
||||
]);
|
||||
|
||||
// Milestone 1: Structural Works (Turnovered Ahead)
|
||||
$m1 = \Modules\ProjectManagement\Models\ProjectMilestone::create([
|
||||
'project_id' => $project->id,
|
||||
'name' => 'Foundation & Structural Turnover',
|
||||
'weight_percentage' => 60.0,
|
||||
'planned_date' => now()->subDays(5)->format('Y-m-d'),
|
||||
'actual_date' => now()->subDays(10)->format('Y-m-d'),
|
||||
]);
|
||||
|
||||
Task::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'milestone_id' => $m1->id,
|
||||
'status' => 'completed',
|
||||
'completion_percentage' => 100,
|
||||
'start_date' => now()->subDays(30)->format('Y-m-d'),
|
||||
'actual_end_date' => now()->subDays(10)->format('Y-m-d'),
|
||||
]);
|
||||
|
||||
// Milestone 2: Finishing Works (In Progress / Ready for Turnover)
|
||||
$m2 = \Modules\ProjectManagement\Models\ProjectMilestone::create([
|
||||
'project_id' => $project->id,
|
||||
'name' => 'Finishing & Final Turnover',
|
||||
'weight_percentage' => 40.0,
|
||||
'planned_date' => now()->addDays(20)->format('Y-m-d'),
|
||||
'actual_date' => null,
|
||||
]);
|
||||
|
||||
Task::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'milestone_id' => $m2->id,
|
||||
'status' => 'in_progress',
|
||||
'completion_percentage' => 50,
|
||||
'start_date' => now()->subDays(9)->format('Y-m-d'),
|
||||
'end_date' => now()->addDays(20)->format('Y-m-d'),
|
||||
]);
|
||||
|
||||
$service = new ProjectProgressService();
|
||||
$evmData = $service->getEvmAnalysisData($project);
|
||||
|
||||
$this->assertNotEmpty($evmData['milestones']);
|
||||
$this->assertCount(2, $evmData['milestones']);
|
||||
|
||||
$m1Data = collect($evmData['milestones'])->firstWhere('id', $m1->id);
|
||||
$this->assertEquals('Turnovered Ahead', $m1Data['turnover_status']);
|
||||
$this->assertTrue($m1Data['is_turnovered']);
|
||||
$this->assertEquals(100.0, $m1Data['progress_percentage']);
|
||||
|
||||
$m2Data = collect($evmData['milestones'])->firstWhere('id', $m2->id);
|
||||
$this->assertEquals('In Progress', $m2Data['turnover_status']);
|
||||
$this->assertFalse($m2Data['is_turnovered']);
|
||||
$this->assertEquals(50.0, $m2Data['progress_percentage']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user