Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
99.49% |
194 / 195 |
|
93.33% |
14 / 15 |
CRAP | |
0.00% |
0 / 1 |
| ProjectGanttApiController | |
99.48% |
193 / 194 |
|
93.33% |
14 / 15 |
35 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| actionProjectGantt | |
100.00% |
20 / 20 |
|
100.00% |
1 / 1 |
3 | |||
| actionStageGantt | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
2 | |||
| actionAllProjectsGantt | |
95.83% |
23 / 24 |
|
0.00% |
0 / 1 |
3 | |||
| fetchProjectRow | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
| fetchStageRow | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
| fetchProjectStages | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
| fetchProjectTasks | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
| fetchStageTasks | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
| buildStageGanttItems | |
100.00% |
35 / 35 |
|
100.00% |
1 / 1 |
3 | |||
| createProjectRootItem | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
1 | |||
| formatGanttItem | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
| hydrateProject | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
4 | |||
| hydrateStage | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
4 | |||
| hydrateTask | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
5 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | /** @license For full copyright and license information, please see the LICENSE.md file. */ |
| 6 | |
| 7 | namespace App\Modules\Projects\Presentation\Api; |
| 8 | |
| 9 | defined('AMMONLY_APP') || exit('Direct script access is forbidden.'); |
| 10 | |
| 11 | use App\Modules\Projects\Application\Service\ProjectGanttDataTransformer; |
| 12 | use App\Modules\Projects\Domain\Model\Project; |
| 13 | use App\Modules\Projects\Domain\Model\ProjectStage; |
| 14 | use App\Modules\Projects\Domain\Model\ProjectTask; |
| 15 | use App\Shared\Infrastructure\Http\ApiResponseTrait; |
| 16 | use Nyholm\Psr7\Factory\Psr17Factory; |
| 17 | use PDO; |
| 18 | use 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 | */ |
| 30 | final 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 | } |