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