Implement revised retention workflow, overdue late penalty calculation, payment proof verification, and dynamic ledger reconciliation

This commit is contained in:
Ajjj
2026-08-14 15:29:55 +08:00
parent ccbd23d474
commit 1ca1d286cd
39 changed files with 4103 additions and 573 deletions

View File

@@ -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,
]);
}

View File

@@ -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));
}
}

View 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']);
}
}

View File

@@ -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']);
}
}