Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.49% covered (success)
99.49%
194 / 195
93.33% covered (success)
93.33%
14 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
ProjectGanttApiController
99.48% covered (success)
99.48%
193 / 194
93.33% covered (success)
93.33%
14 / 15
35
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
 actionProjectGantt
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
3
 actionStageGantt
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 actionAllProjectsGantt
95.83% covered (success)
95.83%
23 / 24
0.00% covered (danger)
0.00%
0 / 1
3
 fetchProjectRow
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 fetchStageRow
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 fetchProjectStages
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 fetchProjectTasks
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 fetchStageTasks
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 buildStageGanttItems
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
1 / 1
3
 createProjectRootItem
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
1
 formatGanttItem
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 hydrateProject
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 hydrateStage
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
4
 hydrateTask
100.00% covered (success)
100.00%
19 / 19
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\Presentation\Api;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Modules\Projects\Application\Service\ProjectGanttDataTransformer;
12use App\Modules\Projects\Domain\Model\Project;
13use App\Modules\Projects\Domain\Model\ProjectStage;
14use App\Modules\Projects\Domain\Model\ProjectTask;
15use App\Shared\Infrastructure\Http\ApiResponseTrait;
16use Nyholm\Psr7\Factory\Psr17Factory;
17use PDO;
18use Psr\Http\Message\ResponseInterface;
19
20/**
21 * Project Gantt Chart REST API Controller.
22 *
23 * Endpoints:
24 * - GET /api/v1/projects/{id}/gantt        -> Gantt dataset for a single project (project + stages + tasks)
25 * - GET /api/v1/project-stages/{id}/gantt  -> Gantt dataset for a single stage (stage + tasks)
26 * - GET /api/v1/projects/gantt             -> Global Gantt dataset for all active projects
27 *
28 * @package App\Modules\Projects\Presentation\Api
29 */
30final readonly class ProjectGanttApiController
31{
32    use ApiResponseTrait;
33
34    private const string ERR_PROJECT_NOT_FOUND = 'Project not found.';
35    private const string ERR_STAGE_NOT_FOUND = 'Project stage not found.';
36
37    /**
38     * ProjectGanttApiController constructor.
39     *
40     * @param PDO                        $pdo         Active database PDO handle.
41     * @param ProjectGanttDataTransformer $transformer Gantt data transformation service.
42     * @param Psr17Factory               $psr17       PSR-17 response factory.
43     */
44    public function __construct(
45        private PDO $pdo,
46        private ProjectGanttDataTransformer $transformer,
47        private Psr17Factory $psr17
48    ) {
49    }
50
51    /**
52     * Returns Gantt tree dataset for a specific project.
53     *
54     * @param int $id Project primary key.
55     * @return ResponseInterface JSON response with Gantt nodes.
56     */
57    public function actionProjectGantt(int $id): ResponseInterface
58    {
59        $project = $this->fetchProjectRow($id);
60        if ($project === null) {
61            return $this->jsonError($this->psr17, self::ERR_PROJECT_NOT_FOUND, 404);
62        }
63
64        $stages = $this->fetchProjectStages($id);
65        $tasks = $this->fetchProjectTasks($id);
66
67        $projectModel = $this->hydrateProject($project);
68        $stageModels = array_map($this->hydrateStage(...), $stages);
69        $taskModels = array_map($this->hydrateTask(...), $tasks);
70
71        $transformed = $this->transformer->transform($projectModel, $stageModels, $taskModels);
72        $projectItem = $this->createProjectRootItem($project);
73
74        $formattedItems = [$projectItem];
75        foreach ($transformed['items'] as $item) {
76            $formattedItems[] = $this->formatGanttItem($item);
77        }
78
79        return $this->jsonSuccess($this->psr17, [
80            'project_id'   => $id,
81            'project_name' => (string) ($project['project_name'] ?? ''),
82            'items'        => $formattedItems,
83            'links'        => $transformed['links'],
84            'total_items'  => count($formattedItems),
85        ]);
86    }
87
88    /**
89     * Returns Gantt dataset for a specific project stage.
90     *
91     * @param int $id Stage primary key.
92     * @return ResponseInterface JSON response with stage Gantt nodes.
93     */
94    public function actionStageGantt(int $id): ResponseInterface
95    {
96        $stage = $this->fetchStageRow($id);
97        if ($stage === null) {
98            return $this->jsonError($this->psr17, self::ERR_STAGE_NOT_FOUND, 404);
99        }
100
101        $tasks = $this->fetchStageTasks($id);
102        $items = $this->buildStageGanttItems($stage, $tasks);
103
104        return $this->jsonSuccess($this->psr17, [
105            'stage_id'    => $id,
106            'stage_name'  => (string) ($stage['stage_name'] ?? ''),
107            'items'       => $items,
108            'total_items' => count($items),
109        ]);
110    }
111
112    /**
113     * Returns global Gantt dataset for all active projects.
114     *
115     * @return ResponseInterface JSON response with multi-project Gantt nodes.
116     */
117    public function actionAllProjectsGantt(): ResponseInterface
118    {
119        $sql = "SELECT `id`, `project_name`, `start_date`, `target_end_date`,
120                       `progress`, `project_status`, `color`
121                FROM   `c_mod_projects_records`
122                WHERE  `special_access` = 1
123                ORDER BY `start_date` ASC, `id` ASC
124                LIMIT 50";
125
126        $stmt = $this->pdo->query($sql);
127        $projects = $stmt->fetchAll(PDO::FETCH_ASSOC);
128
129        $allItems = [];
130        $allLinks = [];
131        foreach ($projects as $project) {
132            $projId = (int) $project['id'];
133            $stages = $this->fetchProjectStages($projId);
134            $tasks = $this->fetchProjectTasks($projId);
135
136            $projectModel = $this->hydrateProject($project);
137            $stageModels = array_map($this->hydrateStage(...), $stages);
138            $taskModels = array_map($this->hydrateTask(...), $tasks);
139
140            $transformed = $this->transformer->transform($projectModel, $stageModels, $taskModels);
141            $projectItem = $this->createProjectRootItem($project);
142
143            $allItems[] = $projectItem;
144            foreach ($transformed['items'] as $item) {
145                $allItems[] = $this->formatGanttItem($item);
146            }
147            $allLinks = array_merge($allLinks, $transformed['links']);
148        }
149
150        return $this->jsonSuccess($this->psr17, [
151            'items'       => $allItems,
152            'links'       => $allLinks,
153            'total_items' => count($allItems),
154        ]);
155    }
156
157    /**
158     * Fetches a single project raw database row.
159     *
160     * @param int $id Project ID.
161     * @return array<string, mixed>|null Project record or null.
162     */
163    private function fetchProjectRow(int $id): ?array
164    {
165        $stmt = $this->pdo->prepare(
166            "SELECT `id`, `project_name`, `project_status`, `start_date`, `target_end_date`,
167                    `progress`, `color`
168             FROM `c_mod_projects_records`
169             WHERE `id` = :id AND `special_access` = 1
170             LIMIT 1"
171        );
172        $stmt->execute([':id' => $id]);
173        $row = $stmt->fetch(PDO::FETCH_ASSOC);
174
175        return is_array($row) ? $row : null;
176    }
177
178    /**
179     * Fetches a single stage raw database row.
180     *
181     * @param int $id Stage ID.
182     * @return array<string, mixed>|null Stage record or null.
183     */
184    private function fetchStageRow(int $id): ?array
185    {
186        $stmt = $this->pdo->prepare(
187            "SELECT `id`, `project_id`, `stage_name`, `start_date`, `end_date`,
188                    `progress`, `stage_status`, `is_milestone`, `sort_order`
189             FROM `c_mod_project_stages_records`
190             WHERE `id` = :id AND `special_access` = 1
191             LIMIT 1"
192        );
193        $stmt->execute([':id' => $id]);
194        $row = $stmt->fetch(PDO::FETCH_ASSOC);
195
196        return is_array($row) ? $row : null;
197    }
198
199    /**
200     * Fetches stages for a given project.
201     *
202     * @param int $projectId Project ID.
203     * @return list<array<string, mixed>> Stages list.
204     */
205    private function fetchProjectStages(int $projectId): array
206    {
207        $sql = "SELECT `id`, `project_id`, `stage_name`, `start_date`, `end_date`,
208                       `progress`, `stage_status`, `is_milestone`, `sort_order`
209                FROM   `c_mod_project_stages_records`
210                WHERE  `project_id` = :pid AND `special_access` = 1
211                ORDER BY `sort_order` ASC, `id` ASC";
212
213        $stmt = $this->pdo->prepare($sql);
214        $stmt->execute([':pid' => $projectId]);
215
216        return $stmt->fetchAll(PDO::FETCH_ASSOC);
217    }
218
219    /**
220     * Fetches tasks for a given project.
221     *
222     * @param int $projectId Project ID.
223     * @return list<array<string, mixed>> Tasks list.
224     */
225    private function fetchProjectTasks(int $projectId): array
226    {
227        $sql = "SELECT `id`, `project_id`, `stage_id`, `task_name`, `start_date`,
228                       `end_date`, `progress`, `task_status`, `depends_on_task_id`,
229                       `priority`, `estimated_hours`, `actual_hours`
230                FROM   `c_mod_project_tasks_records`
231                WHERE  `project_id` = :pid AND `special_access` = 1
232                ORDER BY `start_date` ASC, `id` ASC";
233
234        $stmt = $this->pdo->prepare($sql);
235        $stmt->execute([':pid' => $projectId]);
236
237        return $stmt->fetchAll(PDO::FETCH_ASSOC);
238    }
239
240    /**
241     * Fetches tasks for a specific stage.
242     *
243     * @param int $stageId Stage ID.
244     * @return list<array<string, mixed>> Tasks list.
245     */
246    private function fetchStageTasks(int $stageId): array
247    {
248        $sql = "SELECT `id`, `project_id`, `stage_id`, `task_name`, `start_date`,
249                       `end_date`, `progress`, `task_status`, `depends_on_task_id`,
250                       `priority`, `estimated_hours`, `actual_hours`
251                FROM   `c_mod_project_tasks_records`
252                WHERE  `stage_id` = :sid AND `special_access` = 1
253                ORDER BY `start_date` ASC, `id` ASC";
254
255        $stmt = $this->pdo->prepare($sql);
256        $stmt->execute([':sid' => $stageId]);
257
258        return $stmt->fetchAll(PDO::FETCH_ASSOC);
259    }
260
261    /**
262     * Builds Gantt items for a single stage and its tasks.
263     *
264     * @param array<string, mixed>        $stage Stage row.
265     * @param list<array<string, mixed>>  $tasks Tasks rows.
266     * @return list<array<string, mixed>> Structured Gantt nodes.
267     */
268    private function buildStageGanttItems(array $stage, array $tasks): array
269    {
270        $stageId = (int) $stage['id'];
271        $stageNodeId = 'stage_' . $stageId;
272
273        $items = [
274            [
275                'id'           => $stageNodeId,
276                'name'         => (string) ($stage['stage_name'] ?? 'Etap #' . $stageId),
277                'start'        => (string) ($stage['start_date'] ?? date('Y-m-d')),
278                'end'          => (string) ($stage['end_date'] ?? date('Y-m-d', strtotime('+7 days'))),
279                'progress'     => (float) ($stage['progress'] ?? 0.0),
280                'dependencies' => '',
281                'custom_class' => 'gantt-stage',
282                'type'         => 'stage',
283                'record_id'    => $stageId,
284                'status'       => (string) ($stage['stage_status'] ?? 'planned'),
285                'is_milestone' => (bool) ($stage['is_milestone'] ?? false),
286            ],
287        ];
288
289        foreach ($tasks as $task) {
290            $taskId = (int) $task['id'];
291            $dep = !empty($task['depends_on_task_id']) ? 'task_' . (int) $task['depends_on_task_id'] : '';
292
293            $items[] = [
294                'id'           => 'task_' . $taskId,
295                'name'         => (string) ($task['task_name'] ?? 'Zadanie #' . $taskId),
296                'start'        => (string) ($task['start_date'] ?? $stage['start_date'] ?? date('Y-m-d')),
297                'end'          => (string) ($task['end_date'] ?? $stage['end_date'] ?? date('Y-m-d')),
298                'progress'     => (float) ($task['progress'] ?? 0.0),
299                'dependencies' => $dep,
300                'custom_class' => 'gantt-task gantt-status-' . ($task['task_status'] ?? 'planned'),
301                'type'         => 'task',
302                'record_id'    => $taskId,
303                'parent_id'    => $stageNodeId,
304                'status'       => (string) ($task['task_status'] ?? 'planned'),
305                'priority'     => (string) ($task['priority'] ?? 'normal'),
306            ];
307        }
308
309        return $items;
310    }
311
312    /**
313     * Creates project root Gantt node.
314     *
315     * @param array<string, mixed> $project Project row.
316     * @return array<string, mixed> Project node.
317     */
318    private function createProjectRootItem(array $project): array
319    {
320        $projId = (int) $project['id'];
321        return [
322            'id'           => 'project_' . $projId,
323            'raw_id'       => $projId,
324            'type'         => 'project',
325            'name'         => (string) ($project['project_name'] ?? 'Projekt #' . $projId),
326            'text'         => (string) ($project['project_name'] ?? 'Projekt #' . $projId),
327            'start'        => (string) ($project['start_date'] ?? date('Y-m-d')),
328            'start_date'   => (string) ($project['start_date'] ?? date('Y-m-d')),
329            'end'          => (string) ($project['target_end_date'] ?? date('Y-m-d', strtotime('+30 days'))),
330            'end_date'     => (string) ($project['target_end_date'] ?? date('Y-m-d', strtotime('+30 days'))),
331            'progress'     => (float) ($project['progress'] ?? 0.0),
332            'parent'       => '0',
333            'parent_id'    => null,
334            'status'       => (string) ($project['project_status'] ?? 'planned'),
335            'color'        => '#206bc4',
336        ];
337    }
338
339    /**
340     * Formats transformer item with unified property aliases.
341     *
342     * @param array<string, mixed> $item Raw item.
343     * @return array<string, mixed> Formatted item.
344     */
345    private function formatGanttItem(array $item): array
346    {
347        return array_merge($item, [
348            'name'      => $item['text'] ?? ($item['name'] ?? ''),
349            'start'     => $item['start_date'] ?? ($item['start'] ?? ''),
350            'end'       => $item['end_date'] ?? ($item['end'] ?? ''),
351            'parent_id' => $item['parent'] !== '0' ? $item['parent'] : null,
352        ]);
353    }
354
355    /**
356     * Hydrates Project domain aggregate from database row.
357     */
358    private function hydrateProject(array $row): Project
359    {
360        return new Project(
361            id: (int) $row['id'],
362            name: (string) ($row['project_name'] ?? ''),
363            type: (string) ($row['project_type'] ?? 'implementation'),
364            status: (string) ($row['project_status'] ?? 'planned'),
365            priority: (string) ($row['priority'] ?? 'normal'),
366            startDate: isset($row['start_date']) ? (string) $row['start_date'] : null,
367            targetEndDate: isset($row['target_end_date']) ? (string) $row['target_end_date'] : null,
368            actualEndDate: isset($row['actual_end_date']) ? (string) $row['actual_end_date'] : null,
369            estimatedBudget: (float) ($row['estimated_budget'] ?? 0.0),
370            progress: (float) ($row['progress'] ?? 0.0),
371            estimatedHours: (float) ($row['estimated_hours'] ?? 0.0),
372            actualHours: (float) ($row['actual_hours'] ?? 0.0),
373            color: (string) ($row['color'] ?? 'primary'),
374        );
375    }
376
377    /**
378     * Hydrates ProjectStage domain entity from database row.
379     */
380    private function hydrateStage(array $row): ProjectStage
381    {
382        return new ProjectStage(
383            id: (int) $row['id'],
384            name: (string) ($row['stage_name'] ?? ''),
385            projectId: (int) ($row['project_id'] ?? 0),
386            endDate: (string) ($row['end_date'] ?? date('Y-m-d')),
387            parentId: isset($row['parent_id']) ? (int) $row['parent_id'] : null,
388            type: (string) ($row['stage_type'] ?? 'development'),
389            status: (string) ($row['stage_status'] ?? 'planned'),
390            priority: (string) ($row['priority'] ?? 'normal'),
391            startDate: isset($row['start_date']) ? (string) $row['start_date'] : null,
392            actualEndDate: isset($row['actual_end_date']) ? (string) $row['actual_end_date'] : null,
393            progress: (float) ($row['progress'] ?? 0.0),
394            estimatedHours: (float) ($row['estimated_hours'] ?? 0.0),
395            actualHours: (float) ($row['actual_hours'] ?? 0.0),
396            isMilestone: !empty($row['is_milestone']),
397            sortOrder: (int) ($row['sort_order'] ?? 10),
398        );
399    }
400
401    /**
402     * Hydrates ProjectTask domain entity from database row.
403     */
404    private function hydrateTask(array $row): ProjectTask
405    {
406        return new ProjectTask(
407            id: (int) $row['id'],
408            name: (string) ($row['task_name'] ?? ''),
409            projectId: (int) ($row['project_id'] ?? 0),
410            stageId: (int) ($row['stage_id'] ?? 0),
411            startDate: (string) ($row['start_date'] ?? date('Y-m-d')),
412            endDate: (string) ($row['end_date'] ?? date('Y-m-d')),
413            status: (string) ($row['task_status'] ?? 'planned'),
414            priority: (string) ($row['priority'] ?? 'normal'),
415            type: (string) ($row['task_type'] ?? 'task'),
416            estimatedHours: (float) ($row['estimated_hours'] ?? 0.0),
417            actualHours: (float) ($row['actual_hours'] ?? 0.0),
418            progress: (int) ($row['progress'] ?? 0),
419            actualEndDate: isset($row['actual_end_date']) ? (string) $row['actual_end_date'] : null,
420            internalNumber: isset($row['internal_number']) ? (string) $row['internal_number'] : null,
421            parentTaskId: isset($row['parent_task_id']) ? (int) $row['parent_task_id'] : null,
422            dependsOnTaskId: isset($row['depends_on_task_id']) ? (int) $row['depends_on_task_id'] : null,
423            owner: (int) ($row['owner'] ?? 0),
424        );
425    }
426}