Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
64 / 64
100.00% covered (success)
100.00%
3 / 3
CRAP
100.00% covered (success)
100.00%
1 / 1
ProjectProgressCalculator
100.00% covered (success)
100.00%
63 / 63
100.00% covered (success)
100.00%
3 / 3
20
100.00% covered (success)
100.00%
1 / 1
 calculateTaskProgress
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 calculateStageMetrics
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
9
 calculateProjectMetrics
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Modules\Projects\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Modules\Projects\Domain\Model\ProjectStage;
12use App\Modules\Projects\Domain\Model\ProjectTask;
13
14/**
15 * Application service calculating task progress, stage rollups, and project health metrics.
16 *
17 * @package App\Modules\Projects\Application\Service
18 */
19final class ProjectProgressCalculator
20{
21    public const string STATUS_COMPLETED = 'completed';
22    public const string STATUS_IN_PROGRESS = 'in_progress';
23    public const string STATUS_IN_ACCEPTANCE = 'in_acceptance';
24    public const string STATUS_PLANNED = 'planned';
25
26    /**
27     * Calculates task progress percentage based on execution status and logged hours.
28     *
29     * @param string $status         Current task status.
30     * @param int    $currentProgress Explicitly set progress value (0-100).
31     * @param float  $estimatedHours Estimated work effort in hours.
32     * @param float  $actualHours    Actual logged work hours.
33     * @return int Computed progress percentage clamped between 0 and 100.
34     */
35    public function calculateTaskProgress(
36        string $status,
37        int $currentProgress,
38        float $estimatedHours,
39        float $actualHours
40    ): int {
41        $progress = 0;
42        if ($status === self::STATUS_COMPLETED) {
43            $progress = 100;
44        } elseif ($status === self::STATUS_IN_ACCEPTANCE) {
45            $progress = max(90, min(99, $currentProgress));
46        } elseif ($currentProgress > 0) {
47            $progress = min(99, $currentProgress);
48        } elseif ($status === self::STATUS_IN_PROGRESS && $estimatedHours > 0.0) {
49            $estimatedPercent = (int) round(($actualHours / $estimatedHours) * 100);
50            $progress = max(10, min(95, $estimatedPercent));
51        }
52
53        return $progress;
54    }
55
56    /**
57     * Computes aggregated metrics and progress for a single project stage from child tasks.
58     *
59     * @param list<ProjectTask> $tasks List of tasks assigned to this stage.
60     * @return array{estimated_hours: float, actual_hours: float, progress: float, recommended_status: string}
61     */
62    public function calculateStageMetrics(array $tasks): array
63    {
64        if ($tasks === []) {
65            return [
66                'estimated_hours'    => 0.0,
67                'actual_hours'       => 0.0,
68                'progress'           => 0.0,
69                'recommended_status' => self::STATUS_PLANNED,
70            ];
71        }
72
73        $totalEstimated = 0.0;
74        $totalActual = 0.0;
75        $weightedProgressSum = 0.0;
76        $simpleProgressSum = 0;
77        $allCompleted = true;
78        $anyActive = false;
79
80        foreach ($tasks as $task) {
81            $totalEstimated += $task->estimatedHours;
82            $totalActual += $task->actualHours;
83            $weightedProgressSum += ($task->progress * $task->estimatedHours);
84            $simpleProgressSum += $task->progress;
85
86            if ($task->status !== self::STATUS_COMPLETED) {
87                $allCompleted = false;
88            }
89            if ($task->status === self::STATUS_IN_PROGRESS || $task->status === self::STATUS_IN_ACCEPTANCE) {
90                $anyActive = true;
91            }
92        }
93
94        $progress = $totalEstimated > 0.0
95            ? round($weightedProgressSum / $totalEstimated, 2)
96            : round($simpleProgressSum / count($tasks), 2);
97
98        $status = self::STATUS_PLANNED;
99        if ($allCompleted) {
100            $status = self::STATUS_COMPLETED;
101        } elseif ($anyActive) {
102            $status = self::STATUS_IN_PROGRESS;
103        }
104
105        return [
106            'estimated_hours'    => round($totalEstimated, 2),
107            'actual_hours'       => round($totalActual, 2),
108            'progress'           => min(100.0, max(0.0, $progress)),
109            'recommended_status' => $status,
110        ];
111    }
112
113    /**
114     * Computes project-level metrics from all stages and tasks.
115     *
116     * @param list<ProjectStage> $stages List of project stages.
117     * @param list<ProjectTask>  $tasks  List of all project tasks.
118     * @return array{estimated_hours: float, actual_hours: float, progress: float, recommended_status: string}
119     */
120    public function calculateProjectMetrics(array $stages, array $tasks): array
121    {
122        if ($tasks === [] && $stages === []) {
123            return [
124                'estimated_hours'    => 0.0,
125                'actual_hours'       => 0.0,
126                'progress'           => 0.0,
127                'recommended_status' => self::STATUS_PLANNED,
128            ];
129        }
130
131        $stageMetrics = $this->calculateStageMetrics($tasks);
132
133        if ($tasks === [] && $stages !== []) {
134            $stageProgSum = array_reduce(
135                $stages,
136                static fn(float $c, ProjectStage $s): float => $c + $s->progress,
137                0.0
138            );
139            $stageMetrics['progress'] = round($stageProgSum / count($stages), 2);
140        }
141
142        return $stageMetrics;
143    }
144}