Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
77.99% covered (warning)
77.99%
241 / 309
50.00% covered (danger)
50.00%
11 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
Slot
77.99% covered (warning)
77.99%
241 / 309
50.00% covered (danger)
50.00%
11 / 22
173.42
0.00% covered (danger)
0.00%
0 / 1
 readByAppointment
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 readByAvailability
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
 hasScopeRelevantChanges
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
5.06
 isAvailabilityOutdated
100.00% covered (success)
100.00%
42 / 42
100.00% covered (success)
100.00%
1 / 1
17
 writeByAvailability
73.33% covered (warning)
73.33%
33 / 45
0.00% covered (danger)
0.00%
0 / 1
16.20
 writeByScope
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 writeSlotListForDate
93.18% covered (success)
93.18%
41 / 44
0.00% covered (danger)
0.00%
0 / 1
11.04
 writeAncestorIDs
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 readLastChangedTime
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 readLastChangedTimeByScope
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 readLastChangedTimeByAvailability
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
2.01
 updateSlotProcessMapping
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 deleteSlotProcessOnSlot
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 deleteSlotProcessOnProcess
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 writeSlotProcessMappingFor
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 deleteSlotProcessMappingFor
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 writeCanceledByTimeAndScope
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 writeCanceledByTime
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 deleteSlotsOlderThan
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 readRowsByScopeAndDate
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 writeOptimizedSlotTables
76.92% covered (warning)
76.92%
10 / 13
0.00% covered (danger)
0.00%
0 / 1
4.20
 getLastGeneratedSlotDate
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace BO\Zmsdb;
4
5use BO\Zmsdldb\Helper\DateTime;
6use BO\Zmsentities\Slot as Entity;
7use BO\Zmsentities\Collection\SlotList as Collection;
8use BO\Zmsentities\Availability as AvailabilityEntity;
9use BO\Zmsentities\Scope as ScopeEntity;
10use BO\Zmsdb\OverallCalendar as Calendar;
11
12/**
13 * @SuppressWarnings(Public)
14 * @SuppressWarnings(Complexity)
15 * @SuppressWarnings(Coupling)
16 */
17class Slot extends Base
18{
19    /**
20     * maximum number of slots per appointment
21     */
22    const MAX_SLOTS = 25;
23
24    const MAX_DAYS_OF_SLOT_CALCULATION = 180;
25
26    /**
27     * @return \BO\Zmsentities\Collection\SlotList
28     *
29     */
30    public function readByAppointment(
31        \BO\Zmsentities\Appointment $appointment,
32        $overwriteSlotsCount = null,
33        $extendSlotList = false,
34        $lockSlots = false
35    ) {
36        $appointment = clone $appointment;
37        $availability = (new Availability())->readByAppointment($appointment);
38        // Check if availability allows multiple slots, but allow overwrite
39        if (!$availability->multipleSlotsAllowed || $overwriteSlotsCount >= 1) {
40            $appointment->slotCount = ($overwriteSlotsCount >= 1) ? $overwriteSlotsCount : 1;
41        }
42        $slotList = $availability->getSlotList()->withSlotsForAppointment($appointment, $extendSlotList);
43        foreach ($slotList as $slot) {
44            $this->readByAvailability($slot, $availability, $appointment->toDateTime(), $lockSlots);
45        }
46        return $slotList;
47    }
48
49    public function readByAvailability(
50        \BO\Zmsentities\Slot $slot,
51        AvailabilityEntity $availability,
52        \DateTimeInterface $date,
53        $getLock = false
54    ) {
55        $data = array();
56        $data['scopeID'] = $availability->scope->id;
57        $data['availabilityID'] = $availability->id;
58        $data['year'] = $date->format('Y');
59        $data['month'] = $date->format('m');
60        $data['day'] = $date->format('d');
61        $data['time'] = $slot->getTimeString();
62        $sql = Query\Slot::QUERY_SELECT_SLOT;
63        if ($getLock) {
64            $sql .= ' FOR UPDATE';
65        }
66        $slotID = $this->fetchRow(
67            $sql,
68            $data
69        );
70        return $slotID ? $slotID['slotID'] : false ;
71    }
72
73    public function hasScopeRelevantChanges(
74        \BO\Zmsentities\Scope $scope,
75        \DateTimeInterface $slotLastChange = null
76    ) {
77        $startInDaysDefault = (new Preferences())
78            ->readProperty('scope', $scope->id, 'appointment', 'startInDaysDefault');
79        $endInDaysDefault = (new Preferences())
80            ->readProperty('scope', $scope->id, 'appointment', 'endInDaysDefault');
81        if (
82            $scope->preferences['appointment']['startInDaysDefault'] != $startInDaysDefault
83            || $scope->preferences['appointment']['endInDaysDefault'] != $endInDaysDefault
84        ) {
85            (new Scope())->replacePreferences($scope); //TODO remove after ZMS1 is deactivated
86            return true;
87        }
88        $startInDaysChange = (new Preferences())
89            ->readChangeDateTime('scope', $scope->id, 'appointment', 'startInDaysDefault');
90        $endInDaysChange = (new Preferences())
91            ->readChangeDateTime('scope', $scope->id, 'appointment', 'endInDaysDefault');
92        if (
93            $startInDaysChange->getTimestamp() > $slotLastChange->getTimestamp()
94            || $endInDaysChange->getTimestamp() > $slotLastChange->getTimestamp()
95        ) {
96            return true;
97        }
98    }
99
100    public function isAvailabilityOutdated(
101        \BO\Zmsentities\Availability $availability,
102        \DateTimeInterface $now,
103        \DateTimeInterface $slotLastChange = null
104    ) {
105        $proposedChange = new Helper\AvailabilitySnapShot($availability, $now);
106        $formerChange = new Helper\AvailabilitySnapShot($availability, $slotLastChange);
107
108        if ($formerChange->hasOutdatedAvailability()) {
109            $availability['processingNote'][] = 'outdated: availability change';
110            return true;
111        }
112        if (
113            $formerChange->hasOutdatedScope()
114            && $this->hasScopeRelevantChanges($availability->scope, $slotLastChange)
115        ) {
116            $availability['processingNote'][] = 'outdated: scope change';
117            return true;
118        }
119        if ($formerChange->hasOutdatedDayoff()) {
120            $availability['processingNote'][] = 'outdated: dayoff change';
121            return true;
122        }
123        // Be aware, that last slot change and current time might differ serval days
124        //  if the rebuild fails in some way
125        if (
126            1
127            // First check if the bookable end date on current time was already calculated on last slot change
128            && !$formerChange->hasBookableDateTime($proposedChange->getLastBookableDateTime())
129            // Second check if between last slot change and current time could be a bookable slot
130            && (
131                (
132                    !$formerChange->isOpenedOnLastBookableDay()
133                    && $proposedChange->hasBookableDateTimeAfter($formerChange->getLastBookableDateTime())
134                )
135                // if calculation already happened the day before, check if lastChange time was before opening
136                || (
137                    $formerChange->isOpenedOnLastBookableDay()
138                    && (
139                        !$formerChange->isTimeOpenedOnLastBookableDay()
140                        || $proposedChange->hasBookableDateTimeAfter(
141                            $formerChange->getLastBookableDateTime()->modify('+1day 00:00:00')
142                        )
143                    )
144                )
145            )
146            // Check if daytime is after booking start time if bookable end of now is calculated
147            && (
148                !$proposedChange->isOpenedOnLastBookableDay()
149                || $proposedChange->isTimeOpenedOnLastBookableDay()
150            )
151        ) {
152            $availability['processingNote'][] = 'outdated: new slots required';
153            return true;
154        }
155        if (
156            $availability->getBookableStart($slotLastChange) != $availability->getBookableStart($now)
157            // First check, if bookable start from lastChange was not included in bookable time from now
158            && !$availability->hasDate($availability->getBookableStart($slotLastChange), $now)
159            // Second check, if availability had a bookable time on lastChange before bookable start from now
160            && $availability->hasDateBetween(
161                $availability->getBookableStart($slotLastChange),
162                $availability->getBookableStart($now),
163                $slotLastChange
164            )
165        ) {
166            $availability['processingNote'][] = 'outdated: slots invalidated by bookable start';
167            return true;
168        }
169        $availability['processingNote'][] = 'not outdated';
170        return false;
171    }
172
173    /**
174     * @return bool TRUE if there were changes on slots
175     */
176    public function writeByAvailability(
177        \BO\Zmsentities\Availability $availability,
178        \DateTimeInterface $now,
179        \DateTimeInterface $slotLastChange = null
180    ) {
181        $now = \BO\Zmsentities\Helper\DateTime::create($now);
182        $calculateSlotsUntilDate = \BO\Zmsentities\Helper\DateTime::create($now)->modify('+' . self::MAX_DAYS_OF_SLOT_CALCULATION . ' days');
183        if (!$slotLastChange) {
184            $slotLastChange = $this->readLastChangedTimeByAvailability($availability);
185        }
186        $lastGeneratedSlotDate = $this->getLastGeneratedSlotDate($availability);
187
188        $availability['processingNote'][] = 'lastchange=' . $slotLastChange->format('c');
189        if (!$this->isAvailabilityOutdated($availability, $now, $slotLastChange)) {
190            return false;
191        }
192        $startDate = $availability->getBookableStart($now)->modify('00:00:00');
193        $stopDate = $availability->getBookableEnd($now);
194        $generateNew = $availability->isNewerThan($slotLastChange);
195        (new Availability())->readLock($availability->id);
196        $cancelledSlots = $this->fetchAffected(Query\Slot::QUERY_CANCEL_AVAILABILITY_BEFORE_BOOKABLE, [
197            'availabilityID' => $availability->id,
198            'providedDate' => $startDate->format('Y-m-d')
199        ]);
200        if ($generateNew) {
201            $cancelledSlots = $this->fetchAffected(Query\Slot::QUERY_CANCEL_AVAILABILITY, [
202                'availabilityID' => $availability->id,
203            ]);
204
205            $calendar = new Calendar();
206            $calendar->cancelAvailability(
207                $availability->scope->id,
208                $availability->id
209            );
210
211            if (!$availability->withData(['bookable' => ['startInDays' => 0]])->hasBookableDates($now)) {
212                $availability['processingNote'][] = "cancelled $cancelledSlots slots: availability not bookable ";
213                return ($cancelledSlots > 0) ? true : false;
214            }
215            $availability['processingNote'][] = "cancelled $cancelledSlots slots";
216        }
217
218        $slotlist = $availability->getSlotList();
219        $slotlistIntern = $slotlist->withValueFor('callcenter', 0)->withValueFor('public', 0);
220        $time = $now->modify('00:00:00');
221        if (!$generateNew) {
222            $time = $lastGeneratedSlotDate->modify('+1 day')->modify('00:00:00');
223        }
224        $status = false;
225        do {
226            if ($availability->withData(['bookable' => ['startInDays' => 0]])->hasDate($time, $now)) {
227                $writeStatus = $this->writeSlotListForDate(
228                    $time,
229                    ($time->getTimestamp() < $startDate->getTimestamp()) ? $slotlistIntern : $slotlist,
230                    $availability
231                );
232                $status = $writeStatus ? $writeStatus : $status;
233            }
234            $time = $time->modify('+1day');
235        } while ($time->getTimestamp() <= $stopDate->getTimestamp() && $time->getTimestamp() < $calculateSlotsUntilDate->getTimestamp());
236
237        return $status || (isset($cancelledSlots) && $cancelledSlots > 0);
238    }
239
240    public function writeByScope(\BO\Zmsentities\Scope $scope, \DateTimeInterface $now)
241    {
242        $slotLastChange = $this->readLastChangedTimeByScope($scope);
243        $availabilityList = (new \BO\Zmsdb\Availability())
244            ->readAvailabilityListByScope($scope, 0, $slotLastChange->modify('-1 day'))
245            ;
246        $updatedList = new \BO\Zmsentities\Collection\AvailabilityList();
247        foreach ($availabilityList as $availability) {
248            $availability->scope = clone $scope; //dayoff is required
249            if ($this->writeByAvailability($availability, $now)) {
250                $updatedList->addEntity($availability);
251            }
252        }
253        return $updatedList;
254    }
255
256    protected function writeSlotListForDate(
257        \DateTimeInterface $time,
258        Collection $slotlist,
259        AvailabilityEntity $availability
260    ) {
261        $ancestors = [];
262        $hasAddedSlots = false;
263
264        $calendar       = new OverallCalendar();
265        $scopeId        = $availability->scope->id;
266        $availabilityId = $availability->id;
267        $maxSeat        = (int)($availability->workstationCount['intern'] ?? 1);
268        if ($maxSeat < 1) {
269            $maxSeat = 1;
270        }
271        $timeZone = new \DateTimeZone(\BO\Zmsdb\Connection\Select::$connectionTimezone);
272
273        foreach ($slotlist as $slot) {
274            $slot = clone $slot;
275            $slotID = $this->readByAvailability($slot, $availability, $time);
276            if ($slotID) {
277                $query = new Query\Slot(Query\Base::UPDATE);
278                $query->addConditionSlotId($slotID);
279            } else {
280                $query = new Query\Slot(Query\Base::INSERT);
281                $hasAddedSlots = true;
282            }
283            $slot->status = 'free';
284            $values = $query->reverseEntityMapping($slot, $availability, $time);
285            $values['createTimestamp'] = time();
286            $query->addValues($values);
287            $writeStatus = $this->writeItem($query);
288            if ($writeStatus && !$slotID) {
289                $slotID = $this->getWriter()->lastInsertId();
290            }
291            $ancestors[] = $slotID;
292            // TODO: Check if slot changed before writing ancestor IDs
293            $this->writeAncestorIDs($slotID, $ancestors);
294            $status = $writeStatus ? $writeStatus : $status;
295
296            if ($writeStatus) {
297                $slotStart = new \DateTimeImmutable(
298                    $time->format('Y-m-d') . ' ' . $slot->getTimeString(),
299                    $timeZone
300                );
301                $slotDurationMinutes = (int) $availability->getSlotTimeInMinutes();
302                $slotEnd = $slotStart->modify('+' . $slotDurationMinutes . ' minutes');
303
304                $bulkRows = [];
305                for ($seat = 1; $seat <= $maxSeat; $seat++) {
306                    $cursor = clone $slotStart;
307                    while ($cursor < $slotEnd) {
308                        $bulkRows[] = [$scopeId, $availabilityId, $cursor, $seat, 'free'];
309                        $cursor = $cursor->modify('+5 minutes');
310                    }
311                }
312                $calendar->insertSlotsBulk($bulkRows);
313            }
314        }
315        if ($hasAddedSlots) {
316            $availability['processingNote'][] = 'Added ' . $time->format('Y-m-d');
317        }
318        return $status;
319    }
320
321    protected function writeAncestorIDs($slotID, array $ancestors)
322    {
323        $this->perform(Query\Slot::QUERY_DELETE_ANCESTOR, [
324            'slotID' => $slotID,
325        ]);
326        $ancestorLevel = count($ancestors);
327        foreach ($ancestors as $ancestorID) {
328            if ($ancestorLevel <= self::MAX_SLOTS) {
329                $this->perform(Query\Slot::QUERY_INSERT_ANCESTOR, [
330                    'slotID' => $slotID,
331                    'ancestorID' => $ancestorID,
332                    'ancestorLevel' => $ancestorLevel,
333                ]);
334            }
335            $ancestorLevel--;
336        }
337    }
338
339    public function readLastChangedTime()
340    {
341        $last = $this->fetchRow(
342            Query\Slot::QUERY_LAST_CHANGED
343        );
344        if (!$last['dateString']) {
345            $last['dateString'] = '1970-01-01 12:00';
346        }
347        return new \DateTimeImmutable($last['dateString'] . \BO\Zmsdb\Connection\Select::$connectionTimezone);
348    }
349
350    public function readLastChangedTimeByScope(ScopeEntity $scope)
351    {
352        $last = $this->fetchRow(
353            Query\Slot::QUERY_LAST_CHANGED_SCOPE,
354            [
355                'scopeID' => $scope->id,
356            ]
357        );
358        if (!$last['dateString']) {
359            $last['dateString'] = '1970-01-01 12:00';
360        }
361        return new \DateTimeImmutable($last['dateString'] . \BO\Zmsdb\Connection\Select::$connectionTimezone);
362    }
363
364    public function readLastChangedTimeByAvailability(AvailabilityEntity $availabiliy)
365    {
366        $last = $this->fetchRow(
367            Query\Slot::QUERY_LAST_CHANGED_AVAILABILITY,
368            [
369                'availabilityID' => $availabiliy->id,
370            ]
371        );
372        if (!$last['dateString']) {
373            $last['dateString'] = '1970-01-01 12:00';
374        }
375        return new \DateTimeImmutable($last['dateString'] . \BO\Zmsdb\Connection\Select::$connectionTimezone);
376    }
377
378    public function updateSlotProcessMapping($scopeID = null)
379    {
380        if ($scopeID) {
381            $processIdList = $this->fetchAll(
382                Query\Slot::QUERY_SELECT_MISSING_PROCESS
383                . Query\Slot::QUERY_SELECT_MISSING_PROCESS_BY_SCOPE,
384                ['scopeID' => $scopeID]
385            );
386        } else {
387            $processIdList = $this->fetchAll(Query\Slot::QUERY_SELECT_MISSING_PROCESS, []);
388        }
389        // Client side INSERT ... SELECT ... to reduce table locking
390        foreach ($processIdList as $processId) {
391            $this->perform(Query\Slot::QUERY_INSERT_SLOT_PROCESS, array_values($processId));
392        }
393        return count($processIdList);
394    }
395
396    public function deleteSlotProcessOnSlot($scopeID = null)
397    {
398        if ($scopeID) {
399            $this->perform(
400                Query\Slot::QUERY_DELETE_SLOT_PROCESS_CANCELLED
401                . Query\Slot::QUERY_DELETE_SLOT_PROCESS_CANCELLED_BY_SCOPE,
402                ['scopeID' => $scopeID]
403            );
404        } else {
405            $this->perform(Query\Slot::QUERY_DELETE_SLOT_PROCESS_CANCELLED, []);
406        }
407    }
408
409    public function deleteSlotProcessOnProcess($scopeID = null)
410    {
411        if ($scopeID) {
412            $processIdList = $this->fetchAll(
413                Query\Slot::QUERY_SELECT_DELETABLE_SLOT_PROCESS
414                . Query\Slot::QUERY_SELECT_DELETABLE_SLOT_PROCESS_BY_SCOPE,
415                ['scopeID' => $scopeID]
416            );
417        } else {
418            $processIdList = $this->fetchAll(Query\Slot::QUERY_SELECT_DELETABLE_SLOT_PROCESS);
419        }
420        // Client side INSERT ... SELECT ... to reduce table locking
421        foreach ($processIdList as $processId) {
422            $this->perform(Query\Slot::QUERY_DELETE_SLOT_PROCESS_ID, $processId);
423        }
424        return count($processIdList);
425    }
426
427    public function writeSlotProcessMappingFor($processId)
428    {
429        $this->perform(Query\Slot::QUERY_INSERT_SLOT_PROCESS_ID, [
430            'processId' => $processId,
431        ]);
432        return $this;
433    }
434
435    public function deleteSlotProcessMappingFor($processId)
436    {
437        $this->perform(Query\Slot::QUERY_DELETE_SLOT_PROCESS_ID, [
438            'processId' => $processId,
439        ]);
440        return $this;
441    }
442
443    public function writeCanceledByTimeAndScope(\DateTimeInterface $dateTime, \BO\Zmsentities\Scope $scope)
444    {
445        $status = $this->perform(Query\Slot::QUERY_UPDATE_SLOT_MISSING_AVAILABILITY_BY_SCOPE, [
446            'dateString' => $dateTime->format('Y-m-d'),
447            'scopeID' => $scope->id,
448        ]);
449
450        $calendar = new \BO\Zmsdb\OverallCalendar();
451            $calendar->purgeMissingAvailabilityByScope($dateTime, $scope->id);
452
453        return $this->perform(Query\Slot::QUERY_CANCEL_SLOT_OLD_BY_SCOPE, [
454            'year' => $dateTime->format('Y'),
455            'month' => $dateTime->format('m'),
456            'day' => $dateTime->format('d'),
457            'time' => $dateTime->format('H:i:s'),
458            'scopeID' => $scope->id,
459        ]) && $status;
460    }
461
462    public function writeCanceledByTime(\DateTimeInterface $dateTime)
463    {
464        $status = $this->perform(Query\Slot::QUERY_UPDATE_SLOT_MISSING_AVAILABILITY, [
465            'dateString' => $dateTime->format('Y-m-d'),
466        ]);
467        return $this->perform(Query\Slot::QUERY_CANCEL_SLOT_OLD, [
468            'year' => $dateTime->format('Y'),
469            'month' => $dateTime->format('m'),
470            'day' => $dateTime->format('d'),
471            'time' => $dateTime->format('H:i:s'),
472        ]) && $status;
473    }
474
475    public function deleteSlotsOlderThan(\DateTimeInterface $dateTime)
476    {
477        $status = $this->perform(Query\Slot::QUERY_DELETE_SLOT_OLD, [
478            'year' => $dateTime->format('Y'),
479            'month' => $dateTime->format('m'),
480            'day' => $dateTime->format('d'),
481        ]);
482        $status = ($status && $this->perform(Query\Slot::QUERY_DELETE_SLOT_HIERA));
483        return $status;
484    }
485
486    /**
487     * This function is for debugging
488     */
489    public function readRowsByScopeAndDate(
490        \BO\Zmsentities\Scope $scope,
491        \DateTimeInterface $dateTime
492    ) {
493        $list = $this->fetchAll(Query\Slot::QUERY_SELECT_BY_SCOPE_AND_DAY, [
494            'year' => $dateTime->format('Y'),
495            'month' => $dateTime->format('m'),
496            'day' => $dateTime->format('d'),
497            'scopeID' => $scope->id,
498        ]);
499        return $list;
500    }
501
502    public function writeOptimizedSlotTables()
503    {
504        $queries = [
505            Query\Slot::QUERY_OPTIMIZE_SLOT,
506            Query\Slot::QUERY_OPTIMIZE_SLOT_HIERA,
507            Query\Slot::QUERY_OPTIMIZE_SLOT_PROCESS,
508            Query\Slot::QUERY_OPTIMIZE_PROCESS,
509        ];
510
511        $status = true;
512        foreach ($queries as $query) {
513            try {
514                $status = $status && $this->perform($query);
515            } catch (\PDOException $e) {
516                \App::$log->error("Failed to optimize table with query: $query. Error: " . $e->getMessage(), []);
517
518                return false;
519            }
520        }
521
522        return $status;
523    }
524
525    private function getLastGeneratedSlotDate(AvailabilityEntity $availability)
526    {
527        $date = '1970-01-01 12:00';
528        $last = $this->fetchRow(
529            Query\Slot::QUERY_LAST_IN_AVAILABILITY,
530            [
531                'availabilityID' => $availability->id,
532            ]
533        );
534
535        if (isset($last['dateString'])) {
536            $date = $last['dateString'];
537        }
538
539        return new \DateTimeImmutable($date . \BO\Zmsdb\Connection\Select::$connectionTimezone);
540    }
541}