Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.39% covered (warning)
88.39%
396 / 448
38.10% covered (danger)
38.10%
8 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
CalendarAvailability
88.39% covered (warning)
88.39%
396 / 448
38.10% covered (danger)
38.10%
8 / 21
122.24
0.00% covered (danger)
0.00%
0 / 1
 readFromQuery
81.08% covered (warning)
81.08%
30 / 37
0.00% covered (danger)
0.00%
0 / 1
7.33
 readAvailability
95.76% covered (success)
95.76%
113 / 118
0.00% covered (danger)
0.00%
0 / 1
7
 withoutUnrelatedScopes
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
4.59
 resolveSlotsDateRange
42.86% covered (danger)
42.86%
9 / 21
0.00% covered (danger)
0.00%
0 / 1
19.94
 resolveResponseDaysRange
76.92% covered (warning)
76.92%
10 / 13
0.00% covered (danger)
0.00%
0 / 1
4.20
 filterDaysInDateRange
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 narrowSlotsWindowToFirstBookableDay
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 readBookableDaysForRange
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
5
 findAdjacentBookableDatesByScan
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
1
 findFirstBookableDateAfter
93.33% covered (success)
93.33%
28 / 30
0.00% covered (danger)
0.00%
0 / 1
10.03
 findFirstBookableDateBefore
76.67% covered (warning)
76.67%
23 / 30
0.00% covered (danger)
0.00%
0 / 1
11.27
 formatDayIso
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 buildCalendarFromQuery
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 addProvidersFromQuery
78.57% covered (warning)
78.57%
11 / 14
0.00% covered (danger)
0.00%
0 / 1
5.25
 addRequestsFromQuery
86.96% covered (warning)
86.96%
20 / 23
0.00% covered (danger)
0.00%
0 / 1
8.14
 parseCsv
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 datePartsFromIso
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 buildResult
91.67% covered (success)
91.67%
44 / 48
0.00% covered (danger)
0.00%
0 / 1
12.08
 groupAppointmentsByDateAndOffice
