Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
83.91% covered (warning)
83.91%
438 / 522
22.73% covered (danger)
22.73%
10 / 44
CRAP
0.00% covered (danger)
0.00%
0 / 1
ReportCapacityService
83.91% covered (warning)
83.91%
438 / 522
22.73% covered (danger)
22.73%
10 / 44
404.83
0.00% covered (danger)
0.00%
0 / 1
 getExchangeCapacityData
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 getScopeDateBoundsByScopeId
70.83% covered (warning)
70.83%
17 / 24
0.00% covered (danger)
0.00%
0 / 1
12.48
 getSelectedScopeSlotTimes
77.78% covered (warning)
77.78%
21 / 27
0.00% covered (danger)
0.00%
0 / 1
11.10
 formatScopeSlotTimeHint
88.89% covered (warning)
88.89%
32 / 36
0.00% covered (danger)
0.00%
0 / 1
10.14
 formatGroupedScopeSlotTimeHint
88.89% covered (warning)
88.89%
16 / 18
0.00% covered (danger)
0.00%
0 / 1
6.05
 resolveScopeDisplayName
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 resolveScopeSlotTimeMinutes
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
7.33
 getCapacityPeriod
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
3.71
 getExchangeCapacityForDateRange
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
4.37
 getExchangeCapacityForPeriod
80.00% covered (warning)
80.00%
12 / 15
0.00% covered (danger)
0.00%
0 / 1
6.29
 buildSparseChartExchange
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 buildChartExchange
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 applyChartVisualizationSettings
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 resolveChartLabelIntervalHours
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
6.02
 shouldFetchHourlyFromApi
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
5.12
 resolveRangeDurationHours
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 fetchAggregatedReport
73.33% covered (warning)
73.33%
11 / 15
0.00% covered (danger)
0.00%
0 / 1
5.47
 buildCapacityFetchParams
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 resolveCapacityFetchUrlPeriod
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 normalizeFetchedCapacityExchange
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 aggregateRowsByDate
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
6.09
 fillMissingTimeline
88.64% covered (warning)
88.64%
39 / 44
0.00% covered (danger)
0.00%
0 / 1
12.21
 exchangeDataLooksHourly
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
5.58
 rowDateValue
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 rowNumericValue
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 emptyCapacityRow
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 exchangeSupportsCapacityChannel
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 normalizeDataRow
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 normalizeTimelineKey
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
4.25
 resolveTimelineBounds
68.18% covered (warning)
68.18%
15 / 22
0.00% covered (danger)
0.00%
0 / 1
10.06
 filterRowsByBounds
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 finalizeExchange
44.44% covered (danger)
44.44%
4 / 9
0.00% covered (danger)
0.00%
0 / 1
6.74
 dayFromString
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 enrichPeriodList
86.67% covered (warning)
86.67%
26 / 30
0.00% covered (danger)
0.00%
0 / 1
13.40
 buildDownloadFilename
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 buildDownloadDateRangePart
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
13.04
 buildDownloadExchange
