Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
69.98% covered (warning)
69.98%
324 / 463
76.92% covered (warning)
76.92%
30 / 39
CRAP
0.00% covered (danger)
0.00%
0 / 1
Availability
69.98% covered (warning)
69.98%
324 / 463
76.92% covered (warning)
76.92%
30 / 39
881.91
0.00% covered (danger)
0.00%
0 / 1
 getDefaults
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
1
 hasDate
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 hasBookableDates
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 isOpenedOnDate
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
8
 overridesDayOff
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
6.03
 isOpened
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 hasWeekDay
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 hasAppointment
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 hasTime
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getAvailableSecondsPerDay
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 hasDay
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 hasDayOff
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 hasWeek
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
10
 getStartDateTime
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 getEndDateTime
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 getDuration
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getBookableStart
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 getBookableEnd
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 isBookable
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
7
 hasDateBetween
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 validateStartTime
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
 validateWeekdays
0.00% covered (danger)
0.00%
0 / 48
0.00% covered (danger)
0.00%
0 / 1
110
 validateEndTime
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
12
 validateOriginEndTime
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
72
 validateType
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 validateSlotTime
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
12
 validateBookableDayRange
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 getSlotList
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 getSlotTimeInMinutes
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getConflict
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 isMatchOf
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
15
 hasSharedWeekdayWith
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
9
 getTimeOverlaps