88.89% covered (warning)
88.89%
16 / 18
0.00% covered (danger)
0.00%
0 / 1
7.07
 dayToArray
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 formatCalendarDate
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace BO\Zmsbackend\Calendar\Service;
4
5use BO\Zmsentities\Calendar as Entity;
6use BO\Zmsentities\Collection\DayList;
7
8/**
9 * Combined calendar days + free appointment slots in a single optimised DB pass.
10 *
11 * @SuppressWarnings(Coupling)
12 */
13class CalendarAvailability extends \BO\Zmsbackend\Base
14{
15    public function readFromQuery(
16        \DateTimeInterface $now,
17        string $slotType,
18        $slotsRequired,
19        ?string $startDate,
20        ?string $endDate,
21        ?string $officeIds,
22        ?string $serviceIds,
23        ?string $serviceCounts = '',
24        ?string $providerSource = null,
25        ?string $requestSource = null,
26        ?string $slotsStartDate = null,
27        ?string $slotsEndDate = null
28    ): array {
29        if (!$startDate || !$endDate || !$officeIds || !$serviceIds) {
30            throw new \BO\Zmsbackend\Slot\Exception\Calendar\InvalidAvailabilityInput(
31                'startDate, endDate, officeIds and serviceIds are required'
32            );
33        }
34
35        try {
36            $startDate = (new \DateTimeImmutable($startDate))->format('Y-m-d');
37            $endDate = (new \DateTimeImmutable($endDate))->format('Y-m-d');
38        } catch (\Exception $exception) {
39            throw new \BO\Zmsbackend\Slot\Exception\Calendar\InvalidAvailabilityInput(
40                'startDate and endDate must be valid dates (YYYY-MM-DD)'
41            );
42        }
43
44        if ($startDate > $endDate) {
45            throw new \BO\Zmsbackend\Slot\Exception\Calendar\InvalidAvailabilityInput(
46                'startDate must not be after endDate'
47            );
48        }
49
50        [$slotsStartDate, $slotsEndDate] = $this->resolveSlotsDateRange(
51            $startDate,
52            $endDate,
53            $slotsStartDate,
54            $slotsEndDate
55        );
56
57        $calendar = $this->buildCalendarFromQuery(
58            $startDate,
59            $endDate,
60            $officeIds,
61            $serviceIds,
62            $serviceCounts ?? '',
63            $providerSource,
64            $requestSource
65        );
66
67        return $this->readAvailability(
68            $calendar,
69            $now,
70            $slotType,
71            $slotsRequired,
72            $slotsStartDate,
73            $slotsEndDate
74        );
75    }
76
77    public function readAvailability(
78        Entity $calendar,
79        \DateTimeInterface $now,
80        string $slotType = 'public',
81        $slotsRequired = 0,
82        ?string $slotsStartDate = null,
83        ?string $slotsEndDate = null
84    ): array {
85        $dayRangeStart = $this->formatCalendarDate($calendar->firstDay);
86        $dayRangeEnd = $this->formatCalendarDate($calendar->lastDay);
87        [$slotsStartDate, $slotsEndDate] = $this->resolveSlotsDateRange(
88            $dayRangeStart,
89            $dayRangeEnd,
90            $slotsStartDate,
91            $slotsEndDate
92        );
93
94        // Free-slot SQL only for slotsStart/End (may be a single day).
95        // Bookable-day SQL only for the painted month of that window (not the full horizon).
96        [$responseStartDate, $responseEndDate] = $this->resolveResponseDaysRange(
97            $slotsStartDate,
98            $slotsEndDate,
99            $dayRangeStart,
100            $dayRangeEnd
101        );
102
103        $calendar = (new Calendar())->readResolvedEntity(
104            $calendar,
105            $now,
106            true,
107            $slotType,
108            $slotsRequired,
109            false
110        );
111        // Citizen-only: do not invent bookable scopes when slotsRequired would fall back to 1.
112        $calendar = $this->withoutUnrelatedScopes($calendar, $slotsRequired);
113
114        $dayQuery = new \BO\Zmsbackend\Day\Service\Day();
115        $bookableDays = $this->readBookableDaysForRange(
116            $calendar,
117            $dayQuery,
118            $slotsRequired,
119            $slotType,
120            $now,
121            $responseStartDate,
122            $responseEndDate,
123            false
124        );
125
126        $slotDays = $this->filterDaysInDateRange($bookableDays, $slotsStartDate, $slotsEndDate);
127        $responseDays = $this->filterDaysInDateRange($bookableDays, $responseStartDate, $responseEndDate);
128
129        // Free-slot SQL: one day only â€” either the first bookable day in a multi-day
130        // window, or (for a single-day miss) the first bookable day in the painted month.
131        if ($slotsStartDate !== $slotsEndDate) {
132            [$slotsStartDate, $slotsEndDate, $slotDays] = $this->narrowSlotsWindowToFirstBookableDay(
133                $slotDays,
134                $slotsStartDate,
135                $slotsEndDate
136            );
137        } elseif (count($slotDays) === 0 && count($responseDays) > 0) {
138            [$slotsStartDate, $slotsEndDate, $slotDays] = $this->narrowSlotsWindowToFirstBookableDay(
139                $responseDays,
140                $responseStartDate,
141                $responseEndDate
142            );
143        }
144
145        // Vorlauf / lead time: citizen starts with slotsStartDate=today, but bookable
146        // days may begin later. Snap free-slot + painted month to the first bookable
147        // day in the horizon instead of returning an empty calendar.
148        if (count($responseDays) === 0) {
149            $firstBookableDate = $this->findFirstBookableDateAfter(
150                $calendar,
151                $dayQuery,
152                $slotsRequired,
153                $slotType,
154                $now,
155                (new \DateTimeImmutable($responseStartDate))->modify('-1 day')->format('Y-m-d'),
156                $dayRangeEnd
157            );
158            if ($firstBookableDate !== null) {
159                $slotsStartDate = $firstBookableDate;
160                $slotsEndDate = $firstBookableDate;
161                [$responseStartDate, $responseEndDate] = $this->resolveResponseDaysRange(
162                    $slotsStartDate,
163                    $slotsEndDate,
164                    $dayRangeStart,
165                    $dayRangeEnd
166                );
167                $bookableDays = $this->readBookableDaysForRange(
168                    $calendar,
169                    $dayQuery,
170                    $slotsRequired,
171                    $slotType,
172                    $now,
173                    $responseStartDate,
174                    $responseEndDate,
175                    true
176                );
177                $responseDays = $this->filterDaysInDateRange(
178                    $bookableDays,
179                    $responseStartDate,
180                    $responseEndDate
181                );
182                $slotDays = $this->filterDaysInDateRange(
183                    $bookableDays,
184                    $slotsStartDate,
185                    $slotsEndDate
186                );
187            }
188        }
189
190        $calendar->days = $slotDays;
191
192        $processList = [];
193        if (count($slotDays) > 0) {
194            $processList = (new \BO\Zmsbackend\Process\Service\ProcessStatusFree())
195                ->readFreeProcessesMinimalFromPreparedCalendar(
196                    $calendar,
197                    $slotType,
198                    $slotsRequired,
199                    false
200                );
201        }
202
203        [$prevBookableDate, $nextBookableDate] = $this->findAdjacentBookableDatesByScan(
204            $calendar,
205            $dayQuery,
206            $slotsRequired,
207            $slotType,
208            $now,
209            $responseStartDate,
210            $responseEndDate,
211            $dayRangeStart,
212            $dayRangeEnd
213        );
214
215        $calendar->days = $responseDays;
216        return $this->buildResult(
217            $calendar,
218            $processList,
219            $slotsStartDate,
220            $slotsEndDate,
221            $prevBookableDate,
222            $nextBookableDate,
223            $now
224        );
225    }
226
227    /**
228     * Drop scopes with no request-relation slot requirement when slots are not forced.
229     * Shared Day::writeTemporaryScopeList still defaults those to 1 for zmsadmin;
230     * citizen availability must not invent bookable offices that way.
231     */
232    private function withoutUnrelatedScopes(Entity $calendar, $slotsRequiredForce): Entity
233    {
234        if ($slotsRequiredForce) {
235            return $calendar;
236        }
237
238        // Keep the same ScopeList instance so slotsByID from resolve stays intact.
239        foreach ($calendar->scopes as $key => $scope) {
240            if ($calendar->scopes->getRequiredSlotsByScope($scope) < 1) {
241                unset($calendar->scopes[$key]);
242            }
243        }
244
245        return $calendar;
246    }
247
248    /**
249     * Defaults slots window to the day range. When one side is missing, use the day range bound.
250     * Clamps the slots window to the day range intersection.
251     */
252    private function resolveSlotsDateRange(
253        string $startDate,
254        string $endDate,
255        ?string $slotsStartDate,
256        ?string $slotsEndDate
257    ): array {
258        $slotsStart = $slotsStartDate ?: $startDate;
259        $slotsEnd = $slotsEndDate ?: $endDate;
260
261        try {
262            $slotsStart = (new \DateTimeImmutable($slotsStart))->format('Y-m-d');
263            $slotsEnd = (new \DateTimeImmutable($slotsEnd))->format('Y-m-d');
264        } catch (\Exception $exception) {
265            throw new \BO\Zmsbackend\Slot\Exception\Calendar\InvalidAvailabilityInput(
266                'slotsStartDate and slotsEndDate must be valid dates (YYYY-MM-DD)'
267            );
268        }
269
270        if ($slotsStart > $slotsEnd) {
271            throw new \BO\Zmsbackend\Slot\Exception\Calendar\InvalidAvailabilityInput(
272                'slotsStartDate must not be after slotsEndDate'
273            );
274        }
275
276        // Clamp to day-status range so slot SQL never exceeds the resolved calendar.
277        if ($slotsStart < $startDate) {
278            $slotsStart = $startDate;
279        }
280        if ($slotsEnd > $endDate) {
281            $slotsEnd = $endDate;
282        }
283        if ($slotsStart > $slotsEnd) {
284            throw new \BO\Zmsbackend\Slot\Exception\Calendar\InvalidAvailabilityInput(
285                'slots date range does not overlap startDate/endDate'
286            );
287        }
288
289        return [$slotsStart, $slotsEnd];
290    }
291
292    /**
293     * Bookable days returned to the client cover full calendar month(s) of the free-slots window
294     * (clamped to the overall day-status range), so a single-day slot query still paints the month.
295     */
296    private function resolveResponseDaysRange(
297        string $slotsStartDate,
298        string $slotsEndDate,
299        string $dayRangeStart,
300        string $dayRangeEnd
301    ): array {
302        $monthStart = (new \DateTimeImmutable($slotsStartDate))
303            ->modify('first day of this month')
304            ->format('Y-m-d');
305        $monthEnd = (new \DateTimeImmutable($slotsEndDate))
306            ->modify('last day of this month')
307            ->format('Y-m-d');
308
309        if ($monthStart < $dayRangeStart) {
310            $monthStart = $dayRangeStart;
311        }
312        if ($monthEnd > $dayRangeEnd) {
313            $monthEnd = $dayRangeEnd;
314        }
315        if ($monthStart > $monthEnd) {
316            return [$slotsStartDate, $slotsEndDate];
317        }
318
319        return [$monthStart, $monthEnd];
320    }
321
322    private function filterDaysInDateRange(DayList $days, string $startDate, string $endDate): DayList
323    {
324        $filtered = new DayList();
325        foreach ($days as $day) {
326            $date = $this->formatDayIso($day);
327            if ($date >= $startDate && $date <= $endDate) {
328                $filtered->addEntity($day);
329            }
330        }
331
332        return $filtered;
333    }
334
335    /**
336     * When the client asks for free slots over a range, only query the earliest bookable day.
337     */
338    private function narrowSlotsWindowToFirstBookableDay(
339        DayList $slotDays,
340        string $slotsStartDate,
341        string $slotsEndDate
342    ): array {
343        $firstBookableDate = null;
344        foreach ($slotDays as $day) {
345            $date = $this->formatDayIso($day);
346            if ($firstBookableDate === null || $date < $firstBookableDate) {
347                $firstBookableDate = $date;
348            }
349        }
350
351        if ($firstBookableDate === null) {
352            return [$slotsStartDate, $slotsEndDate, new DayList()];
353        }
354
355        return [
356            $firstBookableDate,
357            $firstBookableDate,
358            $this->filterDaysInDateRange($slotDays, $firstBookableDate, $firstBookableDate),
359        ];
360    }
361
362    /**
363     * Run daylist for a date range only (calendarscope months derived from firstDay/lastDay).
364     * Restores the calendar horizon afterwards so response startDate/endDate stay full-range.
365     * When $rewrite is false, create the temp table (first paint); when true, drop+rebuild.
366     */
367    private function readBookableDaysForRange(
368        Entity $calendar,
369        \BO\Zmsbackend\Day\Service\Day $dayQuery,
370        $slotsRequired,
371        string $slotType,
372        \DateTimeInterface $now,
373        string $rangeStartDate,
374        string $rangeEndDate,
375        bool $rewrite
376    ): DayList {
377        $savedFirst = $calendar->firstDay;
378        $savedLast = $calendar->lastDay;
379        $calendar->firstDay = $this->datePartsFromIso($rangeStartDate);
380        $calendar->lastDay = $this->datePartsFromIso($rangeEndDate);
381
382        try {
383            if ($rewrite) {
384                $dayQuery->rewriteTemporaryScopeList($calendar, $slotsRequired);
385            } else {
386                $dayQuery->writeTemporaryScopeList($calendar, $slotsRequired);
387            }
388            $dayList = $dayQuery->readListFromPreparedTemporaryScopeList(
389                $slotsRequired,
390                \BO\Zmsbackend\Day\Repository\Day::QUERY_DAYLIST_JOIN_AVAILABILITY
391            )
392                ->setStatusByType($slotType, $now)
393                ->withDaysInDateRange($calendar->getFirstDay(), $calendar->getLastDay());
394        } finally {
395            $calendar->firstDay = $savedFirst;
396            $calendar->lastDay = $savedLast;
397        }
398
399        $bookableDays = new DayList();
400        foreach ($dayList as $day) {
401            $status = is_array($day) ? ($day['status'] ?? null) : ($day['status'] ?? null);
402            if ($status === 'bookable') {
403                $bookableDays->addEntity($day);
404            }
405        }
406
407        return $bookableDays;
408    }
409
410    /**
411     * Walk neighbor months until the first bookable date outside the painted window is found.
412     */
413    private function findAdjacentBookableDatesByScan(
414        Entity $calendar,
415        \BO\Zmsbackend\Day\Service\Day $dayQuery,
416        $slotsRequired,
417        string $slotType,
418        \DateTimeInterface $now,
419        string $responseStartDate,
420        string $responseEndDate,
421        string $dayRangeStart,
422        string $dayRangeEnd
423    ): array {
424        return [
425            $this->findFirstBookableDateBefore(
426                $calendar,
427                $dayQuery,
428                $slotsRequired,
429                $slotType,
430                $now,
431                $responseStartDate,
432                $dayRangeStart
433            ),
434            $this->findFirstBookableDateAfter(
435                $calendar,
436                $dayQuery,
437                $slotsRequired,
438                $slotType,
439                $now,
440                $responseEndDate,
441                $dayRangeEnd
442            ),
443        ];
444    }
445
446    private function findFirstBookableDateAfter(
447        Entity $calendar,
448        \BO\Zmsbackend\Day\Service\Day $dayQuery,
449        $slotsRequired,
450        string $slotType,
451        \DateTimeInterface $now,
452        string $afterDate,
453        string $horizonEnd
454    ): ?string {
455        $cursor = (new \DateTimeImmutable($afterDate))->modify('+1 day');
456        $horizonEndDate = new \DateTimeImmutable($horizonEnd);
457        if ($cursor > $horizonEndDate) {
458            return null;
459        }
460
461        while ($cursor <= $horizonEndDate) {
462            $monthFirst = $cursor->modify('first day of this month');
463            $monthLast = $cursor->modify('last day of this month');
464            if ($monthLast > $horizonEndDate) {
465                $monthLast = $horizonEndDate;
466            }
467
468            $bookableDays = $this->readBookableDaysForRange(
469                $calendar,
470                $dayQuery,
471                $slotsRequired,
472                $slotType,
473                $now,
474                $monthFirst->format('Y-m-d'),
475                $monthLast->format('Y-m-d'),
476                true
477            );
478
479            $firstInMonth = null;
480            foreach ($bookableDays as $day) {
481                $date = $this->formatDayIso($day);
482                if ($date <= $afterDate || $date > $horizonEnd) {
483                    continue;
484                }
485                if ($firstInMonth === null || $date < $firstInMonth) {
486                    $firstInMonth = $date;
487                }
488            }
489            if ($firstInMonth !== null) {
490                return $firstInMonth;
491            }
492
493            $cursor = $monthFirst->modify('first day of next month');
494        }
495
496        return null;
497    }
498
499    private function findFirstBookableDateBefore(
500        Entity $calendar,
501        \BO\Zmsbackend\Day\Service\Day $dayQuery,
502        $slotsRequired,
503        string $slotType,
504        \DateTimeInterface $now,
505        string $beforeDate,
506        string $horizonStart
507    ): ?string {
508        $cursor = (new \DateTimeImmutable($beforeDate))->modify('-1 day');
509        $horizonStartDate = new \DateTimeImmutable($horizonStart);
510        if ($cursor < $horizonStartDate) {
511            return null;
512        }
513
514        while ($cursor >= $horizonStartDate) {
515            $monthFirst = $cursor->modify('first day of this month');
516            $monthLast = $cursor->modify('last day of this month');
517            if ($monthFirst < $horizonStartDate) {
518                $monthFirst = $horizonStartDate;
519            }
520
521            $bookableDays = $this->readBookableDaysForRange(
522                $calendar,
523                $dayQuery,
524                $slotsRequired,
525                $slotType,
526                $now,
527                $monthFirst->format('Y-m-d'),
528                $monthLast->format('Y-m-d'),
529                true
530            );
531
532            $lastInMonth = null;
533            foreach ($bookableDays as $day) {
534                $date = $this->formatDayIso($day);
535                if ($date >= $beforeDate || $date < $horizonStart) {
536                    continue;
537                }
538                if ($lastInMonth === null || $date > $lastInMonth) {
539                    $lastInMonth = $date;
540                }
541            }
542            if ($lastInMonth !== null) {
543                return $lastInMonth;
544            }
545
546            $cursor = $monthFirst->modify('last day of previous month');
547        }
548
549        return null;
550    }
551
552    private function formatDayIso(mixed $day): string
553    {
554        $dayData = $this->dayToArray($day);
555
556        return sprintf(
557            '%04d-%02d-%02d',
558            (int) ($dayData['year'] ?? 0),
559            (int) ($dayData['month'] ?? 0),
560            (int) ($dayData['day'] ?? 0)
561        );
562    }
563
564    private function buildCalendarFromQuery(
565        string $startDate,
566        string $endDate,
567        string $officeIds,
568        string $serviceIds,
569        string $serviceCounts,
570        ?string $providerSource,
571        ?string $requestSource
572    ): Entity {
573        $calendar = new Entity();
574        $calendar->firstDay = $this->datePartsFromIso($startDate);
575        $calendar->lastDay = $this->datePartsFromIso($endDate);
576        $this->addProvidersFromQuery($calendar, $officeIds, $providerSource);
577        $this->addRequestsFromQuery($calendar, $serviceIds, $serviceCounts, $requestSource);
578
579        return $calendar;
580    }
581
582    private function addProvidersFromQuery(
583        Entity $calendar,
584        string $officeIds,
585        ?string $providerSource
586    ): void {
587        $officeIdList = $this->parseCsv($officeIds);
588        $providerSources = $providerSource
589            ? []
590            : (new \BO\Zmsbackend\Provider\Service\Provider())->readSourceMapByIds($officeIdList);
591
592        foreach ($officeIdList as $officeId) {
593            $source = $providerSource ?: ($providerSources[$officeId] ?? null);
594            if (!$source) {
595                throw new \BO\Zmsbackend\Slot\Exception\Calendar\InvalidAvailabilityInput(
596                    'Unknown officeId: ' . $officeId
597                );
598            }
599
600            $calendar->providers[] = [
601                'id' => (int) $officeId,
602                'source' => $source,
603            ];
604        }
605    }
606
607    private function addRequestsFromQuery(
608        Entity $calendar,
609        string $serviceIds,
610        string $serviceCounts,
611        ?string $requestSource
612    ): void {
613        $serviceIdList = $this->parseCsv($serviceIds);
614        $countList = $serviceCounts === '' ? [] : array_map('trim', explode(',', $serviceCounts));
615        $requestSources = $requestSource
616            ? []
617            : (new \BO\Zmsbackend\Request\Service\Request())->readSourceMapByIds(
618                array_values(array_filter(array_map('strval', $serviceIdList)))
619            );
620
621        foreach ($serviceIdList as $index => $serviceId) {
622            $source = $requestSource ?: ($requestSources[$serviceId] ?? null);
623            if (!$source) {
624                throw new \BO\Zmsbackend\Slot\Exception\Calendar\InvalidAvailabilityInput(
625                    'Unknown serviceId: ' . $serviceId
626                );
627            }
628
629            $count = max(1, (int) ($countList[$index] ?? 1));
630            if ($count > \BO\Zmsbackend\Slot\Service\Slot::MAX_SLOTS) {
631                throw new \BO\Zmsbackend\Slot\Exception\Calendar\InvalidAvailabilityInput(
632                    'serviceCount exceeds maximum of ' . \BO\Zmsbackend\Slot\Service\Slot::MAX_SLOTS
633                );
634            }
635            for ($slot = 0; $slot < $count; $slot++) {
636                $calendar->requests[] = [
637                    'id' => $serviceId,
638                    'source' => $source,
639                ];
640            }
641        }
642    }
643
644    /**
645     * @return array<int, string>
646     */
647    private function parseCsv(string $value): array
648    {
649        if ($value === '') {
650            return [];
651        }
652
653        return array_values(array_filter(array_map('trim', explode(',', $value)), static fn (string $item): bool => $item !== ''));
654    }
655
656    /**
657     * @return array{year: int, month: int, day: int}
658     */
659    private function datePartsFromIso(string $isoDate): array
660    {
661        $date = new \DateTimeImmutable($isoDate);
662
663        return [
664            'year' => (int) $date->format('Y'),
665            'month' => (int) $date->format('n'),
666            'day' => (int) $date->format('j'),
667        ];
668    }
669
670    /**
671     * @param array<int, array<string, mixed>> $processList
672     *
673     * @return array<string, mixed>
674     */
675    private function buildResult(
676        Entity $calendar,
677        array $processList,
678        string $slotsStartDate,
679        string $slotsEndDate,
680        ?string $prevBookableDate,
681        ?string $nextBookableDate,
682        \DateTimeInterface $now
683    ): array {
684        $scopeToProvider = [];
685        foreach ($calendar->scopes as $scope) {
686            $scopeToProvider[(string) $scope['id']] = (string) $scope['provider']['id'];
687        }
688
689        $appointmentsByDateAndOffice = $this->groupAppointmentsByDateAndOffice($processList, $now);
690        $days = [];
691
692        foreach ($calendar->days as $day) {
693            $dayData = $this->dayToArray($day);
694            if (($dayData['status'] ?? null) !== 'bookable') {
695                continue;
696            }
697
698            $date = sprintf(
699                '%04d-%02d-%02d',
700                (int) $dayData['year'],
701                (int) $dayData['month'],
702                (int) $dayData['day']
703            );
704
705            $inSlotsWindow = $date >= $slotsStartDate && $date <= $slotsEndDate;
706            $dayAppointments = $appointmentsByDateAndOffice[$date] ?? [];
707
708            if ($inSlotsWindow) {
709                // Free-slot verified: only providers that actually have appointments.
710                $providerIds = array_map('strval', array_keys($dayAppointments));
711                sort($providerIds, SORT_STRING);
712                if ($providerIds === []) {
713                    continue;
714                }
715            } else {
716                // Daylist paints the rest of the month without free-slot payloads.
717                // Do not intersect with the slots-window providers: an office can be
718                // closed on the free-slot day and still bookable later in the month.
719                $scopeIdList = isset($dayData['scopeIDs']) && $dayData['scopeIDs'] !== ''
720                    ? array_filter(explode(',', (string) $dayData['scopeIDs']))
721                    : [];
722                $providerIds = [];
723                foreach ($scopeIdList as $scopeId) {
724                    if (isset($scopeToProvider[$scopeId])) {
725                        $providerIds[] = $scopeToProvider[$scopeId];
726                    }
727                }
728                $providerIds = array_values(array_unique($providerIds));
729                sort($providerIds, SORT_STRING);
730                if ($providerIds === []) {
731                    continue;
732                }
733                $dayAppointments = [];
734            }
735
736            $days[] = [
737                'date' => $date,
738                'providerIDs' => implode(',', $providerIds),
739                'appointments' => $dayAppointments,
740            ];
741        }
742
743        return [
744            'startDate' => $this->formatCalendarDate($calendar->firstDay),
745            'endDate' => $this->formatCalendarDate($calendar->lastDay),
746            'slotsStartDate' => $slotsStartDate,
747            'slotsEndDate' => $slotsEndDate,
748            'prevBookableDate' => $prevBookableDate,
749            'nextBookableDate' => $nextBookableDate,
750            'days' => $days,
751        ];
752    }
753
754    /**
755     * @param array<int, array<string, mixed>> $processList
756     *
757     * @return array<string, array<string, array<int, int>>>
758     */
759    private function groupAppointmentsByDateAndOffice(array $processList, \DateTimeInterface $now): array
760    {
761        $grouped = [];
762        $nowTimestamp = $now->getTimestamp();
763
764        foreach ($processList as $process) {
765            $officeId = (string) ($process['scope']['provider']['id'] ?? '');
766            if ($officeId === '') {
767                continue;
768            }
769
770            foreach ($process['appointments'] ?? [] as $appointment) {
771                $timestamp = (int) ($appointment['date'] ?? 0);
772                if ($timestamp <= $nowTimestamp) {
773                    continue;
774                }
775
776                $date = date('Y-m-d', $timestamp);
777                $grouped[$date][$officeId][] = $timestamp;
778            }
779        }
780
781        foreach ($grouped as &$byOffice) {
782            foreach ($byOffice as &$timestamps) {
783                sort($timestamps);
784                $timestamps = array_values(array_unique($timestamps));
785            }
786        }
787        unset($byOffice, $timestamps);
788
789        return $grouped;
790    }
791
792    /**
793     * @return array<string, mixed>
794     */
795    private function dayToArray(mixed $day): array
796    {
797        if ($day instanceof \BO\Zmsentities\Day) {
798            return $day->getArrayCopy();
799        }
800
801        return (array) $day;
802    }
803
804    /**
805     * @param array<string, int|string>|null $date
806     */
807    private function formatCalendarDate(?array $date): string
808    {
809        return sprintf(
810            '%04d-%02d-%02d',
811            (int) ($date['year'] ?? 0),
812            (int) ($date['month'] ?? 0),
813            (int) ($date['day'] ?? 0)
814        );
815    }
816}