93.88% covered (success)
93.88%
46 / 49
0.00% covered (danger)
0.00%
0 / 1
8.01
 exchangeSupportsMinutes
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 buildCapacityMetricLabel
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 resolveCapacityChannelLabel
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 resolveChannelMetric
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
8.04
 formatDownloadDate
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 formatUtilizationPercent
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 prepareDownloadArgs
87.50% covered (warning)
87.50%
21 / 24
0.00% covered (danger)
0.00%
0 / 1
8.12
1<?php
2
3/**
4 * @package Zmsstatistic
5 * @copyright BerlinOnline Stadtportal GmbH & Co. KG
6 **/
7
8namespace BO\Zmsstatistic\Service;
9
10use BO\Zmsentities\Day;
11use BO\Zmsentities\Exchange;
12use DateTimeImmutable;
13
14/**
15 * @SuppressWarnings(TooManyMethods)
16 */
17class ReportCapacityService
18{
19    /** Fetch hour-level warehouse data up to this range length (chart buckets are derived in PHP). */
20    private const MAX_HOURLY_FETCH_HOURS = 336;
21
22    /** Above this count, slot-time hints group by duration instead of listing scope names. */
23    private const SLOT_TIME_HINT_MAX_NAMED_SCOPES = 4;
24
25    public function getExchangeCapacityData(string $scopeId, ?array $dateRange, array $args): mixed
26    {
27        if ($scopeId === '') {
28            return null;
29        }
30
31        if ($dateRange) {
32            return $this->getExchangeCapacityForDateRange($scopeId, $dateRange);
33        }
34
35        if (isset($args['period'])) {
36            return $this->getExchangeCapacityForPeriod($scopeId, $args['period']);
37        }
38
39        return null;
40    }
41
42    /** Available date range per scope from warehouse subject list (periodstart / periodend). */
43    public function getScopeDateBoundsByScopeId(): array
44    {
45        try {
46            $result = \App::$http->readGetResult('/warehouse/capacityscope/');
47            if (!$result) {
48                return [];
49            }
50
51            $subjectList = $result->getEntity();
52
53            if (!$subjectList instanceof Exchange || empty($subjectList->data)) {
54                return [];
55            }
56
57            $dateBoundsByScopeId = [];
58            foreach ($subjectList->data as $row) {
59                $scopeId = (string) ($row[0] ?? '');
60                $periodStart = (string) ($row[1] ?? '');
61                $periodEnd = (string) ($row[2] ?? '');
62
63                if ($scopeId === '' || $periodStart === '' || $periodEnd === '') {
64                    continue;
65                }
66
67                if (!isset($dateBoundsByScopeId[$scopeId])) {
68                    $dateBoundsByScopeId[$scopeId] = [
69                        'min' => $periodStart,
70                        'max' => $periodEnd,
71                    ];
72                    continue;
73                }
74
75                $dateBoundsByScopeId[$scopeId]['min'] = min($dateBoundsByScopeId[$scopeId]['min'], $periodStart);
76                $dateBoundsByScopeId[$scopeId]['max'] = max($dateBoundsByScopeId[$scopeId]['max'], $periodEnd);
77            }
78
79            return $dateBoundsByScopeId;
80        } catch (\Throwable $exception) {
81            return [];
82        }
83    }
84
85    public function getSelectedScopeSlotTimes(array $scopeIds): array
86    {
87        if ($scopeIds === []) {
88            return [];
89        }
90
91        try {
92            $result = \App::$http->readGetResult('/scope/');
93            if (!$result) {
94                return [];
95            }
96
97            $scopeList = $result->getData();
98            if (!is_array($scopeList) && !($scopeList instanceof \Traversable)) {
99                return [];
100            }
101
102            $scopeById = [];
103            foreach ($scopeList as $scope) {
104                $id = (string) ($scope->id ?? '');
105                if ($id !== '') {
106                    $scopeById[$id] = $scope;
107                }
108            }
109
110            $scopeSlotTimeEntries = [];
111            foreach ($scopeIds as $scopeId) {
112                $id = (string) $scopeId;
113                if (!isset($scopeById[$id])) {
114                    continue;
115                }
116
117                $scope = $scopeById[$id];
118                $scopeSlotTimeEntries[] = [
119                    'id' => $id,
120                    'name' => $this->resolveScopeDisplayName($scope, $id),
121                    'slotTimeInMinutes' => $this->resolveScopeSlotTimeMinutes($scope),
122                ];
123            }
124
125            return $scopeSlotTimeEntries;
126        } catch (\Throwable $exception) {
127            return [];
128        }
129    }
130
131    public function formatScopeSlotTimeHint(array $scopeSlotTimes): ?string
132    {
133        if ($scopeSlotTimes === []) {
134            return null;
135        }
136
137        $slotTimeMinutesList = array_values(array_filter(
138            array_map(
139                static fn (array $item): ?int => $item['slotTimeInMinutes'] ?? null,
140                $scopeSlotTimes
141            ),
142            static fn (?int $minutes): bool => $minutes !== null
143        ));
144
145        if ($slotTimeMinutesList === []) {
146            return null;
147        }
148
149        if (count($scopeSlotTimes) === 1) {
150            return sprintf(
151                'Zeitschlitzdauer laut Ã–ffnungszeit: %d Min.',
152                $slotTimeMinutesList[0]
153            );
154        }
155
156        $uniqueSlotTimeMinutes = array_values(array_unique($slotTimeMinutesList));
157        if (count($uniqueSlotTimeMinutes) === 1 && count($slotTimeMinutesList) === count($scopeSlotTimes)) {
158            return sprintf(
159                'Zeitschlitzdauer laut Ã–ffnungszeit: %d Min. (alle ausgewählten Standorte)',
160                $uniqueSlotTimeMinutes[0]
161            );
162        }
163
164        if (count($scopeSlotTimes) > self::SLOT_TIME_HINT_MAX_NAMED_SCOPES) {
165            return $this->formatGroupedScopeSlotTimeHint($scopeSlotTimes);
166        }
167
168        $hintParts = [];
169        foreach ($scopeSlotTimes as $item) {
170            if (($item['slotTimeInMinutes'] ?? null) === null) {
171                continue;
172            }
173
174            $hintParts[] = sprintf(
175                '%s: %d Min.',
176                $item['name'],
177                $item['slotTimeInMinutes']
178            );
179        }
180
181        return $hintParts === []
182            ? null
183            : 'Zeitschlitzdauer laut Ã–ffnungszeit: ' . implode('; ', $hintParts);
184    }
185
186    private function formatGroupedScopeSlotTimeHint(array $scopeSlotTimes): ?string
187    {
188        $scopeCountBySlotMinutes = [];
189        foreach ($scopeSlotTimes as $item) {
190            $minutes = $item['slotTimeInMinutes'] ?? null;
191            if ($minutes === null) {
192                continue;
193            }
194
195            $scopeCountBySlotMinutes[$minutes] = ($scopeCountBySlotMinutes[$minutes] ?? 0) + 1;
196        }
197
198        if ($scopeCountBySlotMinutes === []) {
199            return null;
200        }
201
202        ksort($scopeCountBySlotMinutes, SORT_NUMERIC);
203
204        $hintParts = [];
205        foreach ($scopeCountBySlotMinutes as $minutes => $scopeCount) {
206            $hintParts[] = sprintf(
207                '%d Min. (%d %s)',
208                $minutes,
209                $scopeCount,
210                $scopeCount === 1 ? 'Standort' : 'Standorte'
211            );
212        }
213
214        return 'Zeitschlitzdauer laut Ã–ffnungszeit: ' . implode(', ', $hintParts);
215    }
216
217    private function resolveScopeDisplayName(mixed $scope, string $id): string
218    {
219        if (!is_object($scope)) {
220            return 'Standort ' . $id;
221        }
222
223        $contactName = '';
224        if (isset($scope->contact)) {
225            $contactName = (string) ($scope->contact->name ?? '');
226        }
227
228        $shortName = (string) ($scope->shortName ?? '');
229        $name = trim($contactName . ' ' . $shortName);
230
231        return $name !== '' ? $name : 'Standort ' . $id;
232    }
233
234    private function resolveScopeSlotTimeMinutes(mixed $scope): ?int
235    {
236        if (!is_object($scope) || !isset($scope->provider) || !is_object($scope->provider)) {
237            return null;
238        }
239
240        if (!method_exists($scope->provider, 'getSlotTimeInMinutes')) {
241            return null;
242        }
243
244        $slotTime = $scope->provider->getSlotTimeInMinutes();
245
246        return $slotTime !== null ? (int) $slotTime : null;
247    }
248
249    /**
250     * Period list for navigation; derived from capacityscope report dates when API only returns "_".
251     */
252    public function getCapacityPeriod(string $scopeId): mixed
253    {
254        try {
255            $result = \App::$http->readGetResult('/warehouse/capacityscope/' . $scopeId . '/');
256            if (!$result) {
257                return null;
258            }
259
260            $periodList = $result->getEntity();
261
262            return $this->enrichPeriodList($periodList, $scopeId);
263        } catch (\Throwable $exception) {
264            return null;
265        }
266    }
267
268    public function getExchangeCapacityForDateRange(string $scopeId, array $dateRange): mixed
269    {
270        if (!isset($dateRange['from'], $dateRange['to'])) {
271            return null;
272        }
273
274        $exchange = $this->fetchAggregatedReport($scopeId, $dateRange, null);
275        if (!$exchange || empty($exchange->data)) {
276            return null;
277        }
278
279        $exchange->data = $this->filterRowsByBounds($exchange->data, $dateRange);
280
281        return $this->finalizeExchange($exchange, $dateRange['from'], $dateRange['to']);
282    }
283
284    public function getExchangeCapacityForPeriod(string $scopeId, string $period): mixed
285    {
286        $exchange = $this->fetchAggregatedReport($scopeId, null, $period);
287        if (!$exchange || empty($exchange->data)) {
288            return null;
289        }
290
291        $timelineBounds = $this->resolveTimelineBounds(null, $period);
292        if ($timelineBounds) {
293            $exchange->data = $this->filterRowsByBounds($exchange->data, $timelineBounds);
294        }
295
296        if (empty($exchange->data)) {
297            return null;
298        }
299
300        if ($timelineBounds) {
301            return $this->finalizeExchange(
302                $exchange,
303                $timelineBounds['from'],
304                $timelineBounds['to']
305            );
306        }
307
308        return $this->finalizeExchange($exchange);
309    }
310
311    /**
312     * Sparse API rows for the chart (same as legacy warehouse reports).
313     */
314    public function buildSparseChartExchange(Exchange $exchange, ?array $dateRange, ?string $period): Exchange
315    {
316        return $this->applyChartVisualizationSettings(clone $exchange, $dateRange, $period);
317    }
318
319    /**
320     * Full timeline for the chart (zeros for closed hours/days). Table uses sparse API data.
321     */
322    public function buildChartExchange(Exchange $exchange, ?array $dateRange, ?string $period): Exchange
323    {
324        $chartExchange = clone $exchange;
325        $useHourlyTimeline = $this->shouldFetchHourlyFromApi($dateRange, $period);
326
327        $chartExchange->data = $this->fillMissingTimeline(
328            $chartExchange->data,
329            $dateRange,
330            $period,
331            $useHourlyTimeline
332        );
333
334        return $this->applyChartVisualizationSettings($chartExchange, $dateRange, $period);
335    }
336
337    private function applyChartVisualizationSettings(
338        Exchange $chartExchange,
339        ?array $dateRange,
340        ?string $period
341    ): Exchange {
342        $visualization = $chartExchange['visualization'] ?? [];
343        if (!is_array($visualization)) {
344            $visualization = [];
345        }
346        $visualization['labelIntervalHours'] = $this->resolveChartLabelIntervalHours($dateRange, $period);
347        $visualization['allowSparseTimeline'] = true;
348        if (!isset($visualization['allowCapacityChannel'])) {
349            $visualization['allowCapacityChannel'] = $this->exchangeSupportsCapacityChannel($chartExchange);
350        }
351        $chartExchange['visualization'] = $visualization;
352
353        return $chartExchange;
354    }
355
356    /**
357     * X-axis tick label spacing in hours, or null for daily labels (data stays hourly/daily respectively).
358     */
359    public function resolveChartLabelIntervalHours(?array $dateRange, ?string $period): ?int
360    {
361        $rangeDurationHours = $this->resolveRangeDurationHours($dateRange, $period);
362        if ($rangeDurationHours === null) {
363            return null;
364        }
365
366        if ($rangeDurationHours <= 24) {
367            return 1;
368        }
369
370        if ($rangeDurationHours <= 48) {
371            return 2;
372        }
373
374        if ($rangeDurationHours <= 168) {
375            return 6;
376        }
377
378        if ($rangeDurationHours <= self::MAX_HOURLY_FETCH_HOURS) {
379            return 12;
380        }
381
382        return null;
383    }
384
385    public function shouldFetchHourlyFromApi(?array $dateRange, ?string $period): bool
386    {
387        $rangeDurationHours = $this->resolveRangeDurationHours($dateRange, $period);
388        if ($rangeDurationHours !== null) {
389            return $rangeDurationHours <= self::MAX_HOURLY_FETCH_HOURS;
390        }
391
392        if ($period && $period !== '_' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $period)) {
393            return true;
394        }
395
396        return false;
397    }
398
399    /** Length of selected range in hours (inclusive end day). */
400    public function resolveRangeDurationHours(?array $dateRange, ?string $period): ?float
401    {
402        $bounds = $this->resolveTimelineBounds($dateRange, $period);
403        if (!$bounds) {
404            return null;
405        }
406
407        $rangeStart = new DateTimeImmutable($bounds['from'] . ' 00:00:00');
408        $rangeEnd = new DateTimeImmutable($bounds['to'] . ' 23:59:59');
409
410        return ($rangeEnd->getTimestamp() - $rangeStart->getTimestamp()) / 3600;
411    }
412
413    private function fetchAggregatedReport(string $scopeId, ?array $dateRange, ?string $period): ?Exchange
414    {
415        try {
416            $useHourlyGrouping = $this->shouldFetchHourlyFromApi($dateRange, $period);
417            $warehouseFetchParams = $this->buildCapacityFetchParams($dateRange, $period, $useHourlyGrouping);
418            $warehouseUrlPeriodSegment = $this->resolveCapacityFetchUrlPeriod($dateRange, $period);
419
420            $result = \App::$http->readGetResult(
421                '/warehouse/capacityscope/' . $scopeId . '/' . $warehouseUrlPeriodSegment . '/',
422                $warehouseFetchParams === [] ? null : $warehouseFetchParams
423            );
424            if (!$result) {
425                return null;
426            }
427
428            $exchange = $result->getEntity();
429            if (!$exchange instanceof Exchange) {
430                return null;
431            }
432
433            return $this->normalizeFetchedCapacityExchange($exchange, $useHourlyGrouping);
434        } catch (\Throwable $exception) {
435            return null;
436        }
437    }
438
439    private function buildCapacityFetchParams(?array $dateRange, ?string $period, bool $useHourlyGrouping): array
440    {
441        $warehouseFetchParams = [];
442
443        if ($dateRange) {
444            $warehouseFetchParams['fromDate'] = $dateRange['from'];
445            $warehouseFetchParams['toDate'] = $dateRange['to'];
446        }
447
448        if ($useHourlyGrouping) {
449            $warehouseFetchParams['groupby'] = 'hour';
450        } elseif ($dateRange !== null || ($period !== null && $period !== '_')) {
451            $warehouseFetchParams['groupby'] = 'day';
452        }
453
454        return $warehouseFetchParams;
455    }
456
457    private function resolveCapacityFetchUrlPeriod(?array $dateRange, ?string $period): string
458    {
459        if ($period && $period !== '_') {
460            return $period;
461        }
462
463        if ($dateRange) {
464            return $dateRange['from'];
465        }
466
467        return '_';
468    }
469
470    private function normalizeFetchedCapacityExchange(Exchange $exchange, bool $useHourlyGrouping): Exchange
471    {
472        $exchange->data = $this->aggregateRowsByDate($exchange->data, $useHourlyGrouping);
473
474        if (!$useHourlyGrouping) {
475            if ($this->exchangeDataLooksHourly($exchange->data)) {
476                $exchange->data = $this->aggregateRowsByDate($exchange->data, false);
477            }
478            $exchange->period = 'day';
479        } elseif (($exchange->period ?? 'day') === 'hour') {
480            $exchange->period = 'hour';
481        }
482
483        return $exchange;
484    }
485
486    /** Sum booked/planned counts per date (or hour) across multiple scopes. */
487    public function aggregateRowsByDate(array $rows, bool $useHourlyKeys): array
488    {
489        $rowsByTimelineKey = [];
490
491        foreach ($rows as $row) {
492            if (!is_array($row)) {
493                continue;
494            }
495
496            $normalizedRow = $this->normalizeDataRow($row, $useHourlyKeys);
497            $timelineKey = $normalizedRow[1];
498            if ($timelineKey === '') {
499                continue;
500            }
501
502            if (!isset($rowsByTimelineKey[$timelineKey])) {
503                $rowsByTimelineKey[$timelineKey] = $normalizedRow;
504                continue;
505            }
506
507            for ($metricColumnIndex = 2; $metricColumnIndex <= 9; $metricColumnIndex++) {
508                $rowsByTimelineKey[$timelineKey][$metricColumnIndex] += $normalizedRow[$metricColumnIndex];
509            }
510        }
511
512        ksort($rowsByTimelineKey);
513
514        return array_values($rowsByTimelineKey);
515    }
516
517    /**
518     * Insert zero rows for each hour or day in the selected range so the chart shows
519     * closed periods and gaps between days (not only timestamps with slot data).
520     */
521    private function fillMissingTimeline(
522        array $rows,
523        ?array $dateRange,
524        ?string $period,
525        bool $useHourlyTimeline
526    ): array {
527        $timelineBounds = $this->resolveTimelineBounds($dateRange, $period);
528        if (!$timelineBounds) {
529            return $rows;
530        }
531
532        $rowsByTimelineKey = [];
533        $defaultSubjectId = '';
534
535        foreach ($rows as $row) {
536            if (!is_array($row)) {
537                continue;
538            }
539
540            $normalizedRow = $this->normalizeDataRow($row, $useHourlyTimeline);
541            $timelineKey = $normalizedRow[1];
542            if ($timelineKey === '') {
543                continue;
544            }
545
546            if (!isset($rowsByTimelineKey[$timelineKey])) {
547                $rowsByTimelineKey[$timelineKey] = $normalizedRow;
548                if ($defaultSubjectId === '' && $normalizedRow[0] !== '') {
549                    $defaultSubjectId = $normalizedRow[0];
550                }
551                continue;
552            }
553
554            for ($metricColumnIndex = 2; $metricColumnIndex <= 9; $metricColumnIndex++) {
555                $rowsByTimelineKey[$timelineKey][$metricColumnIndex] += $normalizedRow[$metricColumnIndex];
556            }
557        }
558
559        $rangeStartDate = $timelineBounds['from'];
560        $rangeEndDate = $timelineBounds['to'];
561        $completeTimelineRows = [];
562
563        if ($useHourlyTimeline) {
564            $timelineStart = new DateTimeImmutable($rangeStartDate . ' 00:00:00');
565            $timelineEnd = new DateTimeImmutable($rangeEndDate . ' 23:00:00');
566            $timelineCursor = $timelineStart;
567
568            while ($timelineCursor <= $timelineEnd) {
569                $timelineKey = $this->normalizeTimelineKey(
570                    $timelineCursor->format('Y-m-d') . ' ' . $timelineCursor->format('H') . ':00',
571                    true
572                );
573                $completeTimelineRows[] = $rowsByTimelineKey[$timelineKey]
574                    ?? $this->emptyCapacityRow($defaultSubjectId, $timelineKey);
575                $timelineCursor = $timelineCursor->modify('+1 hour');
576            }
577
578            return $completeTimelineRows;
579        }
580
581        $timelineStart = new DateTimeImmutable($rangeStartDate);
582        $timelineEnd = new DateTimeImmutable($rangeEndDate);
583        $timelineCursor = $timelineStart;
584
585        while ($timelineCursor <= $timelineEnd) {
586            $timelineKey = $timelineCursor->format('Y-m-d');
587            $completeTimelineRows[] = $rowsByTimelineKey[$timelineKey]
588                ?? $this->emptyCapacityRow($defaultSubjectId, $timelineKey);
589            $timelineCursor = $timelineCursor->modify('+1 day');
590        }
591
592        return $completeTimelineRows;
593    }
594
595    private function exchangeDataLooksHourly(array $rows): bool
596    {
597        foreach ($rows as $row) {
598            if (!is_array($row)) {
599                continue;
600            }
601
602            $dateValue = $this->rowDateValue($row);
603            if ($dateValue !== '' && preg_match('/\d{2}:\d{2}/', $dateValue)) {
604                return true;
605            }
606        }
607
608        return false;
609    }
610
611    private function rowDateValue(array $row): string
612    {
613        if (isset($row['date'])) {
614            return (string) $row['date'];
615        }
616
617        return (string) ($row[1] ?? '');
618    }
619
620    private function rowNumericValue(array $row, string $variable, int $position): int
621    {
622        if (isset($row[$variable])) {
623            return (int) $row[$variable];
624        }
625
626        return (int) ($row[$position] ?? 0);
627    }
628
629    private function emptyCapacityRow(string $subjectId, string $timelineKey): array
630    {
631        return [$subjectId, $timelineKey, 0, 0, 0, 0, 0, 0, 0, 0];
632    }
633
634    public function exchangeSupportsCapacityChannel(Exchange $exchange): bool
635    {
636        foreach ($exchange->dictionary ?? [] as $entry) {
637            if (($entry['variable'] ?? null) === 'bookedcount_public') {
638                return true;
639            }
640        }
641
642        return false;
643    }
644
645    private function normalizeDataRow(array $row, bool $useHourlyKeys): array
646    {
647        $rowDateValue = $this->rowDateValue($row);
648
649        return [
650            (string) ($row['subjectid'] ?? $row[0] ?? ''),
651            $this->normalizeTimelineKey($rowDateValue, $useHourlyKeys),
652            $this->rowNumericValue($row, 'bookedcount', 2),
653            $this->rowNumericValue($row, 'plannedcount', 3),
654            $this->rowNumericValue($row, 'bookedminutes', 4),
655            $this->rowNumericValue($row, 'plannedminutes', 5),
656            $this->rowNumericValue($row, 'bookedcount_public', 6),
657            $this->rowNumericValue($row, 'plannedcount_public', 7),
658            $this->rowNumericValue($row, 'bookedminutes_public', 8),
659            $this->rowNumericValue($row, 'plannedminutes_public', 9),
660        ];
661    }
662
663    private function normalizeTimelineKey(string $rowDateValue, bool $useHourlyKeys): string
664    {
665        if ($rowDateValue === '') {
666            return '';
667        }
668
669        if (!$useHourlyKeys) {
670            return substr($rowDateValue, 0, 10);
671        }
672
673        $timestamp = strtotime($rowDateValue);
674        if ($timestamp === false) {
675            return $rowDateValue;
676        }
677
678        return date('Y-m-d H', $timestamp) . ':00';
679    }
680
681    private function resolveTimelineBounds(?array $dateRange, ?string $period): ?array
682    {
683        if ($dateRange && isset($dateRange['from'], $dateRange['to'])) {
684            return [
685                'from' => $dateRange['from'],
686                'to' => $dateRange['to'],
687            ];
688        }
689
690        if (!$period || $period === '_') {
691            return null;
692        }
693
694        if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $period)) {
695            return ['from' => $period, 'to' => $period];
696        }
697
698        if (preg_match('/^\d{4}-\d{2}$/', $period)) {
699            $start = new DateTimeImmutable($period . '-01');
700            $end = $start->modify('last day of this month');
701
702            return [
703                'from' => $start->format('Y-m-d'),
704                'to' => $end->format('Y-m-d'),
705            ];
706        }
707
708        if (preg_match('/^\d{4}$/', $period)) {
709            return [
710                'from' => $period . '-01-01',
711                'to' => $period . '-12-31',
712            ];
713        }
714
715        return null;
716    }
717
718    private function filterRowsByBounds(array $rows, array $bounds): array
719    {
720        $rangeStart = $bounds['from'];
721        $rangeEnd = $bounds['to'];
722
723        return array_values(array_filter($rows, static function ($row) use ($rangeStart, $rangeEnd) {
724            $date = (string) ($row[1] ?? '');
725            if ($date === '') {
726                return false;
727            }
728
729            $day = substr($date, 0, 10);
730
731            return $day >= $rangeStart && $day <= $rangeEnd;
732        }));
733    }
734
735    private function finalizeExchange(Exchange $exchange, ?string $fromDate = null, ?string $toDate = null): Exchange
736    {
737        if ($fromDate && $toDate) {
738            $exchange->firstDay = $this->dayFromString($fromDate);
739            $exchange->lastDay = $this->dayFromString($toDate);
740        } elseif (!empty($exchange->data)) {
741            $firstRowDate = (string) $exchange->data[0][1];
742            $lastRowDate = (string) $exchange->data[count($exchange->data) - 1][1];
743            $exchange->firstDay = $this->dayFromString(substr($firstRowDate, 0, 10));
744            $exchange->lastDay = $this->dayFromString(substr($lastRowDate, 0, 10));
745        }
746
747        return $exchange;
748    }
749
750    private function dayFromString(string $date): Day
751    {
752        $dateTime = new \DateTime(substr($date, 0, 10));
753
754        return (new Day())->setDateTime($dateTime);
755    }
756
757    private function enrichPeriodList(mixed $periodList, string $scopeId): mixed
758    {
759        if (!$periodList instanceof Exchange) {
760            return $periodList;
761        }
762
763        $hasOnlyAggregatePeriod = count($periodList->data) === 1
764            && isset($periodList->data[0][0])
765            && $periodList->data[0][0] === '_';
766
767        if (!$hasOnlyAggregatePeriod) {
768            return $periodList;
769        }
770
771        $exchange = $this->fetchAggregatedReport($scopeId, null, null);
772        if (!$exchange || empty($exchange->data)) {
773            return $periodList;
774        }
775
776        $years = [];
777        $months = [];
778
779        foreach ($exchange->data as $row) {
780            $rowDateValue = (string) ($row[1] ?? '');
781            if ($rowDateValue === '') {
782                continue;
783            }
784
785            $year = substr($rowDateValue, 0, 4);
786            $month = substr($rowDateValue, 0, 7);
787
788            if (!in_array($year, $years, true)) {
789                $years[] = $year;
790            }
791
792            if (!in_array($month, $months, true)) {
793                $months[] = $month;
794            }
795        }
796
797        rsort($years);
798        rsort($months);
799
800        $periodList->data = [];
801        foreach ($years as $year) {
802            $periodList->data[] = [$year];
803        }
804        foreach ($months as $month) {
805            $periodList->data[] = [$month];
806        }
807
808        return $periodList;
809    }
810
811    public function buildDownloadFilename(?array $dateRange, ?string $period, string $valueMode = 'slots'): string
812    {
813        $valueSuffix = $valueMode === 'minutes' ? '-minuten' : '-zeitschlitze';
814        $rangePart = $this->buildDownloadDateRangePart($dateRange, $period);
815        $baseName = 'terminkapazitaet' . $valueSuffix;
816
817        if ($rangePart === '') {
818            return $baseName;
819        }
820
821        return $baseName . '_' . $rangePart;
822    }
823
824    private function buildDownloadDateRangePart(?array $dateRange, ?string $period): string
825    {
826        if ($dateRange !== null && !empty($dateRange['from']) && !empty($dateRange['to'])) {
827            return $dateRange['from'] . '-bis-' . $dateRange['to'];
828        }
829
830        if ($period === null || $period === '' || $period === '_') {
831            return '';
832        }
833
834        if (preg_match('/^(\d{4}-\d{2}-\d{2})_(\d{4}-\d{2}-\d{2})$/', $period, $matches)) {
835            return $matches[1] . '-bis-' . $matches[2];
836        }
837
838        return preg_replace('/[^0-9-]/', '', $period);
839    }
840
841    public function buildDownloadExchange(
842        Exchange $exchange,
843        string $channelMode = 'total',
844        string $valueMode = 'slots'
845    ): Exchange {
846        $channelMode = in_array($channelMode, ['total', 'public', 'intern_only'], true)
847            ? $channelMode
848            : 'total';
849        $useMinutes = $valueMode === 'minutes' && $this->exchangeSupportsMinutes($exchange);
850        $isHourly = ($exchange->period ?? '') === 'hour';
851
852        $download = new Exchange();
853        $download->addDictionaryEntry(
854            'date',
855            'string',
856            $isHourly ? 'Zeitpunkt' : 'Datum'
857        );
858        $download->addDictionaryEntry(
859            'planned',
860            'number',
861            $this->buildCapacityMetricLabel('planned', $channelMode, $useMinutes)
862        );
863        $download->addDictionaryEntry(
864            'booked',
865            'number',
866            $this->buildCapacityMetricLabel('booked', $channelMode, $useMinutes)
867        );
868        $download->addDictionaryEntry('utilization', 'string', 'Auslastung');
869
870        $totalPlanned = 0;
871        $totalBooked = 0;
872
873        foreach ($exchange->data as $row) {
874            if (!is_array($row)) {
875                continue;
876            }
877
878            $normalizedRow = $this->normalizeDataRow($row, $isHourly);
879            $planned = $this->resolveChannelMetric($normalizedRow, 'planned', $channelMode, $useMinutes);
880            $booked = $this->resolveChannelMetric($normalizedRow, 'booked', $channelMode, $useMinutes);
881            $totalPlanned += $planned;
882            $totalBooked += $booked;
883            $utilization = $planned > 0 ? round(($booked / $planned) * 1000) / 10 : 0;
884
885            $download->addDataSet([
886                $this->formatDownloadDate((string) $normalizedRow[1], $isHourly),
887                (string) $planned,
888                (string) $booked,
889                $this->formatUtilizationPercent($utilization),
890            ]);
891        }
892
893        $totalUtilization = $totalPlanned > 0
894            ? round(($totalBooked / $totalPlanned) * 1000) / 10
895            : 0;
896        $download->addDataSet([
897            'Summe',
898            (string) $totalPlanned,
899            (string) $totalBooked,
900            $this->formatUtilizationPercent($totalUtilization),
901        ]);
902
903        return $download;
904    }
905
906    private function exchangeSupportsMinutes(Exchange $exchange): bool
907    {
908        foreach ($exchange->dictionary ?? [] as $entry) {
909            if (($entry['variable'] ?? null) === 'bookedminutes') {
910                return true;
911            }
912        }
913
914        return false;
915    }
916
917    private function buildCapacityMetricLabel(string $kind, string $channelMode, bool $useMinutes): string
918    {
919        $unit = $useMinutes ? 'Minuten' : 'Zeitschlitze';
920        $prefix = $kind === 'planned' ? 'Geplante' : 'Gebuchte';
921
922        return $prefix . ' Kapazität ' . $this->resolveCapacityChannelLabel($channelMode) . ' (' . $unit . ')';
923    }
924
925    private function resolveCapacityChannelLabel(string $channelMode): string
926    {
927        if ($channelMode === 'public') {
928            return 'Internet';
929        }
930        if ($channelMode === 'intern_only') {
931            return 'nur intern';
932        }
933
934        return 'insgesamt';
935    }
936
937    private function resolveChannelMetric(
938        array $normalizedRow,
939        string $metric,
940        string $channelMode,
941        bool $useMinutes
942    ): int {
943        if ($useMinutes) {
944            $totalPosition = $metric === 'planned' ? 5 : 4;
945            $publicPosition = $metric === 'planned' ? 9 : 8;
946        } else {
947            $totalPosition = $metric === 'planned' ? 3 : 2;
948            $publicPosition = $metric === 'planned' ? 7 : 6;
949        }
950
951        $total = (int) ($normalizedRow[$totalPosition] ?? 0);
952        $public = (int) ($normalizedRow[$publicPosition] ?? 0);
953
954        if ($channelMode === 'public') {
955            return $public;
956        }
957        if ($channelMode === 'intern_only') {
958            return max(0, $total - $public);
959        }
960
961        return $total;
962    }
963
964    private function formatDownloadDate(string $dateValue, bool $isHourly): string
965    {
966        if ($isHourly || !preg_match('/^(\d{4})-(\d{2})-(\d{2})/', $dateValue, $matches)) {
967            return $dateValue;
968        }
969
970        return $matches[3] . '.' . $matches[2] . '.' . $matches[1];
971    }
972
973    private function formatUtilizationPercent(float $utilization): string
974    {
975        $formatted = number_format($utilization, 1, ',', '.');
976        if (str_ends_with($formatted, ',0')) {
977            $formatted = substr($formatted, 0, -2);
978        }
979
980        return $formatted . ' %';
981    }
982
983    /**
984     * Prepare download arguments for capacity report Excel export.
985     */
986    public function prepareDownloadArgs(
987        array $args,
988        string $scopeId,
989        mixed $exchangeCapacity,
990        ?array $dateRange,
991        array $selectedScopes = [],
992        string $valueMode = 'slots',
993        string $channelMode = 'total'
994    ): array {
995        $args['category'] = 'capacityscope';
996        $args['subject'] = 'capacityscope';
997        $args['subjectid'] = $scopeId;
998
999        if ($dateRange) {
1000            $args['period'] = $dateRange['from'] . '_' . $dateRange['to'];
1001        } elseif (!isset($args['period']) || $args['period'] === null || $args['period'] === '') {
1002            $args['period'] = '_';
1003        }
1004
1005        $args['downloadTitle'] = $this->buildDownloadFilename(
1006            $dateRange,
1007            $args['period'] ?? null,
1008            $valueMode
1009        );
1010
1011        if (!empty($selectedScopes)) {
1012            $args['selectedScopes'] = $selectedScopes;
1013        } elseif ($scopeId !== '') {
1014            $args['selectedScopes'] = array_values(array_filter(explode(',', $scopeId)));
1015        }
1016
1017        if ($exchangeCapacity instanceof Exchange) {
1018            $args['reports'] = [$exchangeCapacity];
1019            $args['report'] = $this->buildDownloadExchange(
1020                $exchangeCapacity,
1021                $channelMode,
1022                $valueMode
1023            );
1024        }
1025
1026        return $args;
1027    }
1028}