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