Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
104 / 104
100.00% covered (success)
100.00%
11 / 11
CRAP
100.00% covered (success)
100.00%
1 / 1
WorkTimeRollupService
100.00% covered (success)
100.00%
103 / 103
100.00% covered (success)
100.00%
11 / 11
44
100.00% covered (success)
100.00%
1 / 1
 resolvePolymorphicReferences
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
9
 applyProcessRef
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 applySubprocessRef
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 applySubSubprocessRef
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 applyPartyRef
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 calculateDuration
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 calculateTotalAmount
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
 rollupWorkTimeToDatabase
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
7
 rollupTaskHours
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 rollupStageHours
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 rollupProjectHours
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
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\WorkTime\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Engine\Application\Transformer\PolymorphicRelationTransformer;
12use PDO;
13
14/**
15 * Service handling polymorphic reference resolution, duration calculation,
16 * and roll-up aggregation of logged work time across tasks, stages, and projects.
17 *
18 * @package App\Modules\WorkTime\Application\Service
19 */
20final class WorkTimeRollupService
21{
22    /**
23     * Resolves polymorphic references into direct indexed entity IDs.
24     *
25     * @param array<string, mixed> $data Raw form or database input data.
26     * @return array{
27     *     project_id: int|null,
28     *     stage_id: int|null,
29     *     task_id: int|null,
30     *     company_id: int|null,
31     *     contract_id: int|null,
32     *     ticket_id: int|null
33     * } Resolved entity identifiers.
34     */
35    public function resolvePolymorphicReferences(array $data): array
36    {
37        $resolved = [
38            'project_id'  => isset($data['project_id']) ? (int) $data['project_id'] : null,
39            'stage_id'    => isset($data['stage_id']) ? (int) $data['stage_id'] : null,
40            'task_id'     => isset($data['task_id']) ? (int) $data['task_id'] : null,
41            'company_id'  => isset($data['company_id']) ? (int) $data['company_id'] : null,
42            'partner_id'  => isset($data['partner_id']) ? (int) $data['partner_id'] : null,
43            'contact_id'  => isset($data['contact_id']) ? (int) $data['contact_id'] : null,
44            'contract_id' => isset($data['contract_id']) ? (int) $data['contract_id'] : null,
45            'ticket_id'   => isset($data['ticket_id']) ? (int) $data['ticket_id'] : null,
46        ];
47
48        $this->applyProcessRef($data['process_ref'] ?? null, $resolved);
49        $this->applySubprocessRef($data['subprocess_ref'] ?? null, $resolved);
50        $this->applySubSubprocessRef($data['sub_subprocess_ref'] ?? null, $resolved);
51        $this->applyPartyRef($data['related_party_ref'] ?? $data['company_ref'] ?? null, $resolved);
52
53        return $resolved;
54    }
55
56    /**
57     * @param array<string, int|null> $resolved
58     */
59    private function applyProcessRef(?string $ref, array &$resolved): void
60    {
61        $process = PolymorphicRelationTransformer::parse($ref);
62        if ($process === null) {
63            return;
64        }
65
66        if ($process['module'] === 'projects') {
67            $resolved['project_id'] = $process['id'];
68        } elseif ($process['module'] === 'contracts') {
69            $resolved['contract_id'] = $process['id'];
70        }
71    }
72
73    /**
74     * @param array<string, int|null> $resolved
75     */
76    private function applySubprocessRef(?string $ref, array &$resolved): void
77    {
78        $subprocess = PolymorphicRelationTransformer::parse($ref);
79        if ($subprocess === null) {
80            return;
81        }
82
83        if ($subprocess['module'] === 'project_stages') {
84            $resolved['stage_id'] = $subprocess['id'];
85        } elseif ($subprocess['module'] === 'project_tasks') {
86            $resolved['task_id'] = $subprocess['id'];
87        } elseif ($subprocess['module'] === 'tickets') {
88            $resolved['ticket_id'] = $subprocess['id'];
89        }
90    }
91
92    /**
93     * @param array<string, int|null> $resolved
94     */
95    private function applySubSubprocessRef(?string $ref, array &$resolved): void
96    {
97        $sub = PolymorphicRelationTransformer::parse($ref);
98        if ($sub !== null && $sub['module'] === 'project_tasks') {
99            $resolved['task_id'] = $sub['id'];
100        }
101    }
102
103    /**
104     * @param array<string, int|null> $resolved
105     */
106    private function applyPartyRef(?string $ref, array &$resolved): void
107    {
108        $party = PolymorphicRelationTransformer::parse($ref);
109        if ($party === null) {
110            return;
111        }
112
113        if ($party['module'] === 'companies') {
114            $resolved['company_id'] = $party['id'];
115        } elseif ($party['module'] === 'partners') {
116            $resolved['partner_id'] = $party['id'];
117        } elseif ($party['module'] === 'contacts') {
118            $resolved['contact_id'] = $party['id'];
119        }
120    }
121
122    /**
123     * Calculates duration in minutes and fractional hours between two datetime timestamps.
124     *
125     * @param string $startDate ISO datetime string.
126     * @param string $endDate   ISO datetime string.
127     * @return array{duration_minutes: int, duration_hours: float}
128     */
129    public function calculateDuration(string $startDate, string $endDate): array
130    {
131        $startTs = strtotime($startDate);
132        $endTs = strtotime($endDate);
133
134        if ($startTs === false || $endTs === false || $endTs <= $startTs) {
135            return [
136                'duration_minutes' => 0,
137                'duration_hours'   => 0.0,
138            ];
139        }
140
141        $minutes = (int) round(($endTs - $startTs) / 60);
142        $hours = round($minutes / 60, 2);
143
144        return [
145            'duration_minutes' => $minutes,
146            'duration_hours'   => $hours,
147        ];
148    }
149
150    /**
151     * Computes the total monetary billing amount.
152     *
153     * @param float      $durationHours Duration of the entry in hours.
154     * @param float|null $hourlyRate    Hourly monetary billing rate.
155     * @param bool       $isBillable    Whether the work item is billable.
156     * @return float|null Computed total amount, or null if non-billable.
157     */
158    public function calculateTotalAmount(float $durationHours, ?float $hourlyRate, bool $isBillable = true): ?float
159    {
160        if (!$isBillable || $hourlyRate === null || $hourlyRate <= 0.0) {
161            return null;
162        }
163
164        return round($durationHours * $hourlyRate, 2);
165    }
166
167    /**
168     * Rolls up logged hours from work time entries into task, stage, and project database tables.
169     *
170     * @param PDO      $pdo       Active database connection PDO instance.
171     * @param int|null $taskId    Optional target task ID.
172     * @param int|null $stageId   Optional target stage ID.
173     * @param int|null $projectId Optional target project ID.
174     * @return array{task_hours: float, stage_hours: float, project_hours: float}
175     */
176    public function rollupWorkTimeToDatabase(
177        PDO $pdo,
178        ?int $taskId,
179        ?int $stageId,
180        ?int $projectId
181    ): array {
182        $taskHours = 0.0;
183        $stageHours = 0.0;
184        $projectHours = 0.0;
185
186        if ($taskId !== null && $taskId > 0) {
187            $taskHours = $this->rollupTaskHours($pdo, $taskId);
188        }
189
190        if ($stageId !== null && $stageId > 0) {
191            $stageHours = $this->rollupStageHours($pdo, $stageId);
192        }
193
194        if ($projectId !== null && $projectId > 0) {
195            $projectHours = $this->rollupProjectHours($pdo, $projectId);
196        }
197
198        return [
199            'task_hours'    => $taskHours,
200            'stage_hours'   => $stageHours,
201            'project_hours' => $projectHours,
202        ];
203    }
204
205    /**
206     * Recalculates and updates actual hours for a task.
207     */
208    private function rollupTaskHours(PDO $pdo, int $taskId): float
209    {
210        $sql = "SELECT COALESCE(SUM(`duration_hours`), 0.0) AS `total`
211                FROM   `c_mod_work_time_records`
212                WHERE  `task_id` = :task_id
213                  AND  `status` != 'rejected'";
214
215        $stmt = $pdo->prepare($sql);
216        $stmt->execute([':task_id' => $taskId]);
217        $hours = round((float) $stmt->fetchColumn(), 2);
218
219        $update = $pdo->prepare("UPDATE `c_mod_project_tasks_records` SET `actual_hours` = :h WHERE `id` = :id");
220        $update->execute([':h' => $hours, ':id' => $taskId]);
221
222        return $hours;
223    }
224
225    /**
226     * Recalculates and updates actual hours for a stage.
227     */
228    private function rollupStageHours(PDO $pdo, int $stageId): float
229    {
230        $sql = "SELECT COALESCE(SUM(`actual_hours`), 0.0) AS `total`
231                FROM   `c_mod_project_tasks_records`
232                WHERE  `stage_id` = :stage_id";
233
234        $stmt = $pdo->prepare($sql);
235        $stmt->execute([':stage_id' => $stageId]);
236        $taskTotal = (float) $stmt->fetchColumn();
237
238        $directSql = "SELECT COALESCE(SUM(`duration_hours`), 0.0) AS `total`
239                      FROM   `c_mod_work_time_records`
240                      WHERE  `stage_id` = :stage_id
241                        AND  `task_id` IS NULL
242                        AND  `status` != 'rejected'";
243
244        $directStmt = $pdo->prepare($directSql);
245        $directStmt->execute([':stage_id' => $stageId]);
246        $directTotal = (float) $directStmt->fetchColumn();
247
248        $total = round($taskTotal + $directTotal, 2);
249
250        $update = $pdo->prepare("UPDATE `c_mod_project_stages_records` SET `actual_hours` = :h WHERE `id` = :id");
251        $update->execute([':h' => $total, ':id' => $stageId]);
252
253        return $total;
254    }
255
256    /**
257     * Recalculates and updates actual hours for a project.
258     */
259    private function rollupProjectHours(PDO $pdo, int $projectId): float
260    {
261        $sql = "SELECT COALESCE(SUM(`duration_hours`), 0.0) AS `total`
262                FROM   `c_mod_work_time_records`
263                WHERE  `project_id` = :project_id
264                  AND  `status` != 'rejected'";
265
266        $stmt = $pdo->prepare($sql);
267        $stmt->execute([':project_id' => $projectId]);
268        $hours = round((float) $stmt->fetchColumn(), 2);
269
270        $update = $pdo->prepare("UPDATE `c_mod_projects_records` SET `actual_hours` = :h WHERE `id` = :id");
271        $update->execute([':h' => $hours, ':id' => $projectId]);
272
273        return $hours;
274    }
275}