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

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