Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.87% covered (success)
90.87%
229 / 252
83.33% covered (warning)
83.33%
10 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
WidgetAggregationDataService
90.84% covered (success)
90.84%
228 / 251
83.33% covered (warning)
83.33%
10 / 12
66.05
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
 computeChartData
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 computeSingleFilterData
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
3
 computeMultiFilterCombinedData
100.00% covered (success)
100.00%
41 / 41
100.00% covered (success)
100.00%
1 / 1
6
 buildWhereClause
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
6
 appendCoOwnerCondition
84.44% covered (warning)
84.44%
38 / 45
0.00% covered (danger)
0.00%
0 / 1
8.24
 appendTimeRangeCondition
54.29% covered (warning)
54.29%
19 / 35
0.00% covered (danger)
0.00%
0 / 1
16.74
 formatChartJsPayload
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
9
 alignMultiFilterDatasets
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
3
 buildMetricExpression
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
8
 resolveColumnName
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 resolvePalette
100.00% covered (success)
100.00%
1 / 1
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\Core\Grid\Widget\Application;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Engine\Application\Query\Clause\UniversalFilterClauseBuilder;
12use App\Core\Engine\Domain\Model\FieldMetadata;
13use App\Core\Engine\Domain\Model\FilterMetadata;
14use App\Core\Engine\Domain\Model\ModuleMetadata;
15use App\Core\Engine\Domain\Model\PermissionContext;
16use App\Core\Engine\Domain\Repository\MetadataRepositoryInterface;
17use App\Core\Grid\GridRequest;
18use PDO;
19
20/**
21 * Widget Aggregation Data Service.
22 *
23 * Executes dynamic GROUP BY and aggregation queries (COUNT, SUM, AVG) for analytical charts.
24 * Leverages UniversalFilterClauseBuilder and active module filter rules with strict SQL parameter binding.
25 *
26 * @package App\Core\Grid\Widget\Application
27 */
28final readonly class WidgetAggregationDataService implements WidgetAggregationDataServiceInterface
29{
30    private const string SQL_COUNT = 'COUNT(*)';
31    private const string SQL_SUM = 'COALESCE(SUM(%s), 0)';
32    private const string SQL_AVG = 'COALESCE(ROUND(AVG(%s), 2), 0)';
33    private const string SQL_MIN = 'COALESCE(MIN(%s), 0)';
34    private const string SQL_MAX = 'COALESCE(MAX(%s), 0)';
35    private const string LABEL_EMPTY = '(Brak / None)';
36
37    private const string COLOR_PRIMARY_DEFAULT = '#206bc4';
38    private const string DATE_FORMAT_YMD_HIS = 'Y-m-d H:i:s';
39
40    private const array PALETTES = [
41        'default' => [
42            self::COLOR_PRIMARY_DEFAULT, '#4299e1', '#2fb344', '#f59f00', '#d63939',
43            '#6f42c1', '#0ca678', '#17a2b8', '#fd7e14', '#e83e8c',
44        ],
45        'emerald' => [
46            '#2fb344', '#0ca678', '#20c997', '#63e6be', '#38d9a9',
47            '#099268', '#12b886', '#40c057', '#51cf66', '#8ce99a',
48        ],
49        'sunset'  => [
50            '#f59f00', '#fd7e14', '#e8590c', '#d63939', '#e03131',
51            '#f76707', '#ff922b', '#ffa94d', '#ffc078', '#ffd43b',
52        ],
53        'neon'    => [
54            '#6f42c1', '#7950f2', '#ae3ec9', '#d6336c', '#f06595',
55            '#be4bdb', '#9775fa', '#cc5de8', '#e599f7', '#f783ac',
56        ],
57    ];
58
59    /**
60     * WidgetAggregationDataService constructor.
61     *
62     * @param PDO                          $pdo                Database connection.
63     * @param MetadataRepositoryInterface  $metadataRepository Metadata repository.
64     * @param UniversalFilterClauseBuilder $filterBuilder      Filter clause builder.
65     */
66    public function __construct(
67        private PDO                          $pdo,
68        private MetadataRepositoryInterface  $metadataRepository,
69        private UniversalFilterClauseBuilder $filterBuilder = new UniversalFilterClauseBuilder()
70    ) {
71    }
72
73    /**
74     * Computes Chart.js compatible aggregated data structure for a given widget configuration.
75     *
76     * @param array<string, mixed> $params  Widget parameters JSON array.
77     * @param PermissionContext   $context Current actor security context.
78     * @return array{labels: list<string>, datasets: list<array<string, mixed>>} Chart.js data object.
79     */
80    public function computeChartData(array $params, PermissionContext $context): array
81    {
82        $moduleName = (string) ($params['source_module'] ?? $params['module'] ?? '');
83        if ($moduleName === '') {
84            return ['labels' => [], 'datasets' => []];
85        }
86
87        try {
88            $module = $this->metadataRepository->findModule($moduleName);
89            $fields = $this->metadataRepository->findFields($module->id);
90
91            $multiFilters = $params['multi_filter_combine'] ?? $params['combine_filters'] ?? [];
92            return (is_array($multiFilters) && count($multiFilters) > 1)
93                ? $this->computeMultiFilterCombinedData($module, $fields, $params, $context, $multiFilters)
94                : $this->computeSingleFilterData($module, $fields, $params, $context);
95        } catch (\Throwable) {
96            return ['labels' => [], 'datasets' => []];
97        }
98    }
99
100    /**
101     * Computes single dataset chart representation.
102     *
103     * @param ModuleMetadata            $module
104     * @param array<int, FieldMetadata> $fields
105     * @param array<string, mixed>      $params
106     * @param PermissionContext         $context
107     * @return array{labels: list<string>, datasets: list<array<string, mixed>>}
108     */
109    private function computeSingleFilterData(
110        ModuleMetadata    $module,
111        array             $fields,
112        array             $params,
113        PermissionContext $context
114    ): array {
115        $rawFilterId = $params['source_filter_id'] ?? $params['filter_id'] ?? null;
116        $filterId = $rawFilterId !== null ? (int) $rawFilterId : null;
117        $filter = $this->metadataRepository->findFilter($module->id, $filterId);
118
119        $groupByFieldKey = (string) ($params['group_by_field'] ?? 'status');
120        $metricType = strtolower((string) ($params['metric_type'] ?? 'count'));
121        $metricFieldKey = (string) ($params['metric_field'] ?? '');
122
123        $groupByCol = $this->resolveColumnName($fields, $groupByFieldKey) ?? 'status';
124        $metricExpr = $this->buildMetricExpression($module->tableAlias, $fields, $metricType, $metricFieldKey);
125
126        [$whereSql, $queryParams] = $this->buildWhereClause($module, $fields, $filter, $params, $context);
127
128        $sql = sprintf(
129            'SELECT COALESCE(NULLIF(CAST(`%s`.`%s` AS TEXT), ""), "%s") AS `grp`, %s AS `val` ' .
130            'FROM `%s` AS `%s` ' .
131            '%s ' .
132            'GROUP BY `grp` ' .
133            'ORDER BY `val` DESC ' .
134            'LIMIT 30',
135            $module->tableAlias,
136            $groupByCol,
137            self::LABEL_EMPTY,
138            $metricExpr,
139            $module->tableName,
140            $module->tableAlias,
141            $whereSql !== '' ? 'WHERE ' . $whereSql : ''
142        );
143
144        $stmt = $this->pdo->prepare($sql);
145        $stmt->execute($queryParams);
146        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
147
148        $palette = $this->resolvePalette((string) ($params['color_palette'] ?? 'default'));
149        return $this->formatChartJsPayload(
150            $rows,
151            (string) ($params['chart_type'] ?? 'bar'),
152            $palette,
153            (string) ($params['title'] ?? '')
154        );
155    }
156
157    /**
158     * Computes multi-filter combined series dataset (e.g. Sales Funnel or Pipeline comparison).
159     *
160     * @param ModuleMetadata            $module
161     * @param array<int, FieldMetadata> $fields
162     * @param array<string, mixed>      $params
163     * @param PermissionContext         $context
164     * @param array<int, mixed>         $filterIds
165     * @return array{labels: list<string>, datasets: list<array<string, mixed>>}
166     */
167    private function computeMultiFilterCombinedData(
168        ModuleMetadata    $module,
169        array             $fields,
170        array             $params,
171        PermissionContext $context,
172        array             $filterIds
173    ): array {
174        $groupByFieldKey = (string) ($params['group_by_field'] ?? 'status');
175        $metricType = strtolower((string) ($params['metric_type'] ?? 'count'));
176        $metricFieldKey = (string) ($params['metric_field'] ?? '');
177        $groupByCol = $this->resolveColumnName($fields, $groupByFieldKey) ?? 'status';
178        $metricExpr = $this->buildMetricExpression($module->tableAlias, $fields, $metricType, $metricFieldKey);
179
180        $allLabels = [];
181        $datasets = [];
182        $palette = $this->resolvePalette((string) ($params['color_palette'] ?? 'default'));
183        $colorIdx = 0;
184
185        foreach ($filterIds as $fId) {
186            $filter = $this->metadataRepository->findFilter($module->id, (int) $fId);
187            [$whereSql, $queryParams] = $this->buildWhereClause($module, $fields, $filter, $params, $context);
188
189            $sql = sprintf(
190                'SELECT COALESCE(NULLIF(CAST(`%s`.`%s` AS TEXT), ""), "%s") AS `grp`, %s AS `val` ' .
191                'FROM `%s` AS `%s` %s GROUP BY `grp` ORDER BY `val` DESC LIMIT 20',
192                $module->tableAlias,
193                $groupByCol,
194                self::LABEL_EMPTY,
195                $metricExpr,
196                $module->tableName,
197                $module->tableAlias,
198                $whereSql !== '' ? 'WHERE ' . $whereSql : ''
199            );
200
201            $stmt = $this->pdo->prepare($sql);
202            $stmt->execute($queryParams);
203            $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
204
205            $dataMap = [];
206            foreach ($rows as $r) {
207                $grp = (string) $r['grp'];
208                $dataMap[$grp] = (float) $r['val'];
209                if (!in_array($grp, $allLabels, true)) {
210                    $allLabels[] = $grp;
211                }
212            }
213
214            $color = $palette[$colorIdx % count($palette)];
215            $colorIdx++;
216
217            $datasets[] = [
218                'label'           => $filter->label !== '' ? $filter->label : $filter->name,
219                'dataMap'         => $dataMap,
220                'backgroundColor' => $color,
221                'borderColor'     => $color,
222            ];
223        }
224
225        return $this->alignMultiFilterDatasets($allLabels, $datasets);
226    }
227
228    /**
229     * Builds WHERE clause combining base filter rules, time ranges, and user scope.
230     *
231     * @param ModuleMetadata            $module
232     * @param array<int, FieldMetadata> $fields
233     * @param FilterMetadata            $filter
234     * @param array<string, mixed>      $params
235     * @param PermissionContext         $context
236     * @return array{string, array<string, mixed>}
237     */
238    private function buildWhereClause(
239        ModuleMetadata    $module,
240        array             $fields,
241        FilterMetadata    $filter,
242        array             $params,
243        PermissionContext $context
244    ): array {
245        $gridRequest = new GridRequest(1, 100);
246
247        [$whereSql, $queryParams] = $this->filterBuilder->buildWhere(
248            $module,
249            $fields,
250            $filter,
251            $gridRequest,
252            $context,
253            false
254        );
255
256        $whereSql = (string) preg_replace('/^WHERE\s+/i', '', trim($whereSql));
257
258        $extraWhere = [];
259        $userScope = (string) ($params['user_scope'] ?? 'all');
260        if ($userScope === 'owner_only') {
261            $extraWhere[] = sprintf('`%s`.`owner` = :curr_actor_user', $module->tableAlias);
262            $queryParams[':curr_actor_user'] = $context->actorUserId;
263        } elseif ($userScope === 'owner_and_co_owners' || $userScope === 'current_user') {
264            $this->appendCoOwnerCondition($module, $fields, $context, $extraWhere, $queryParams);
265        }
266
267        $timeRange = (string) ($params['time_range'] ?? 'all');
268        $this->appendTimeRangeCondition($timeRange, $extraWhere, $queryParams, $module->tableAlias);
269
270        if (!empty($extraWhere)) {
271            $extraSql = implode(' AND ', $extraWhere);
272            $whereSql = $whereSql !== '' ? "({$whereSql}) AND {$extraSql}" : $extraSql;
273        }
274
275        return [$whereSql, $queryParams];
276    }
277
278    /**
279     * Appends SQL condition matching primary owner or assigned co-owners and structure.
280     *
281     * @param ModuleMetadata            $module       Module metadata.
282     * @param array<int, FieldMetadata> $fields       Module fields.
283     * @param PermissionContext         $context      Security context.
284     * @param list<string>             &$extraWhere    Conditions list.
285     * @param array<string, mixed>      &$queryParams Parameter bindings.
286     */
287    private function appendCoOwnerCondition(
288        ModuleMetadata    $module,
289        array             $fields,
290        PermissionContext $context,
291        array             &$extraWhere,
292        array             &$queryParams
293    ): void {
294        $alias = $module->tableAlias;
295        $structIds = $context->actorStructureIds;
296
297        $queryParams[':curr_actor_uid1'] = $context->actorUserId;
298        $queryParams[':curr_actor_uid2'] = $context->actorUserId;
299        $queryParams[':curr_actor_mod']  = $module->name;
300
301        $hasCoOwners = false;
302        foreach ($fields as $field) {
303            if ($field->fieldKey === 'co_owners' || $field->columnExpression === 'co_owners') {
304                $hasCoOwners = true;
305                break;
306            }
307        }
308
309        $jsonClause = '';
310        $driver = strtolower((string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME));
311        if ($hasCoOwners && $driver !== 'sqlite') {
312            $jsonClause = sprintf(
313                ' OR (`%s`.`co_owners` IS NOT NULL AND JSON_CONTAINS(`%s`.`co_owners`, :curr_actor_json))',
314                $alias,
315                $alias
316            );
317            $queryParams[':curr_actor_json'] = (string) json_encode($context->actorUserId);
318        }
319
320        if (!empty($structIds)) {
321            $structPlaceholders = [];
322            foreach ($structIds as $sIdx => $sId) {
323                $pName = ':curr_act_sid_' . $sIdx;
324                $structPlaceholders[] = $pName;
325                $queryParams[$pName] = $sId;
326            }
327            $structIn = implode(', ', $structPlaceholders);
328
329            $extraWhere[] = sprintf(
330                '((`%s`.`owner` = :curr_actor_uid1) '
331                . 'OR EXISTS (SELECT 1 FROM `a_core_record_co_owners` AS `_co_wid` '
332                . 'WHERE `_co_wid`.`module_name` = :curr_actor_mod AND `_co_wid`.`record_id` = `%s`.`id` '
333                . 'AND (`_co_wid`.`user_id` = :curr_actor_uid2 OR `_co_wid`.`structure_id` IN (%s)))%s)',
334                $alias,
335                $alias,
336                $structIn,
337                $jsonClause
338            );
339            return;
340        }
341
342        $extraWhere[] = sprintf(
343            '((`%s`.`owner` = :curr_actor_uid1) '
344            . 'OR EXISTS (SELECT 1 FROM `a_core_record_co_owners` AS `_co_wid` '
345            . 'WHERE `_co_wid`.`module_name` = :curr_actor_mod AND `_co_wid`.`record_id` = `%s`.`id` '
346            . 'AND `_co_wid`.`user_id` = :curr_actor_uid2)%s)',
347            $alias,
348            $alias,
349            $jsonClause
350        );
351    }
352
353    /**
354     * Appends SQL time range condition using database-agnostic parameter bindings.
355     *
356     * @param string               $timeRange
357     * @param list<string>        &$extraWhere
358     * @param array<string, mixed> &$queryParams
359     * @param string               $tableAlias
360     */
361    private function appendTimeRangeCondition(
362        string $timeRange,
363        array  &$extraWhere,
364        array  &$queryParams,
365        string $tableAlias = 't'
366    ): void {
367        $now = new \DateTimeImmutable();
368        $dateCol = "`{$tableAlias}`.`created_at`";
369
370        switch ($timeRange) {
371            case 'today':
372                $extraWhere[] = "{$dateCol} >= :tr_start";
373                $queryParams[':tr_start'] = $now->setTime(0, 0, 0)->format(self::DATE_FORMAT_YMD_HIS);
374                break;
375            case 'this_week':
376                $extraWhere[] = "{$dateCol} >= :tr_start";
377                $queryParams[':tr_start'] = $now->modify('monday this week')->setTime(0, 0, 0)
378                    ->format(self::DATE_FORMAT_YMD_HIS);
379                break;
380            case 'this_month':
381                $extraWhere[] = "{$dateCol} >= :tr_start";
382                $queryParams[':tr_start'] = $now->modify('first day of this month')
383                    ->setTime(0, 0, 0)
384                    ->format(self::DATE_FORMAT_YMD_HIS);
385                break;
386            case 'this_quarter':
387                $quarterMonth = (int) (floor(((int) $now->format('n') - 1) / 3) * 3) + 1;
388                $extraWhere[] = "{$dateCol} >= :tr_start";
389                $queryParams[':tr_start'] = $now->setDate((int) $now->format('Y'), $quarterMonth, 1)
390                    ->setTime(0, 0, 0)
391                    ->format(self::DATE_FORMAT_YMD_HIS);
392                break;
393            case 'this_year':
394                $extraWhere[] = "{$dateCol} >= :tr_start";
395                $queryParams[':tr_start'] = $now->setDate((int) $now->format('Y'), 1, 1)
396                    ->setTime(0, 0, 0)
397                    ->format(self::DATE_FORMAT_YMD_HIS);
398                break;
399            case 'last_30_days':
400                $extraWhere[] = "{$dateCol} >= :tr_start";
401                $queryParams[':tr_start'] = $now->sub(new \DateInterval('P30D'))
402                    ->setTime(0, 0, 0)
403                    ->format(self::DATE_FORMAT_YMD_HIS);
404                break;
405            case 'last_90_days':
406                $extraWhere[] = "{$dateCol} >= :tr_start";
407                $queryParams[':tr_start'] = $now->sub(new \DateInterval('P90D'))
408                    ->setTime(0, 0, 0)
409                    ->format(self::DATE_FORMAT_YMD_HIS);
410                break;
411            default:
412                break;
413        }
414    }
415
416    /**
417     * Formats SQL rows into Chart.js data object.
418     *
419     * @param array<int, array<string, mixed>> $rows
420     * @param string                           $chartType
421     * @param list<string>                     $palette
422     * @param string                           $title
423     * @return array{labels: list<string>, datasets: list<array<string, mixed>>}
424     */
425    private function formatChartJsPayload(
426        array  $rows,
427        string $chartType,
428        array  $palette,
429        string $title = ''
430    ): array {
431        $labels = [];
432        $data = [];
433        $bgColors = [];
434        $borderColors = [];
435        $isSingleColorType = in_array($chartType, ['line', 'area'], true);
436
437        $colorIdx = 0;
438        foreach ($rows as $row) {
439            $labels[] = (string) $row['grp'];
440            $data[] = (float) $row['val'];
441
442            $color = $palette[$colorIdx % count($palette)];
443            $bgColors[] = $isSingleColorType ? 'rgba(32, 107, 196, 0.2)' : $color;
444            $borderColors[] = $isSingleColorType ? self::COLOR_PRIMARY_DEFAULT : $color;
445            $colorIdx++;
446        }
447
448        $label = $title !== '' ? $title : 'Wartość / Value';
449
450        $dataset = [
451            'label'           => $label,
452            'data'            => $data,
453            'backgroundColor' => $isSingleColorType ? ($bgColors[0] ?? self::COLOR_PRIMARY_DEFAULT) : $bgColors,
454            'borderColor'     => $isSingleColorType ? ($borderColors[0] ?? self::COLOR_PRIMARY_DEFAULT) : $borderColors,
455            'borderWidth'     => 2,
456            'fill'            => $chartType === 'area',
457            'tension'         => $chartType === 'line' || $chartType === 'area' ? 0.3 : 0,
458        ];
459
460        return [
461            'labels'   => $labels,
462            'datasets' => [$dataset],
463        ];
464    }
465
466    /**
467     * Aligns multi-filter datasets against unified label set.
468     *
469     * @param list<string>               $allLabels
470     * @param list<array<string, mixed>> $rawDatasets
471     * @return array{labels: list<string>, datasets: list<array<string, mixed>>}
472     */
473    private function alignMultiFilterDatasets(array $allLabels, array $rawDatasets): array
474    {
475        $finalDatasets = [];
476        foreach ($rawDatasets as $ds) {
477            /** @var array<string, float> $dataMap */
478            $dataMap = $ds['dataMap'];
479            $alignedData = [];
480            foreach ($allLabels as $label) {
481                $alignedData[] = $dataMap[$label] ?? 0.0;
482            }
483
484            $finalDatasets[] = [
485                'label'           => $ds['label'],
486                'data'            => $alignedData,
487                'backgroundColor' => $ds['backgroundColor'],
488                'borderColor'     => $ds['borderColor'],
489                'borderWidth'     => 2,
490            ];
491        }
492
493        return [
494            'labels'   => $allLabels,
495            'datasets' => $finalDatasets,
496        ];
497    }
498
499    /**
500     * Builds metric SQL expression.
501     *
502     * @param string                    $tableAlias
503     * @param array<int, FieldMetadata> $fields
504     * @param string                    $metricType
505     * @param string                    $metricFieldKey
506     * @return string
507     */
508    private function buildMetricExpression(
509        string $tableAlias,
510        array  $fields,
511        string $metricType,
512        string $metricFieldKey
513    ): string {
514        $col = $this->resolveColumnName($fields, $metricFieldKey);
515
516        if ($col === null || $metricType === 'count') {
517            return self::SQL_COUNT;
518        }
519
520        $qualifiedCol = "`{$tableAlias}`.`{$col}`";
521
522        return match ($metricType) {
523            'sum'   => sprintf(self::SQL_SUM, $qualifiedCol),
524            'avg'   => sprintf(self::SQL_AVG, $qualifiedCol),
525            'min'   => sprintf(self::SQL_MIN, $qualifiedCol),
526            'max'   => sprintf(self::SQL_MAX, $qualifiedCol),
527            default => self::SQL_COUNT,
528        };
529    }
530
531    /**
532     * Resolves matching column name from fields metadata.
533     *
534     * @param array<int, FieldMetadata> $fields
535     * @param string                    $fieldKey
536     * @return string|null
537     */
538    private function resolveColumnName(array $fields, string $fieldKey): ?string
539    {
540        foreach ($fields as $f) {
541            if ($f->fieldKey === $fieldKey) {
542                $parts = explode('.', $f->columnExpression);
543                return end($parts) ?: $f->fieldKey;
544            }
545        }
546
547        return null;
548    }
549
550    /**
551     * Resolves palette color hex strings.
552     *
553     * @param string $name
554     * @return list<string>
555     */
556    private function resolvePalette(string $name): array
557    {
558        return self::PALETTES[$name] ?? self::PALETTES['default'];
559    }
560}