Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.26% covered (success)
94.26%
115 / 122
66.67% covered (warning)
66.67%
4 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
KanbanStatusTransitionService
94.21% covered (success)
94.21%
114 / 121
66.67% covered (warning)
66.67%
4 / 6
21.09
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 updateStatus
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 applyStatusUpdate
66.67% covered (warning)
66.67%
12 / 18
0.00% covered (danger)
0.00%
0 / 1
4.59
 cascadeProgressRollup
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
9
 recalculateStageProgress
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
2
 recalculateProjectProgress
100.00% covered (success)
100.00%
48 / 48
100.00% covered (success)
100.00%
1 / 1
3
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\Core\Engine\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Modules\Projects\Application\Service\ProjectProgressCalculator;
12use App\Modules\Projects\Domain\Model\ProjectStage;
13use App\Modules\Projects\Domain\Model\ProjectTask;
14use PDO;
15use PDOException;
16
17/**
18 * Universal Kanban Status Transition and Progress Rollup Service.
19 *
20 * Updates record status upon Kanban card Drag & Drop and cascades progress
21 * rollups through task, stage, and project hierarchies.
22 *
23 * @package App\Core\Engine\Application\Service
24 */
25final readonly class KanbanStatusTransitionService
26{
27    /**
28     * KanbanStatusTransitionService constructor.
29     *
30     * @param PDO                       $pdo        Active database PDO connection handle.
31     * @param ProjectProgressCalculator $calculator Project progress calculator service.
32     */
33    public function __construct(
34        private PDO $pdo,
35        private ProjectProgressCalculator $calculator
36    ) {
37    }
38
39    /**
40     * Executes status update and cascading progress rollup.
41     *
42     * @param array<string, mixed> $config    Module configuration.
43     * @param string               $module    Module machine name.
44     * @param int                  $id        Record primary key.
45     * @param string               $newStatus Target status key.
46     * @return array{success: bool, rollup: array<string, mixed>}
47     */
48    public function updateStatus(array $config, string $module, int $id, string $newStatus): array
49    {
50        $updated = $this->applyStatusUpdate($config, $id, $newStatus);
51        if (!$updated) {
52            return ['success' => false, 'rollup' => []];
53        }
54
55        $rollupInfo = $this->cascadeProgressRollup($module, $id);
56
57        return ['success' => true, 'rollup' => $rollupInfo];
58    }
59
60    /**
61     * Applies status update to database table.
62     *
63     * @param array<string, mixed> $cfg       Module configuration.
64     * @param int                  $id        Record primary key.
65     * @param string               $newStatus New status key.
66     * @return bool True on successful update.
67     */
68    public function applyStatusUpdate(array $cfg, int $id, string $newStatus): bool
69    {
70        $table = (string) $cfg['table'];
71        $statusCol = (string) $cfg['status_col'];
72
73        $sql = "UPDATE `{$table}` SET `{$statusCol}` = :status";
74        $params = [':status' => $newStatus, ':id' => $id];
75
76        if ($newStatus === ProjectProgressCalculator::STATUS_COMPLETED) {
77            $sql .= ", `progress` = 100, `actual_end_date` = COALESCE(`actual_end_date`, :now)";
78            $params[':now'] = date('Y-m-d H:i:s');
79        } elseif ($newStatus === ProjectProgressCalculator::STATUS_PLANNED) {
80            $sql .= ", `progress` = 0";
81        }
82
83        $sql .= " WHERE `id` = :id";
84
85        try {
86            $stmt = $this->pdo->prepare($sql);
87            $stmt->execute($params);
88            return $stmt->rowCount() > 0;
89        } catch (PDOException) {
90            // If progress column does not exist (e.g., tickets), update only status column
91            $sqlFallback = "UPDATE `{$table}` SET `{$statusCol}` = :status WHERE `id` = :id";
92            $stmtFallback = $this->pdo->prepare($sqlFallback);
93            $stmtFallback->execute([':status' => $newStatus, ':id' => $id]);
94            return $stmtFallback->rowCount() > 0;
95        }
96    }
97
98    /**
99     * Triggers cascade progress rollup in ProjectProgressCalculator.
100     *
101     * @param string $module   Module machine name.
102     * @param int    $recordId Record primary key.
103     * @return array<string, mixed>
104     */
105    public function cascadeProgressRollup(string $module, int $recordId): array
106    {
107        if ($module !== 'project_tasks') {
108            return ['cascaded' => false];
109        }
110
111        $stmt = $this->pdo->prepare(
112            "SELECT `project_id`, `stage_id` FROM `c_mod_project_tasks_records` WHERE `id` = :id LIMIT 1"
113        );
114        $stmt->execute([':id' => $recordId]);
115        $task = $stmt->fetch(PDO::FETCH_ASSOC);
116
117        if (!is_array($task)) {
118            return ['cascaded' => false];
119        }
120
121        $stageId = isset($task['stage_id']) ? (int) $task['stage_id'] : null;
122        $projectId = isset($task['project_id']) ? (int) $task['project_id'] : null;
123
124        if ($stageId !== null && $stageId > 0) {
125            $this->recalculateStageProgress($stageId);
126        }
127
128        if ($projectId !== null && $projectId > 0) {
129            $this->recalculateProjectProgress($projectId);
130        }
131
132        return [
133            'cascaded'   => true,
134            'stage_id'   => $stageId,
135            'project_id' => $projectId,
136        ];
137    }
138
139    /**
140     * Recalculates single stage progress based on child tasks.
141     *
142     * @param int $stageId Project stage ID.
143     */
144    public function recalculateStageProgress(int $stageId): void
145    {
146        $stmt = $this->pdo->prepare(
147            "SELECT `id`, `project_id`, `stage_id`, `task_name`, `start_date`, `end_date`,
148                    `progress`, `task_status`, `priority`, `estimated_hours`, `actual_hours`
149             FROM `c_mod_project_tasks_records`
150             WHERE `stage_id` = :sid"
151        );
152        $stmt->execute([':sid' => $stageId]);
153        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
154
155        $taskModels = [];
156        foreach ($rows as $r) {
157            $taskModels[] = new ProjectTask(
158                id: (int) $r['id'],
159                name: (string) ($r['task_name'] ?? ''),
160                projectId: (int) ($r['project_id'] ?? 0),
161                stageId: (int) ($r['stage_id'] ?? 0),
162                startDate: (string) ($r['start_date'] ?? date('Y-m-d')),
163                endDate: (string) ($r['end_date'] ?? date('Y-m-d')),
164                status: (string) ($r['task_status'] ?? 'planned'),
165                priority: (string) ($r['priority'] ?? 'normal'),
166                estimatedHours: (float) ($r['estimated_hours'] ?? 0.0),
167                actualHours: (float) ($r['actual_hours'] ?? 0.0),
168                progress: (int) ($r['progress'] ?? 0),
169            );
170        }
171
172        $metrics = $this->calculator->calculateStageMetrics($taskModels);
173
174        $updateSql = "UPDATE `c_mod_project_stages_records`
175                      SET `progress` = :prog, `actual_hours` = :act
176                      WHERE `id` = :id";
177        $this->pdo->prepare($updateSql)->execute([
178            ':prog' => $metrics['progress'],
179            ':act'  => round($metrics['actual_hours'], 2),
180            ':id'   => $stageId,
181        ]);
182    }
183
184    /**
185     * Recalculates single project progress based on child stages.
186     *
187     * @param int $projectId Project ID.
188     */
189    public function recalculateProjectProgress(int $projectId): void
190    {
191        $stmt = $this->pdo->prepare(
192            "SELECT `id`, `project_id`, `stage_name`, `end_date`, `progress`,
193                    `stage_status`, `estimated_hours`, `actual_hours`, `is_milestone`
194             FROM `c_mod_project_stages_records`
195             WHERE `project_id` = :pid"
196        );
197        $stmt->execute([':pid' => $projectId]);
198        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
199
200        $stageModels = [];
201        foreach ($rows as $r) {
202            $stageModels[] = new ProjectStage(
203                id: (int) $r['id'],
204                name: (string) ($r['stage_name'] ?? ''),
205                projectId: (int) ($r['project_id'] ?? 0),
206                endDate: (string) ($r['end_date'] ?? date('Y-m-d')),
207                status: (string) ($r['stage_status'] ?? 'planned'),
208                progress: (float) ($r['progress'] ?? 0.0),
209                estimatedHours: (float) ($r['estimated_hours'] ?? 0.0),
210                actualHours: (float) ($r['actual_hours'] ?? 0.0),
211                isMilestone: !empty($r['is_milestone']),
212            );
213        }
214
215        $stmtTasks = $this->pdo->prepare(
216            "SELECT `id`, `project_id`, `stage_id`, `task_name`, `start_date`, `end_date`,
217                    `progress`, `task_status`, `priority`, `estimated_hours`, `actual_hours`
218             FROM `c_mod_project_tasks_records`
219             WHERE `project_id` = :pid"
220        );
221        $stmtTasks->execute([':pid' => $projectId]);
222        $taskRows = $stmtTasks->fetchAll(PDO::FETCH_ASSOC);
223
224        $taskModels = [];
225        foreach ($taskRows as $tr) {
226            $taskModels[] = new ProjectTask(
227                id: (int) $tr['id'],
228                name: (string) ($tr['task_name'] ?? ''),
229                projectId: (int) ($tr['project_id'] ?? 0),
230                stageId: (int) ($tr['stage_id'] ?? 0),
231                startDate: (string) ($tr['start_date'] ?? date('Y-m-d')),
232                endDate: (string) ($tr['end_date'] ?? date('Y-m-d')),
233                status: (string) ($tr['task_status'] ?? 'planned'),
234                priority: (string) ($tr['priority'] ?? 'normal'),
235                estimatedHours: (float) ($tr['estimated_hours'] ?? 0.0),
236                actualHours: (float) ($tr['actual_hours'] ?? 0.0),
237                progress: (int) ($tr['progress'] ?? 0),
238            );
239        }
240
241        $metrics = $this->calculator->calculateProjectMetrics($stageModels, $taskModels);
242
243        $updateSql = "UPDATE `c_mod_projects_records`
244                      SET `progress` = :prog, `actual_hours` = :act
245                      WHERE `id` = :id";
246        $this->pdo->prepare($updateSql)->execute([
247            ':prog' => $metrics['progress'],
248            ':act'  => round($metrics['actual_hours'], 2),
249            ':id'   => $projectId,
250        ]);
251    }
252}