Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.67% covered (success)
90.67%
408 / 450
42.86% covered (danger)
42.86%
9 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
CalendarAvailability
90.67% covered (success)
90.67%
408 / 450
42.86% covered (danger)
42.86%
9 / 21
115.14
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
100.00% covered (success)
100.00%
118 / 118
100.00% covered (success)
100.00%
1 / 1
7
 withoutUnrelatedScopes
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
4.59
 resolveSlotsDateRange
69.57% covered (warning)
69.57%
16 / 23
0.00% covered (danger)
0.00%
0 / 1
11.28
 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. No overlap snaps to the nearest bound.
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            // No overlap: snap to the nearest day in the horizon instead of failing.
285            if ($slotsEnd < $startDate) {
286                $slotsStart = $startDate;
287                $slotsEnd = $startDate;
288            } else {
289                $slotsStart = $endDate;
290                $slotsEnd = $endDate;
291            }
292        }
293
294        return [$slotsStart, $slotsEnd];
295    }
296
297    /**
298     * Bookable days returned to the client cover full calendar month(s) of the free-slots window
299     * (clamped to the overall day-status range), so a single-day slot query still paints the month.
300     */
301    private function resolveResponseDaysRange(
302        string $slotsStartDate,
303        string $slotsEndDate,
304        string $dayRangeStart,
305        string $dayRangeEnd
306    ): array {
307        $monthStart = (new \DateTimeImmutable($slotsStartDate))
308            ->modify('first day of this month')
309            ->format('Y-m-d');
310        $monthEnd = (new \DateTimeImmutable($slotsEndDate))
311            ->modify('last day of this month')
312            ->format('Y-m-d');
313
314        if ($monthStart < $dayRangeStart) {
315            $monthStart = $dayRangeStart;
316        }
317        if ($monthEnd > $dayRangeEnd) {
318            $monthEnd = $dayRangeEnd;
319        }
320        if ($monthStart > $monthEnd) {
321            return [$slotsStartDate, $slotsEndDate];
322        }
323
324        return [$monthStart, $monthEnd];
325    }
326
327    private function filterDaysInDateRange(DayList $days, string $startDate, string $endDate): DayList
328    {
329        $filtered = new DayList();
330        foreach ($days as $day) {
331            $date = $this->formatDayIso($day);
332            if ($date >= $startDate && $date <= $endDate) {
333                $filtered->addEntity($day);
334            }
335        }
336
337        return $filtered;
338    }
339
340    /**
341     * When the client asks for free slots over a range, only query the earliest bookable day.
342     */
343    private function narrowSlotsWindowToFirstBookableDay(
344        DayList $slotDays,
345        string $slotsStartDate,
346        string $slotsEndDate
347    ): array {
348        $firstBookableDate = null;
349        foreach ($slotDays as $day) {
350            $date = $this->formatDayIso($day);
351            if ($firstBookableDate === null || $date < $firstBookableDate) {
352                $firstBookableDate = $date;
353            }
354        }
355
356        if ($firstBookableDate === null) {
357            return [$slotsStartDate, $slotsEndDate, new DayList()];
358        }
359
360        return [
361            $firstBookableDate,
362            $firstBookableDate,
363            $this->filterDaysInDateRange($slotDays, $firstBookableDate, $firstBookableDate),
364        ];
365    }
366
367    /**
368     * Run daylist for a date range only (calendarscope months derived from firstDay/lastDay).
369     * Restores the calendar horizon afterwards so response startDate/endDate stay full-range.
370     * When $rewrite is false, create the temp table (first paint); when true, drop+rebuild.
371     */
372    private function readBookableDaysForRange(
373        Entity $calendar,
374        \BO\Zmsbackend\Day\Service\Day $dayQuery,
375        $slotsRequired,
376        string $slotType,
377        \DateTimeInterface $now,
378        string $rangeStartDate,
379        string $rangeEndDate,
380        bool $rewrite
381    ): DayList {
382        $savedFirst = $calendar->firstDay;
383        $savedLast = $calendar->lastDay;
384        $calendar->firstDay = $this->datePartsFromIso($rangeStartDate);
385        $calendar->lastDay = $this->datePartsFromIso($rangeEndDate);
386
387        try {
388            if ($rewrite) {
389                $dayQuery->rewriteTemporaryScopeList($calendar, $slotsRequired);
390            } else {
391                $dayQuery->writeTemporaryScopeList($calendar, $slotsRequired);
392            }
393            $dayList = $dayQuery->readListFromPreparedTemporaryScopeList(
394                $slotsRequired,
395                \BO\Zmsbackend\Day\Repository\Day::QUERY_DAYLIST_JOIN_AVAILABILITY
396            )
397                ->setStatusByType($slotType, $now)
398                ->withDaysInDateRange($calendar->getFirstDay(), $calendar->getLastDay());
399        } finally {
400            $calendar->firstDay = $savedFirst;
401            $calendar->lastDay = $savedLast;
402        }
403
404        $bookableDays = new DayList();
405        foreach ($dayList as $day) {
406            $status = is_array($day) ? ($day['status'] ?? null) : ($day['status'] ?? null);
407            if ($status === 'bookable') {
408                $bookableDays->addEntity($day);
409            }
410        }
411
412        return $bookableDays;
413    }
414
415    /**
416     * Walk neighbor months until the first bookable date outside the painted window is found.
417     */
418    private function findAdjacentBookableDatesByScan(
419        Entity $calendar,
420        \BO\Zmsbackend\Day\Service\Day $dayQuery,
421        $slotsRequired,
422        string $slotType,
423        \DateTimeInterface $now,
424        string $responseStartDate,
425        string $responseEndDate,
426        string $dayRangeStart,
427        string $dayRangeEnd
428    ): array {
429        return [
430            $this->findFirstBookableDateBefore(
431                $calendar,
432                $dayQuery,
433                $slotsRequired,
434                $slotType,
435                $now,
436                $responseStartDate,
437                $dayRangeStart
438            ),
439            $this->findFirstBookableDateAfter(
440                $calendar,
441                $dayQuery,
442                $slotsRequired,
443                $slotType,
444                $now,
445                $responseEndDate,
446                $dayRangeEnd
447            ),
448        ];
449    }
450
451    private function findFirstBookableDateAfter(
452        Entity $calendar,
453        \BO\Zmsbackend\Day\Service\Day $dayQuery,
454        $slotsRequired,
455        string $slotType,
456        \DateTimeInterface $now,
457        string $afterDate,
458        string $horizonEnd
459    ): ?string {
460        $cursor = (new \DateTimeImmutable($afterDate))->modify('+1 day');
461        $horizonEndDate = new \DateTimeImmutable($horizonEnd);
462        if ($cursor > $horizonEndDate) {
463            return null;
464        }
465
466        while ($cursor <= $horizonEndDate) {
467            $monthFirst = $cursor->modify('first day of this month');
468            $monthLast = $cursor->modify('last day of this month');
469            if ($monthLast > $horizonEndDate) {
470                $monthLast = $horizonEndDate;
471            }
472
473            $bookableDays = $this->readBookableDaysForRange(
474                $calendar,
475                $dayQuery,
476                $slotsRequired,
477                $slotType,
478                $now,
479                $monthFirst->format('Y-m-d'),
480                $monthLast->format('Y-m-d'),
481                true
482            );
483
484            $firstInMonth = null;
485            foreach ($bookableDays as $day) {
486                $date = $this->formatDayIso($day);
487                if ($date <= $afterDate || $date > $horizonEnd) {
488                    continue;
489                }
490                if ($firstInMonth === null || $date < $firstInMonth) {
491                    $firstInMonth = $date;
492                }
493            }
494            if ($firstInMonth !== null) {
495                return $firstInMonth;
496            }
497
498            $cursor = $monthFirst->modify('first day of next month');
499        }
500
501        return null;
502    }
503
504    private function findFirstBookableDateBefore(
505        Entity $calendar,
506        \BO\Zmsbackend\Day\Service\Day $dayQuery,
507        $slotsRequired,
508        string $slotType,
509        \DateTimeInterface $now,
510        string $beforeDate,
511        string $horizonStart
512    ): ?string {
513        $cursor = (new \DateTimeImmutable($beforeDate))->modify('-1 day');
514        $horizonStartDate = new \DateTimeImmutable($horizonStart);
515        if ($cursor < $horizonStartDate) {
516            return null;
517        }
518
519        while ($cursor >= $horizonStartDate) {
520            $monthFirst = $cursor->modify('first day of this month');
521            $monthLast = $cursor->modify('last day of this month');
522            if ($monthFirst < $horizonStartDate) {
523                $monthFirst = $horizonStartDate;
524            }
525
526            $bookableDays = $this->readBookableDaysForRange(
527                $calendar,
528                $dayQuery,
529                $slotsRequired,
530                $slotType,
531                $now,
532                $monthFirst->format('Y-m-d'),
533                $monthLast->format('Y-m-d'),
534                true
535            );
536
537            $lastInMonth = null;
538            foreach ($bookableDays as $day) {
539                $date = $this->formatDayIso($day);
540                if ($date >= $beforeDate || $date < $horizonStart) {
541                    continue;
542                }
543                if ($lastInMonth === null || $date > $lastInMonth) {
544                    $lastInMonth = $date;
545                }
546            }
547            if ($lastInMonth !== null) {
548                return $lastInMonth;
549            }
550
551            $cursor = $monthFirst->modify('last day of previous month');
552        }
553
554        return null;
555    }
556
557    private function formatDayIso(\BO\Zmsentities\Day $day): string
558    {
559        $dayData = $this->dayToArray($day);
560
561        return sprintf(
562            '%04d-%02d-%02d',
563            (int) ($dayData['year'] ?? 0),
564            (int) ($dayData['month'] ?? 0),
565            (int) ($dayData['day'] ?? 0)
566        );
567    }
568
569    private function buildCalendarFromQuery(
570        string $startDate,
571        string $endDate,
572        string $officeIds,
573        string $serviceIds,
574        string $serviceCounts,
575        ?string $providerSource,
576        ?string $requestSource
577    ): Entity {
578        $calendar = new Entity();
579        $calendar->firstDay = $this->datePartsFromIso($startDate);
580        $calendar->lastDay = $this->datePartsFromIso($endDate);
581        $this->addProvidersFromQuery($calendar, $officeIds, $providerSource);
582        $this->addRequestsFromQuery($calendar, $serviceIds, $serviceCounts, $requestSource);
583
584        return $calendar;
585    }
586
587    private function addProvidersFromQuery(
588        Entity $calendar,
589        string $officeIds,
590        ?string $providerSource
591    ): void {
592        $officeIdList = $this->parseCsv($officeIds);
593        $providerSources = $providerSource
594            ? []
595            : (new \BO\Zmsbackend\Provider\Service\Provider())->readSourceMapByIds($officeIdList);
596
597        foreach ($officeIdList as $officeId) {
598            $source = $providerSource ?: ($providerSources[$officeId] ?? null);
599            if (!$source) {
600                throw new \BO\Zmsbackend\Slot\Exception\Calendar\InvalidAvailabilityInput(
601                    'Unknown officeId: ' . $officeId
602                );
603            }
604
605            $calendar->providers[] = [
606                'id' => (int) $officeId,
607                'source' => $source,
608            ];
609        }
610    }
611
612    private function addRequestsFromQuery(
613        Entity $calendar,
614        string $serviceIds,
615        string $serviceCounts,
616        ?string $requestSource
617    ): void {
618        $serviceIdList = $this->parseCsv($serviceIds);
619        $countList = $serviceCounts === '' ? [] : array_map('trim', explode(',', $serviceCounts));
620        $requestSources = $requestSource
621            ? []
622            : (new \BO\Zmsbackend\Request\Service\Request())->readSourceMapByIds(
623                array_values(array_filter(array_map('strval', $serviceIdList)))
624            );
625
626        foreach ($serviceIdList as $index => $serviceId) {
627            $source = $requestSource ?: ($requestSources[$serviceId] ?? null);
628            if (!$source) {
629                throw new \BO\Zmsbackend\Slot\Exception\Calendar\InvalidAvailabilityInput(
630                    'Unknown serviceId: ' . $serviceId
631                );
632            }
633
634            $count = max(1, (int) ($countList[$index] ?? 1));
635            if ($count > \BO\Zmsbackend\Slot\Service\Slot::MAX_SLOTS) {
636                throw new \BO\Zmsbackend\Slot\Exception\Calendar\InvalidAvailabilityInput(
637                    'serviceCount exceeds maximum of ' . \BO\Zmsbackend\Slot\Service\Slot::MAX_SLOTS
638                );
639            }
640            for ($slot = 0; $slot < $count; $slot++) {
641                $calendar->requests[] = [
642                    'id' => $serviceId,
643                    'source' => $source,
644                ];
645            }
646        }
647    }
648
649    /**
650     * @return array<int, string>
651     */
652    private function parseCsv(string $value): array
653    {
654        if ($value === '') {
655            return [];
656        }
657
658        return array_values(array_filter(array_map('trim', explode(',', $value)), static fn (string $item): bool => $item !== ''));
659    }
660
661    /**
662     * @return array{year: int, month: int, day: int}
663     */
664    private function datePartsFromIso(string $isoDate): array
665    {
666        $date = new \DateTimeImmutable($isoDate);
667
668        return [
669            'year' => (int) $date->format('Y'),
670            'month' => (int) $date->format('n'),
671            'day' => (int) $date->format('j'),
672        ];
673    }
674
675    /**
676     * @param array<int, array<string, mixed>> $processList
677     *
678     * @return array<string, mixed>
679     */
680    private function buildResult(
681        Entity $calendar,
682        array $processList,
683        string $slotsStartDate,
684        string $slotsEndDate,
685        ?string $prevBookableDate,
686        ?string $nextBookableDate,
687        \DateTimeInterface $now
688    ): array {
689        $scopeToProvider = [];
690        foreach ($calendar->scopes as $scope) {
691            $scopeToProvider[(string) $scope['id']] = (string) $scope['provider']['id'];
692        }
693
694        $appointmentsByDateAndOffice = $this->groupAppointmentsByDateAndOffice($processList, $now);
695        $days = [];
696
697        foreach ($calendar->days as $day) {
698            $dayData = $this->dayToArray($day);
699            if (($dayData['status'] ?? null) !== 'bookable') {
700                continue;
701            }
702
703            $date = sprintf(
704                '%04d-%02d-%02d',
705                (int) $dayData['year'],
706                (int) $dayData['month'],
707                (int) $dayData['day']
708            );
709
710            $inSlotsWindow = $date >= $slotsStartDate && $date <= $slotsEndDate;
711            $dayAppointments = $appointmentsByDateAndOffice[$date] ?? [];
712
713            if ($inSlotsWindow) {
714                // Free-slot verified: only providers that actually have appointments.
715                $providerIds = array_map('strval', array_keys($dayAppointments));
716                sort($providerIds, SORT_STRING);
717                if ($providerIds === []) {
718                    continue;
719                }
720            } else {
721                // Daylist paints the rest of the month without free-slot payloads.
722                // Do not intersect with the slots-window providers: an office can be
723                // closed on the free-slot day and still bookable later in the month.
724                $scopeIdList = isset($dayData['scopeIDs']) && $dayData['scopeIDs'] !== ''
725                    ? array_filter(explode(',', (string) $dayData['scopeIDs']))
726                    : [];
727                $providerIds = [];
728                foreach ($scopeIdList as $scopeId) {
729                    if (isset($scopeToProvider[$scopeId])) {
730                        $providerIds[] = $scopeToProvider[$scopeId];
731                    }
732                }
733                $providerIds = array_values(array_unique($providerIds));
734                sort($providerIds, SORT_STRING);
735                if ($providerIds === []) {
736                    continue;
737                }
738                $dayAppointments = [];
739            }
740
741            $days[] = [
742                'date' => $date,
743                'providerIDs' => implode(',', $providerIds),
744                'appointments' => $dayAppointments,
745            ];
746        }
747
748        return [
749            'startDate' => $this->formatCalendarDate($calendar->firstDay),
750            'endDate' => $this->formatCalendarDate($calendar->lastDay),
751            'slotsStartDate' => $slotsStartDate,
752            'slotsEndDate' => $slotsEndDate,
753            'prevBookableDate' => $prevBookableDate,
754            'nextBookableDate' => $nextBookableDate,
755            'days' => $days,
756        ];
757    }
758
759    /**
760     * @param array<int, array<string, mixed>> $processList
761     *
762     * @return array<string, array<string, array<int, int>>>
763     */
764    private function groupAppointmentsByDateAndOffice(array $processList, \DateTimeInterface $now): array
765    {
766        $grouped = [];
767        $nowTimestamp = $now->getTimestamp();
768
769        foreach ($processList as $process) {
770            $officeId = (string) ($process['scope']['provider']['id'] ?? '');
771            if ($officeId === '') {
772                continue;
773            }
774
775            foreach ($process['appointments'] ?? [] as $appointment) {
776                $timestamp = (int) ($appointment['date'] ?? 0);
777                if ($timestamp <= $nowTimestamp) {
778                    continue;
779                }
780
781                $date = date('Y-m-d', $timestamp);
782                $grouped[$date][$officeId][] = $timestamp;
783            }
784        }
785
786        foreach ($grouped as &$byOffice) {
787            foreach ($byOffice as &$timestamps) {
788                sort($timestamps);
789                $timestamps = array_values(array_unique($timestamps));
790            }
791        }
792        unset($byOffice, $timestamps);
793
794        return $grouped;
795    }
796
797    /**
798     * @return array<string, mixed>
799     */
800    private function dayToArray(mixed $day): array
801    {
802        if ($day instanceof \BO\Zmsentities\Day) {
803            return $day->getArrayCopy();
804        }
805
806        return (array) $day;
807    }
808
809    /**
810     * @param array<string, int|string>|null $date
811     */
812    private function formatCalendarDate(?array $date): string
813    {
814        return sprintf(
815            '%04d-%02d-%02d',
816            (int) ($date['year'] ?? 0),
817            (int) ($date['month'] ?? 0),
818            (int) ($date['day'] ?? 0)
819        );
820    }
821}