100.00% covered (success)
100.00%
38 / 38
100.00% covered (success)
100.00%
1 / 1
12
 withCalculatedSlots
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 withScope
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 __toString
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
3
 offsetSet
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 isNewerThan
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 withLessData
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
9
1<?php
2
3namespace BO\Zmsentities;
4
5/**
6 * @SuppressWarnings(Complexity)
7 * @SuppressWarnings(Coupling)
8 * @SuppressWarnings(PublicMethod)
9 *
10 * Schema-backed entity (ArrayObject::ARRAY_AS_PROPS); document dynamic keys for Psalm.
11 *
12 * @property int|string $id
13 * @property Scope|array $scope
14 * @property string|null $description
15 * @property array $workstationCount
16 * @property int|string|null $slotTimeInMinutes
17 * @property string $type
18 * @property string|null $startTime
19 * @property string|null $endTime
20 * @property array $repeat
21 * @property bool|int|string|null $multipleSlotsAllowed
22 * @property array $bookable
23 * @property int|null $version
24 * @property array $weekday
25 */
26class Availability extends Schema\Entity
27{
28    public const string PRIMARY = 'id';
29
30    public static $schema = "availability.json";
31
32    /**
33     * @var array $weekday english localized weekdays to avoid problems with setlocale()
34     */
35    protected static $weekdayNameList = [
36        'sunday',
37        'monday',
38        'tuesday',
39        'wednesday',
40        'thursday',
41        'friday',
42        'saturday'
43    ];
44
45    /**
46     * Performance costs for modifying time are high, cache the calculated value
47     * @var \DateTimeImmutable $startTimeCache
48     */
49    protected $startTimeCache;
50
51    /**
52     * Performance costs for modifying time are high, cache the calculated value
53     * @var \DateTimeImmutable $endTimeCache
54     */
55    protected $endTimeCache;
56
57    /**
58     * Set Default values
59     *
60     * @return (((int|string)[]|int|string)[]|int|string|true)[]
61     *
62     */
63    #[\Override]
64    public function getDefaults()
65    {
66        return [
67            'id' => 0,
68            'weekday' => array_fill_keys(self::$weekdayNameList, 0),
69            'repeat' => [
70                'afterWeeks' => 1,
71                'weekOfMonth' => 0,
72            ],
73            'bookable' => [
74                'startInDays' => 1,
75                'endInDays' => 60,
76            ],
77            'workstationCount' => [
78                'public' => 0,
79                'intern' => 0,
80            ],
81            'lastChange' => 0,
82            'multipleSlotsAllowed' => true,
83            'slotTimeInMinutes' => 10,
84            'startDate' => 0,
85            'endDate' => 0,
86            'startTime' => '0:00',
87            'endTime' => '23:59',
88            'type' => 'appointment',
89            'version' => 1,
90            'scope' => [
91                'id' => 123,
92                'provider' => [
93                    'id' => 0,
94                    'name' => '',
95                    'source' => ''
96                ],
97                'shortName' => ''
98            ]
99        ];
100    }
101
102    /**
103     * Check, if the dateTime contains a day given by the settings
104     * ATTENTION: Time critical function, keep highly optimized
105     * Compared to isOpened() the Booking time is checked too
106     *
107     * @param \DateTimeInterface $dateTime
108     *
109     * @return Bool
110     */
111    public function hasDate(\DateTimeInterface $dateTime, \DateTimeInterface $now)
112    {
113        $dateTime = Helper\DateTime::create($dateTime);
114        if (
115            !$this->isOpenedOnDate($dateTime)
116            || !$this->isBookable($dateTime, $now)
117        ) {
118            // Out of date range
119            return false;
120        }
121        return true;
122    }
123
124    public function hasBookableDates(\DateTimeInterface $now): bool
125    {
126        if ($this->workstationCount['intern'] <= 0) {
127            return false;
128        }
129        if ($this->getEndDateTime()->getTimestamp() < $now->getTimestamp()) {
130            return false;
131        }
132        $stopDate = $this->getBookableEnd($now);
133        if ($this->getStartDateTime()->getTimestamp() > $stopDate->getTimestamp()) {
134            return false;
135        }
136        return $this->hasDateBetween($this->getBookableStart($now), $this->getBookableEnd($now), $now);
137    }
138
139    /**
140     * Check, if the dateTime contains a day
141     * ATTENTION: Time critical function, keep highly optimized
142     *
143     * @param \DateTimeInterface $dateTime
144     * @param string|false $type of "openinghours", "appointment" or false to ignore type
145     *
146     * @return Bool
147     */
148    public function isOpenedOnDate(\DateTimeInterface $dateTime, string|false $type = false)
149    {
150        $dateTime = Helper\DateTime::create($dateTime);
151        if (
152            !$this->hasWeekDay($dateTime)
153            || ($type !== false && $this->type != $type)
154            || !$this->hasDay($dateTime)
155            || !$this->hasWeek($dateTime)
156            || ($this->getDuration() > 1 && $this->hasDayOff($dateTime))
157        ) {
158            // Out of date range
159            return false;
160        }
161        return true;
162    }
163
164    public function overridesDayOff(): bool
165    {
166        if ($this->getDuration() >= 2) {
167            return false;
168        }
169
170        $start = $this->getStartDateTime()->modify('00:00:00');
171        $end   = $this->getEndDateTime()->modify('23:59:59');
172        $current = clone $start;
173
174        while ($current <= $end) {
175            try {
176                if ($this->hasWeekDay($current) && $this->hasDayOff($current)) {
177                    return true;
178                }
179            } catch (Exception\DayoffMissing $e) {
180                // Scope doesn't have dayoff data, skip this day
181            }
182            $current = $current->modify('+1 day');
183        }
184        return false;
185    }
186
187    /**
188     * Check if date and time is in availability
189     * Compared to hasDate() the time of the day is checked, but not booking time
190     *
191     * @param \DateTimeInterface $dateTime
192     * @param string|false $type of "openinghours", "appointment" or false to ignore type
193     *
194     */
195    public function isOpened(\DateTimeInterface $dateTime, string|false $type = false): bool
196    {
197        return (!$this->isOpenedOnDate($dateTime, $type) || !$this->hasTime($dateTime)) ? false : true;
198    }
199
200    public function hasWeekDay(\DateTimeInterface $dateTime): bool
201    {
202        $weekDayName = self::$weekdayNameList[$dateTime->format('w')];
203        if (!$this['weekday'][$weekDayName]) {
204            // Wrong weekday
205            return false;
206        }
207        return true;
208    }
209
210    public function hasAppointment(Appointment $appointment): bool
211    {
212        $dateTime = $appointment->toDateTime();
213        $isOpenedStart = $this->isOpened($dateTime, false);
214        $duration = $this->slotTimeInMinutes * $appointment->slotCount;
215        $endTime = $dateTime->modify("+" . $duration . "minutes")
216            ->modify("-1 second"); // To allow the last slot for an appointment
217        $isOpenedEnd = $this->isOpened($endTime, false);
218        return ($isOpenedStart && $isOpenedEnd);
219    }
220
221    /**
222     * Check, if the dateTime is a time covered by availability
223     *
224     * @param \DateTimeInterface $dateTime
225     *
226     * @return Bool
227     */
228    public function hasTime(\DateTimeInterface $dateTime)
229    {
230        $start = $this->getStartDateTime()->getSecondsOfDay();
231        $end = $this->getEndDateTime()->getSecondsOfDay();
232        $compare = Helper\DateTime::create($dateTime)->getSecondsOfDay();
233        if ($start > $compare || $end <= $compare) {
234            // Out of time range
235            return false;
236        }
237        return true;
238    }
239
240    public function getAvailableSecondsPerDay($type = "intern")
241    {
242        $start = $this->getStartDateTime()->getSecondsOfDay();
243        $end = $this->getEndDateTime()->getSecondsOfDay();
244        return ($end - $start) * $this->workstationCount[$type];
245    }
246
247    /**
248     * Check, if the dateTime is a day covered by availability
249     *
250     * @param \DateTimeInterface $dateTime
251     *
252     * @return Bool
253     */
254    public function hasDay(\DateTimeInterface $dateTime)
255    {
256        $start = $this->getStartDateTime()->modify('0:00:00');
257        $end = $this->getEndDateTime()->modify('23:59:59');
258        if ($dateTime->getTimestamp() < $start->getTimestamp() || $dateTime->getTimestamp() > $end->getTimestamp()) {
259            // Out of date range
260            return false;
261        }
262        return true;
263    }
264
265    /**
266     * Check, if the dateTime is a dayoff date
267     *
268     * @param \DateTimeInterface $dateTime
269     *
270     * @return Bool
271     */
272    public function hasDayOff(\DateTimeInterface $dateTime)
273    {
274        if (isset($this['scope']['dayoff'])) {
275            $timeStamp = $dateTime->format('Y-m-d');
276            foreach ($this['scope']['dayoff'] as $dayOff) {
277                if (date('Y-m-d', $dayOff['date']) == $timeStamp) {
278                    return true;
279                }
280            }
281        } else {
282            throw new Exception\DayoffMissing();
283        }
284        return false;
285    }
286
287    /**
288     * Check, if the dateTime contains a week given by the week repetition settings
289     *
290     * @param \DateTimeInterface $dateTime
291     *
292     * @return Bool
293     */
294    public function hasWeek(\DateTimeInterface $dateTime)
295    {
296        $dateTime = Helper\DateTime::create($dateTime);
297        $start = $this->getStartDateTime();
298        $monday = "monday this week";
299        if (
300            $this['repeat']['afterWeeks']
301            && ($this['repeat']['afterWeeks'] == 1
302                || 0 ===
303                    $dateTime->modify($monday)->diff($start->modify($monday))->days
304                 % ($this['repeat']['afterWeeks'] * 7)
305            )
306        ) {
307            return true;
308        }
309        if (
310            $this['repeat']['weekOfMonth']
311            && (
312                $dateTime->isWeekOfMonth($this['repeat']['weekOfMonth'])
313                // On a value of 5, always take the last week
314                || ($this['repeat']['weekOfMonth'] >= 5 && $dateTime->isLastWeekOfMonth())
315            )
316        ) {
317            return true;
318        }
319        if (!$this['repeat']['weekOfMonth'] && !$this['repeat']['afterWeeks']) {
320            return true;
321        }
322        return false;
323    }
324
325    /**
326     * Get DateTimeInterface for start time of availability
327     *
328     * @return \DateTimeInterface
329     */
330    public function getStartDateTime()
331    {
332        if (!$this->startTimeCache) {
333            $this->startTimeCache = Helper\DateTime::create()
334                ->setTimestamp($this['startDate'])
335                ->modify('today ' .  $this['startTime']);
336        }
337        return $this->startTimeCache;
338    }
339
340    /**
341     * Get DateTimeInterface for end time of availability
342     *
343     * @return \DateTimeInterface
344     */
345    public function getEndDateTime()
346    {
347        if (!$this->endTimeCache) {
348            $this->endTimeCache = Helper\DateTime::create()
349                ->setTimestamp($this['endDate'])
350                ->modify('today ' .  $this['endTime']);
351        }
352        return $this->endTimeCache;
353    }
354
355    /**
356     * Get duration of availability
357     *
358     * @return integer
359     */
360    public function getDuration()
361    {
362        $startTime = $this->getStartDateTime();
363        $endTime = $this->getEndDateTime();
364        return (int)$endTime->diff($startTime)->format("%a");
365    }
366
367    /**
368     * Get DateTimeInterface for start booking time of availability
369     *
370     * @param \DateTimeInterface $now relative time to compare booking settings
371     *
372     * @return \DateTimeInterface
373     */
374    public function getBookableStart(\DateTimeInterface $now)
375    {
376        $now = Helper\DateTime::create($now);
377        $availabilityStart = Helper\Property::create($this)->bookable->startInDays->get();
378        $time = $this->getStartDateTime()->format('H:i:s');
379        if (null !== $availabilityStart) {
380            return $now->modify('+' . $availabilityStart . 'days')->modify($time);
381        }
382        $scopeStart = Helper\Property::create($this)->scope->preferences->appointment->startInDaysDefault->get();
383        if (null !== $scopeStart) {
384            return $now->modify('+' . $scopeStart . 'days')->modify($time);
385        }
386        throw new \BO\Zmsentities\Exception\ProcessBookableFailed(
387            "Undefined start time for booking, try to set the scope properly"
388        );
389    }
390
391    /**
392     * Get DateTimeInterface for end booking time of availability
393     *
394     * @param \DateTimeInterface $now relative time to compare booking settings
395     *
396     * @return \DateTimeInterface
397     */
398    public function getBookableEnd(\DateTimeInterface $now)
399    {
400        $now = Helper\DateTime::create($now);
401        $availabilityEnd = Helper\Property::create($this)->bookable->endInDays->get();
402        $time = $this->getEndDateTime()->format('H:i:s');
403        if (null !== $availabilityEnd) {
404            return $now->modify('+' . $availabilityEnd . 'days')->modify($time);
405        }
406        $scopeEnd = Helper\Property::create($this)->scope->preferences->appointment->endInDaysDefault->get();
407        if (null !== $scopeEnd) {
408            return $now->modify('+' . $scopeEnd . 'days')->modify($time);
409        }
410        throw new \BO\Zmsentities\Exception\ProcessBookableFailed(
411            "Undefined end time for booking, try to set the scope properly"
412        );
413    }
414
415    /**
416     * Check, if the dateTime contains is within the bookable range (usually for public access)
417     * The current time is used to compare the start Time of the availability
418     *
419     * @param \DateTimeInterface $bookableDate
420     * @param \DateTimeInterface $now relative time to compare booking settings
421     *
422     * @return bool
423     */
424    public function isBookable(\DateTimeInterface $bookableDate, \DateTimeInterface $now)
425    {
426        if (!$this->hasDay($bookableDate)) {
427            return false;
428        }
429        $bookableCurrentTime = $bookableDate->modify($now->format('H:i:s'));
430        Helper\DateTime::create($bookableDate)->getTimestamp() + Helper\DateTime::create($now)->getSecondsOfDay();
431        $startDate = $this->getBookableStart($now)->modify('00:00:00');
432
433        if ($bookableCurrentTime->getTimestamp() < $startDate->getTimestamp()) {
434            return false;
435        }
436        $endDate = $this->getBookableEnd($now)->modify('23:59:59');
437        if ($bookableCurrentTime->getTimestamp() > $endDate->getTimestamp()) {
438            return false;
439        }
440        if (
441            $bookableDate->format('Y-m-d') == $endDate->format('Y-m-d')
442            && $now->format('Y-m-d') != $this->getEndDateTime()->format('Y-m-d')
443        ) {
444            // Avoid releasing all appointments on midnight, allow smaller contingents distributed over the day
445            $delayedStart = $this->getBookableEnd($now)->modify($this->getStartDateTime()->format('H:i:s'));
446            if ($bookableCurrentTime->getTimestamp() < $delayedStart->getTimestamp()) {
447                return false;
448            }
449        }
450        return true;
451    }
452
453    /**
454     * Check, if a day between two dates is included
455     *
456     * @return bool
457     */
458    public function hasDateBetween(\DateTimeInterface $startTime, \DateTimeInterface $stopTime, \DateTimeInterface $now): bool
459    {
460        if ($startTime->getTimestamp() < $now->getTimestamp()) {
461            $startTime = $now;
462        }
463        if ($stopTime->getTimestamp() < $now->getTimestamp()) {
464            return false;
465        }
466        do {
467            if ($this->hasDate($startTime, $now)) {
468                return true;
469            }
470            $startTime = $startTime->modify('+1 day');
471        } while ($startTime->getTimestamp() <= $stopTime->getTimestamp());
472        return false;
473    }
474
475    public function validateStartTime(\DateTimeInterface $today, \DateTimeInterface $tomorrow, \DateTimeInterface $startDate, \DateTimeInterface $selectedDate, string $kind): array
476    {
477        $errorList = [];
478
479        $startTime = (clone $startDate)->setTime(0, 0);
480        $isFuture = ($kind && $kind === 'future');
481
482        if (
483            !$isFuture &&
484            $selectedDate->getTimestamp() > $today->getTimestamp() &&
485            $startTime->getTimestamp() > (clone $selectedDate)->setTime(0, 0)->getTimestamp()
486        ) {
487            $errorList[] = [
488                'type' => 'startTimeFuture',
489                'message' => "Das Startdatum der Ã–ffnungszeit muss vor dem " . $tomorrow->format('d.m.Y') . " liegen."
490            ];
491        }
492
493        return $errorList;
494    }
495
496    public function validateWeekdays(\DateTimeInterface $startDate, \DateTimeInterface $endDate, array $weekday, string $kind): array
497    {
498        $errorList = [];
499
500        // Skip validation if this is part of a split series
501        if ($kind === 'origin' || $kind === 'future') {
502            return $errorList;
503        }
504
505        if ($startDate > $endDate) {
506            return $errorList;
507        }
508
509        $hasSelectedDay = false;
510        foreach (self::$weekdayNameList as $day) {
511            if ((int)$weekday[$day] > 0) {
512                $hasSelectedDay = true;
513                break;
514            }
515        }
516
517        if (!$hasSelectedDay) {
518            $errorList[] = [
519                'type' => 'weekdayRequired',
520                'message' => 'Mindestens ein Wochentag muss ausgewählt sein.'
521            ];
522            return $errorList;
523        }
524
525        $germanWeekdays = [
526            'sunday' => 'Sonntag',
527            'monday' => 'Montag',
528            'tuesday' => 'Dienstag',
529            'wednesday' => 'Mittwoch',
530            'thursday' => 'Donnerstag',
531            'friday' => 'Freitag',
532            'saturday' => 'Samstag'
533        ];
534
535        $selectedWeekdays = array_filter(self::$weekdayNameList, function ($day) use ($weekday) {
536            return (int)$weekday[$day] > 0;
537        });
538        $foundWeekdays = [];
539
540        $currentDate = clone $startDate;
541        while ($currentDate <= $endDate) {
542            $weekDayName = self::$weekdayNameList[$currentDate->format('w')];
543            if (in_array($weekDayName, $selectedWeekdays)) {
544                $foundWeekdays[] = $weekDayName;
545            }
546            $currentDate = $currentDate->modify('+1 day');
547        }
548
549        $missingWeekdays = array_diff($selectedWeekdays, array_unique($foundWeekdays));
550        if (!empty($missingWeekdays)) {
551            $germanMissingWeekdays = array_map(function ($day) use ($germanWeekdays) {
552                return $germanWeekdays[$day];
553            }, $missingWeekdays);
554
555            $errorList[] = [
556                'type' => 'invalidWeekday',
557                'message' => sprintf(
558                    'Die ausgewählten Wochentage (%s) kommen im gewählten Zeitraum nicht vor.',
559                    implode(', ', $germanMissingWeekdays)
560                )
561            ];
562        }
563
564        return $errorList;
565    }
566
567    public function validateEndTime(\DateTimeInterface $startDate, \DateTimeInterface $endDate): array
568    {
569        $errorList = [];
570
571        $startHour = (int) $startDate->format('H');
572        $endHour = (int) $endDate->format('H');
573        $startMinute = (int) $startDate->format('i');
574        $endMinute = (int) $endDate->format('i');
575        $dayMinutesStart = ($startHour * 60) + $startMinute;
576        $dayMinutesEnd = ($endHour * 60) + $endMinute;
577        $startTimestamp = $startDate->getTimestamp();
578        $endTimestamp = $endDate->getTimestamp();
579
580        if ($dayMinutesEnd <= $dayMinutesStart) {
581            $errorList[] = [
582                'type' => 'endTime',
583                'message' => 'Die Endzeit darf nicht vor der Startzeit liegen.'
584            ];
585        } elseif ($startTimestamp >= $endTimestamp) {
586            $errorList[] = [
587                'type' => 'endTime',
588                'message' => 'Das Enddatum darf nicht vor dem Startdatum liegen.'
589            ];
590        }
591
592        return $errorList;
593    }
594
595    public function validateOriginEndTime(\DateTimeInterface $today, \DateTimeInterface $yesterday, \DateTimeInterface $endDate, \DateTimeInterface $selectedDate, string $kind): array
596    {
597        $errorList = [];
598        $endHour = (int) $endDate->format('H');
599        $endMinute = (int) $endDate->format('i');
600        $startDate = $this->getStartDateTime();
601        $startHour = (int) $startDate->format('H');
602        $startMinute = (int) $startDate->format('i');
603        $endDateTime = (clone $endDate)->setTime($endHour, $endMinute);
604        $startDateTime = (clone $startDate)->setTime($startHour, $startMinute);
605        $endTimestamp = $endDateTime->getTimestamp();
606        $startTimestamp = $startDateTime->getTimestamp();
607        $isOrigin = ($kind && $kind === 'origin');
608
609        if (!$isOrigin && $selectedDate->getTimestamp() > $today->getTimestamp() && $endDate < (clone $selectedDate)->setTime(0, 0)) {
610            $errorList[] = [
611                'type' => 'endTimeFuture',
612                'message' => "Das Enddatum der Ã–ffnungszeit muss nach dem " . $yesterday->format('d.m.Y') . " liegen."
613            ];
614        }
615
616        if (!$isOrigin && $startTimestamp < $today->getTimestamp() && $endTimestamp < $today->getTimestamp()) {
617            $errorList[] = [
618                'type' => 'endTimePast',
619                'message' => 'Öffnungszeiten in der Vergangenheit lassen sich nicht bearbeiten '
620                    . '(Die aktuelle Zeit "' . $today->format('d.m.Y H:i') . ' Uhr" liegt nach dem Terminende am "'
621                    . $endDateTime->format('d.m.Y H:i') . ' Uhr" und dem Terminanfang am "'
622                    . $startDateTime->format('d.m.Y H:i') . ' Uhr").'
623            ];
624        }
625
626        return $errorList;
627    }
628
629    public function validateType(string $kind): array
630    {
631        $errorList = [];
632        if (empty($kind)) {
633            $errorList[] = [
634                'type' => 'type',
635                'message' => 'Typ erforderlich'
636            ];
637        }
638        return $errorList;
639    }
640
641    public function validateSlotTime(\DateTimeInterface $startDate, \DateTimeInterface $endDate): array
642    {
643        $errorList = [];
644        $slotTime = $this['slotTimeInMinutes'];
645
646        $startHour = (int)$startDate->format('H');
647        $startMinute = (int)$startDate->format('i');
648        $endHour = (int)$endDate->format('H');
649        $endMinute = (int)$endDate->format('i');
650
651        $totalMinutes = (($endHour - $startHour) * 60) + ($endMinute - $startMinute);
652
653        if ($slotTime === 0) {
654            $errorList[] = [
655                'type' => 'slotTime',
656                'message' => 'Die Slot-Zeit darf nicht 0 sein.'
657            ];
658            return $errorList;
659        }
660
661        if ($totalMinutes % $slotTime > 0) {
662            $errorList[] = [
663                'type' => 'slotCount',
664                'message' => 'Zeitschlitze müssen sich gleichmäßig in der Ã–ffnungszeit aufteilen lassen.'
665            ];
666        }
667
668        return $errorList;
669    }
670
671    public function validateBookableDayRange(int $startInDays, int $endInDays): array
672    {
673        $errorList = [];
674        if ($startInDays > $endInDays) {
675            $errorList[] = [
676                'type' => 'bookableDayRange',
677                'message' => 'Bitte geben Sie im Feld \'von\' eine kleinere Zahl ein als im Feld \'bis\', wenn Sie bei \'Buchbar\' sind.'
678            ];
679        }
680
681        return $errorList;
682    }
683
684    /**
685     * Creates a list of slots available on a valid day
686     *
687     * @return Array of arrays with the keys time, public, intern
688     */
689    public function getSlotList()
690    {
691        $startTime = Helper\DateTime::create($this['startTime']);
692        $stopTime = Helper\DateTime::create($this['endTime']);
693        $slotList = new Collection\SlotList();
694        $slotInstance = new Slot($this['workstationCount']);
695        if ($this['slotTimeInMinutes'] > 0) {
696            do {
697                $slot = clone $slotInstance;
698                $slot->setTime($startTime);
699                $slotList[] = $slot;
700                $startTime = $startTime->modify('+' . $this['slotTimeInMinutes'] . 'minute');
701                // Only add a slot, if at least a minute is left, otherwise do not ("<" instead "<=")
702            } while ($startTime->getTimestamp() < $stopTime->getTimestamp());
703        }
704        return $slotList;
705    }
706
707    public function getSlotTimeInMinutes()
708    {
709        return $this['slotTimeInMinutes'];
710    }
711
712    /**
713     * Get problems on configuration of this availability
714     *
715     * @return Collection\ProcessList with processes in status "conflict"
716     */
717    public function getConflict()
718    {
719        $start = $this->getStartDateTime()->getSecondsOfDay();
720        $end = $this->getEndDateTime()->getSecondsOfDay();
721        $minutesPerDay = floor(($end - $start) / 60);
722        if ($minutesPerDay % $this->slotTimeInMinutes > 0) {
723            $conflict = new Process();
724            $conflict->status = 'conflict';
725            $appointment = $conflict->getFirstAppointment();
726            $appointment->availability = $this;
727            $appointment->date = $this->getStartDateTime()->getTimestamp();
728            $conflict->amendment =
729                "Der eingestellte Zeitschlitz von {$this->slotTimeInMinutes} Minuten"
730                . " sollte in die eingestellte Uhrzeit passen.";
731            return $conflict;
732        }
733        return false;
734    }
735
736    /**
737     * Check of a different availability has the same opening configuration
738     */
739    public function isMatchOf(Availability $availability): bool
740    {
741        return ($this->type != $availability->type
742            || $this->startTime != $availability->startTime
743            || $this->endTime != $availability->endTime
744            || $this->startDate != $availability->startDate
745            || $this->endDate != $availability->endDate
746            || $this->repeat['afterWeeks'] != $availability->repeat['afterWeeks']
747            || $this->repeat['weekOfMonth'] != $availability->repeat['weekOfMonth']
748            || (bool)$this->weekday['monday'] != (bool)$availability->weekday['monday']
749            || (bool)$this->weekday['tuesday'] != (bool)$availability->weekday['tuesday']
750            || (bool)$this->weekday['wednesday'] != (bool)$availability->weekday['wednesday']
751            || (bool)$this->weekday['thursday'] != (bool)$availability->weekday['thursday']
752            || (bool)$this->weekday['friday'] != (bool)$availability->weekday['friday']
753            || (bool)$this->weekday['saturday'] != (bool)$availability->weekday['saturday']
754            || (bool)$this->weekday['sunday'] != (bool)$availability->weekday['sunday']
755        ) ? false : true;
756    }
757
758    public function hasSharedWeekdayWith(Availability $availability): bool
759    {
760        return ($this->type == $availability->type
761            && (bool)$this->weekday['monday'] != (bool)$availability->weekday['monday']
762            && (bool)$this->weekday['tuesday'] != (bool)$availability->weekday['tuesday']
763            && (bool)$this->weekday['wednesday'] != (bool)$availability->weekday['wednesday']
764            && (bool)$this->weekday['thursday'] != (bool)$availability->weekday['thursday']
765            && (bool)$this->weekday['friday'] != (bool)$availability->weekday['friday']
766            && (bool)$this->weekday['saturday'] != (bool)$availability->weekday['saturday']
767            && (bool)$this->weekday['sunday'] != (bool)$availability->weekday['sunday']
768        ) ? false : true;
769    }
770
771    /**
772     * Get overlaps on daytime
773     * This functions does not check, if two availabilities are openend on the same day!
774     *
775     * @param Availability $availability for comparision
776     *
777     * @return Collection\ProcessList with processes in status "conflict"
778     *
779     *
780     */
781
782    /*
783    1
784    Case 01:  |-----|
785              |-----|
786                 2
787
788                 1
789    Case 02:  |-----|
790                 |-----|
791                    2
792
793                    1
794    Case 03:     |-----|
795              |-----|
796                 2
797
798                   1
799    Case 04:  |---------|
800                |-----|
801                   2
802
803                   1
804    Case 05:    |-----|
805              |---------|
806                   2
807
808                 1
809    Case 06:  |-----|
810                      |-----|
811                         2
812
813                         1
814    Case 07:          |-----|
815              |-----|
816                 2
817
818                 1
819    Case 08:  |-----|
820                    |-----|
821                       2
822
823                       1
824    Case 09:        |-----|
825              |-----|
826                 2
827
828                 1
829    Case 10:     |
830              |-----|
831                 2
832
833                 1
834    Case 11:  |-----|
835                 |
836                 2
837
838              1
839    Case 12:  |
840              |-----|
841                 2
842
843                    1
844    Case 13:        |
845              |-----|
846                 2
847
848                 1
849    Case 14:  |-----|
850              |
851              2
852
853                 1
854    Case 15:  |-----|
855                    |
856                    2
857
858              1
859    Case 16:  |
860              |
861              2
862
863            |                         |    Operlap    |     Overlap
864      Case  |         Example         | Open Interval | Closed Interval
865    --------|-------------------------|---------------|-----------------
866    Case 01 | 09:00-11:00 09:00-11:00 |      Yes      |        Yes
867    Case 02 | 09:00-11:00 10:00-12:00 |      Yes      |        Yes
868    Case 03 | 10:00-12:00 09:00-11:00 |      Yes      |        Yes
869    Case 04 | 09:00-12:00 10:00-11:00 |      Yes      |        Yes
870    Case 05 | 10:00-11:00 09:00-12:00 |      Yes      |        Yes
871    Case 06 | 09:00-10:00 11:00-12:00 |      No       |        No
872    Case 07 | 11:00-12:00 09:00-10:00 |      No       |        No
873    Case 08 | 09:00-10:00 10:00-11:00 |      No       |        Yes
874    Case 09 | 10:00-11:00 09:00-10:00 |      No       |        Yes
875    Case 10 | 10:00-10:00 09:00-11:00 |      Yes      |        Yes
876    Case 11 | 09:00-11:00 10:00-10:00 |      Yes      |        Yes
877    Case 12 | 09:00-09:00 09:00-10:00 |      No       |        Yes
878    Case 13 | 10:00-10:00 09:00-10:00 |      No       |        Yes
879    Case 14 | 09:00-10:00 09:00-09:00 |      No       |        Yes
880    Case 15 | 09:00-10:00 10:00-10:00 |      No       |        Yes
881    Case 16 | 09:00-09:00 09:00-09:00 |      No       |        Yes
882    */
883
884    public function getTimeOverlaps(Availability $availability, \DateTimeInterface $currentDate)
885    {
886        $processList = new Collection\ProcessList();
887        if (
888            $availability->id != $this->id
889            && $availability->type == $this->type
890            && $this->hasSharedWeekdayWith($availability)
891        ) {
892            $processTemplate = new Process();
893            $processTemplate->amendment = "Zwei Ã–ffnungszeiten Ã¼berschneiden sich.";
894            $processTemplate->status = 'conflict';
895            $appointment = $processTemplate->getFirstAppointment();
896            $appointment->availability = $this;
897            $appointment->date = $this->getStartDateTime()->getTimestamp();
898            $thisStart = $this->getStartDateTime()->getSecondsOfDay();
899            $thisEnd = $this->getEndDateTime()->getSecondsOfDay();
900            $availabilityStart = $availability->getStartDateTime()->getSecondsOfDay();
901            $availabilityEnd = $availability->getEndDateTime()->getSecondsOfDay();
902
903            $isEqual = ($availabilityStart == $thisStart && $availabilityEnd == $thisEnd);
904
905            if ($availabilityStart < $thisEnd && $thisStart < $availabilityEnd && ! $isEqual) {
906                $process = clone $processTemplate;
907                $process->getFirstAppointment()->date = $this
908                    ->getStartDateTime()
909                    ->modify($currentDate->format("Y-m-d"))
910                    ->getTimestamp();
911                $processList->addEntity($process);
912            } elseif ($thisEnd < $availabilityStart && $availabilityEnd < $thisStart && ! $isEqual) {
913                $process = clone $processTemplate;
914                $process->getFirstAppointment()->date = $availability
915                    ->getStartDateTime()
916                    ->modify($currentDate->format("Y-m-d"))
917                    ->getTimestamp();
918                $processList->addEntity($process);
919            } elseif ($isEqual) {
920                $process = clone $processTemplate;
921                $process->amendment = "Zwei Ã–ffnungszeiten sind gleich.";
922                $process->getFirstAppointment()->date = $availability
923                    ->getStartDateTime()
924                    ->modify($currentDate->format("Y-m-d"))
925                    ->getTimestamp();
926                $processList->addEntity($process);
927            }
928        }
929        return $processList;
930    }
931
932    /**
933     * Update workstationCount to number of calculated appointments
934     *
935     * @return self cloned
936     */
937    public function withCalculatedSlots()
938    {
939        $availability = clone $this;
940        $startTime = Helper\DateTime::create($this['startTime']);
941        $stopTime = Helper\DateTime::create($this['endTime']);
942        $openingSeconds = $stopTime->getTimestamp() - $startTime->getTimestamp();
943        $openingMinutes = floor($openingSeconds / 60);
944        $slices = 0;
945        if ($this['slotTimeInMinutes'] > 0) {
946            $slices = floor($openingMinutes / $this['slotTimeInMinutes']);
947        }
948        $slot = new Slot([
949            'type' => Slot::FREE,
950            'intern' => $this['workstationCount']['intern'] * $slices,
951            'public' => $this['workstationCount']['public'] * $slices,
952        ]);
953        $availability['workstationCount'] = $slot;
954        return $availability;
955    }
956
957    public function withScope(\BO\Zmsentities\Scope $scope): static
958    {
959        $availability = clone $this;
960        $availability->scope = $scope;
961        return $availability;
962    }
963
964    public function __toString()
965    {
966        $info = "Availability." . $this['type'] . " #" . $this['id'];
967        $info .= " starting " . $this->startDate . $this->getStartDateTime()->format(' Y-m-d');
968        $info .= "||now+" . $this['bookable']['startInDays'] . " ";
969        $info .= " until " . $this->getEndDateTime()->format('Y-m-d');
970        $info .= "||now+" . $this['bookable']['endInDays'] . " ";
971        if ($this['repeat']['afterWeeks']) {
972            $info .= " every " . $this['repeat']['afterWeeks'] . " week(s)";
973        }
974        if ($this['repeat']['weekOfMonth']) {
975            $info .= " each " . $this['repeat']['weekOfMonth'] . ". weekOfMonth";
976        }
977        $info .= " on ";
978        $weekdays = array_filter($this['weekday'], function ($value) {
979            return $value > 0;
980        });
981        $info .= implode(',', array_keys($weekdays));
982        $info .= " from " . $this->getStartDateTime()->format('H:i');
983        $info .= " to " . $this->getEndDateTime()->format('H:i');
984        $info .= " using " . $this['slotTimeInMinutes'] . "min slots";
985        $info .= " with p{$this['workstationCount']['public']}/";
986        $info .= "i{$this['workstationCount']['intern']}";
987        $day = $this->getSlotList()->getSummerizedSlot();
988        $info .= " day $day";
989        return $info;
990    }
991
992    /**
993     * Delete cache on changes
994     *
995     */
996    #[\Override]
997    public function offsetSet(mixed $index, mixed $value): void
998    {
999        $this->startTimeCache = null;
1000        $this->endTimeCache = null;
1001        parent::offsetSet($index, $value);
1002    }
1003
1004    /**
1005     * Check if availability is newer than given time
1006     *
1007     * @return bool
1008     */
1009    public function isNewerThan(\DateTimeInterface $dateTime)
1010    {
1011        return ($dateTime->getTimestamp() < $this->lastChange);
1012    }
1013
1014    /**
1015     * Reduce data of dereferenced entities to a required minimum
1016     *
1017     * @return static
1018     */
1019    #[\Override]
1020    public function withLessData(array $keepArray = [])
1021    {
1022        $entity = clone $this;
1023        if (! in_array('repeat', $keepArray)) {
1024            unset($entity['repeat']);
1025        }
1026        if (! in_array('id', $keepArray)) {
1027            unset($entity['id']);
1028        }
1029        if (! in_array('bookable', $keepArray)) {
1030            unset($entity['bookable']);
1031        }
1032        if (! in_array('workstationCount', $keepArray)) {
1033            unset($entity['workstationCount']);
1034        }
1035        if (! in_array('multipleSlotsAllowed', $keepArray)) {
1036            unset($entity['multipleSlotsAllowed']);
1037        }
1038        if (! in_array('lastChange', $keepArray)) {
1039            unset($entity['lastChange']);
1040        }
1041        if (! in_array('slotTimeInMinutes', $keepArray)) {
1042            unset($entity['slotTimeInMinutes']);
1043        }
1044        if (! in_array('description', $keepArray)) {
1045            unset($entity['description']);
1046        }
1047
1048        return $entity;
1049    }
1050}