Implement revised retention workflow, overdue late penalty calculation, payment proof verification, and dynamic ledger reconciliation
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user