Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
77.35% covered (warning)
77.35%
239 / 309
50.00% covered (danger)
50.00%
11 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
Slot
77.35% covered (warning)
77.35%
239 / 309
50.00% covered (danger)
50.00%
11 / 22
181.09
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
70.21% covered (warning)
70.21%
33 / 47
0.00% covered (danger)
0.00%
0 / 1
17.47
 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%
11 / 11
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\Dldb\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            $calendar = new Calendar();
205            $calendar->deleteFreeRange(
206                $availability->scope->id,
207                $availability->id,
208                $startDate,
209                $stopDate
210            );
211
212            if (!$availability->withData(['bookable' => ['startInDays' => 0]])->hasBookableDates($now)) {
213                $availability['processingNote'][] = "cancelled $cancelledSlots slots: availability not bookable ";
214                return ($cancelledSlots > 0) ? true : false;
215            }
216            $availability['processingNote'][] = "cancelled $cancelledSlots slots";
217        }
218
219        $slotlist = $availability->getSlotList();
220        $slotlistIntern = $slotlist->withValueFor('callcenter', 0)->withValueFor('public', 0);
221        $time = $now->modify('00:00:00');
222        if (!$generateNew) {
223            $time = $lastGeneratedSlotDate->modify('+1 day')->modify('00:00:00');
224        }
225        $status = false;
226        do {
227            if ($availability->withData(['bookable' => ['startInDays' => 0]])->hasDate($time, $now)) {
228                $writeStatus = $this->writeSlotListForDate(
229                    $time,
230                    ($time->getTimestamp() < $startDate->getTimestamp()) ? $slotlistIntern : $slotlist,
231                    $availability
232                );
233                $status = $writeStatus ? $writeStatus : $status;
234            }
235            $time = $time->modify('+1day');
236        } while ($time->getTimestamp() <= $stopDate->getTimestamp() && $time->getTimestamp() < $calculateSlotsUntilDate->getTimestamp());
237
238        return $status || (isset($cancelledSlots) && $cancelledSlots > 0);
239    }
240
241    public function writeByScope(\BO\Zmsentities\Scope $scope, \DateTimeInterface $now)
242    {
243        $slotLastChange = $this->readLastChangedTimeByScope($scope);
244        $availabilityList = (new \BO\Zmsdb\Availability())
245            ->readAvailabilityListByScope($scope, 0, $slotLastChange->modify('-1 day'))
246            ;
247        $updatedList = new \BO\Zmsentities\Collection\AvailabilityList();
248        foreach ($availabilityList as $availability) {
249            $availability->scope = clone $scope; //dayoff is required
250            if ($this->writeByAvailability($availability, $now)) {
251                $updatedList->addEntity($availability);
252            }
253        }
254        return $updatedList;
255    }
256
257    protected function writeSlotListForDate(
258        \DateTimeInterface $time,
259        Collection $slotlist,
260        AvailabilityEntity $availability
261    ) {
262        $ancestors = [];
263        $hasAddedSlots = false;
264
265        $calendar       = new OverallCalendar();
266        $scopeId        = $availability->scope->id;
267        $availabilityId = $availability->id;
268        $maxSeat        = (int)($availability->workstationCount['intern'] ?? 1);
269        if ($maxSeat < 1) {
270            $maxSeat = 1;
271        }
272        $timeZone = new \DateTimeZone(\BO\Zmsdb\Connection\Select::$connectionTimezone);
273
274        foreach ($slotlist as $slot) {
275            $slot = clone $slot;
276            $slotID = $this->readByAvailability($slot, $availability, $time);
277            if ($slotID) {
278                $query = new Query\Slot(Query\Base::UPDATE);
279                $query->addConditionSlotId($slotID);
280            } else {
281                $query = new Query\Slot(Query\Base::INSERT);
282                $hasAddedSlots = true;
283            }
284            $slot->status = 'free';
285            $values = $query->reverseEntityMapping($slot, $availability, $time);
286            $values['createTimestamp'] = time();
287            $query->addValues($values);
288            $writeStatus = $this->writeItem($query);
289            if ($writeStatus && !$slotID) {
290                $slotID = $this->getWriter()->lastInsertId();
291            }
292            $ancestors[] = $slotID;
293            // TODO: Check if slot changed before writing ancestor IDs
294            $this->writeAncestorIDs($slotID, $ancestors);
295            $status = $writeStatus ? $writeStatus : $status;
296
297            if ($writeStatus) {
298                $slotStart = new \DateTimeImmutable(
299                    $time->format('Y-m-d') . ' ' . $slot->getTimeString(),
300                    $timeZone
301                );
302                $slotDurationMinutes = (int) $availability->getSlotTimeInMinutes();
303                $slotEnd = $slotStart->modify('+' . $slotDurationMinutes . ' minutes');
304
305                $bulkRows = [];
306                for ($seat = 1; $seat <= $maxSeat; $seat++) {
307                    $cursor = clone $slotStart;
308                    while ($cursor < $slotEnd) {
309                        $bulkRows[] = [$scopeId, $availabilityId, $cursor, $seat, 'free'];
310                        $cursor = $cursor->modify('+5 minutes');
311                    }
312                }
313                $calendar->insertSlotsBulk($bulkRows);
314            }
315        }
316        if ($hasAddedSlots) {
317            $availability['processingNote'][] = 'Added ' . $time->format('Y-m-d');
318        }
319        return $status;
320    }
321
322    protected function writeAncestorIDs($slotID, array $ancestors)
323    {
324        $this->perform(Query\Slot::QUERY_DELETE_ANCESTOR, [
325            'slotID' => $slotID,
326        ]);
327        $ancestorLevel = count($ancestors);
328        foreach ($ancestors as $ancestorID) {
329            if ($ancestorLevel <= self::MAX_SLOTS) {
330                $this->perform(Query\Slot::QUERY_INSERT_ANCESTOR, [
331                    'slotID' => $slotID,
332                    'ancestorID' => $ancestorID,
333                    'ancestorLevel' => $ancestorLevel,
334                ]);
335            }
336            $ancestorLevel--;
337        }
338    }
339
340    public function readLastChangedTime()
341    {
342        $last = $this->fetchRow(
343            Query\Slot::QUERY_LAST_CHANGED
344        );
345        if (!$last['dateString']) {
346            $last['dateString'] = '1970-01-01 12:00';
347        }
348        return new \DateTimeImmutable($last['dateString'] . \BO\Zmsdb\Connection\Select::$connectionTimezone);
349    }
350
351    public function readLastChangedTimeByScope(ScopeEntity $scope)
352    {
353        $last = $this->fetchRow(
354            Query\Slot::QUERY_LAST_CHANGED_SCOPE,
355            [
356                'scopeID' => $scope->id,
357            ]
358        );
359        if (!$last['dateString']) {
360            $last['dateString'] = '1970-01-01 12:00';
361        }
362        return new \DateTimeImmutable($last['dateString'] . \BO\Zmsdb\Connection\Select::$connectionTimezone);
363    }
364
365    public function readLastChangedTimeByAvailability(AvailabilityEntity $availabiliy)
366    {
367        $last = $this->fetchRow(
368            Query\Slot::QUERY_LAST_CHANGED_AVAILABILITY,
369            [
370                'availabilityID' => $availabiliy->id,
371            ]
372        );
373        if (!$last['dateString']) {
374            $last['dateString'] = '1970-01-01 12:00';
375        }
376        return new \DateTimeImmutable($last['dateString'] . \BO\Zmsdb\Connection\Select::$connectionTimezone);
377    }
378
379    public function updateSlotProcessMapping($scopeID = null)
380    {
381        if ($scopeID) {
382            $processIdList = $this->fetchAll(
383                Query\Slot::QUERY_SELECT_MISSING_PROCESS
384                . Query\Slot::QUERY_SELECT_MISSING_PROCESS_BY_SCOPE,
385                ['scopeID' => $scopeID]
386            );
387        } else {
388            $processIdList = $this->fetchAll(Query\Slot::QUERY_SELECT_MISSING_PROCESS, []);
389        }
390        // Client side INSERT ... SELECT ... to reduce table locking
391        foreach ($processIdList as $processId) {
392            $this->perform(Query\Slot::QUERY_INSERT_SLOT_PROCESS, array_values($processId));
393        }
394        return count($processIdList);
395    }
396
397    public function deleteSlotProcessOnSlot($scopeID = null)
398    {
399        if ($scopeID) {
400            $this->perform(
401                Query\Slot::QUERY_DELETE_SLOT_PROCESS_CANCELLED
402                . Query\Slot::QUERY_DELETE_SLOT_PROCESS_CANCELLED_BY_SCOPE,
403                ['scopeID' => $scopeID]
404            );
405        } else {
406            $this->perform(Query\Slot::QUERY_DELETE_SLOT_PROCESS_CANCELLED, []);
407        }
408    }
409
410    public function deleteSlotProcessOnProcess($scopeID = null)
411    {
412        if ($scopeID) {
413            $processIdList = $this->fetchAll(
414                Query\Slot::QUERY_SELECT_DELETABLE_SLOT_PROCESS
415                . Query\Slot::QUERY_SELECT_DELETABLE_SLOT_PROCESS_BY_SCOPE,
416                ['scopeID' => $scopeID]
417            );
418        } else {
419            $processIdList = $this->fetchAll(Query\Slot::QUERY_SELECT_DELETABLE_SLOT_PROCESS);
420        }
421        // Client side INSERT ... SELECT ... to reduce table locking
422        foreach ($processIdList as $processId) {
423            $this->perform(Query\Slot::QUERY_DELETE_SLOT_PROCESS_ID, $processId);
424        }
425        return count($processIdList);
426    }
427
428    public function writeSlotProcessMappingFor($processId)
429    {
430        $this->perform(Query\Slot::QUERY_INSERT_SLOT_PROCESS_ID, [
431            'processId' => $processId,
432        ]);
433        return $this;
434    }
435
436    public function deleteSlotProcessMappingFor($processId)
437    {
438        $this->perform(Query\Slot::QUERY_DELETE_SLOT_PROCESS_ID, [
439            'processId' => $processId,
440        ]);
441        return $this;
442    }
443
444    public function writeCanceledByTimeAndScope(\DateTimeInterface $dateTime, \BO\Zmsentities\Scope $scope)
445    {
446        $status = $this->perform(Query\Slot::QUERY_UPDATE_SLOT_MISSING_AVAILABILITY_BY_SCOPE, [
447            'dateString' => $dateTime->format('Y-m-d'),
448            'scopeID' => $scope->id,
449        ]);
450        return $this->perform(Query\Slot::QUERY_CANCEL_SLOT_OLD_BY_SCOPE, [
451            'year' => $dateTime->format('Y'),
452            'month' => $dateTime->format('m'),
453            'day' => $dateTime->format('d'),
454            'time' => $dateTime->format('H:i:s'),
455            'scopeID' => $scope->id,
456        ]) && $status;
457    }
458
459    public function writeCanceledByTime(\DateTimeInterface $dateTime)
460    {
461        $status = $this->perform(Query\Slot::QUERY_UPDATE_SLOT_MISSING_AVAILABILITY, [
462            'dateString' => $dateTime->format('Y-m-d'),
463        ]);
464        return $this->perform(Query\Slot::QUERY_CANCEL_SLOT_OLD, [
465            'year' => $dateTime->format('Y'),
466            'month' => $dateTime->format('m'),
467            'day' => $dateTime->format('d'),
468            'time' => $dateTime->format('H:i:s'),
469        ]) && $status;
470    }
471
472    public function deleteSlotsOlderThan(\DateTimeInterface $dateTime)
473    {
474        $status = $this->perform(Query\Slot::QUERY_DELETE_SLOT_OLD, [
475            'year' => $dateTime->format('Y'),
476            'month' => $dateTime->format('m'),
477            'day' => $dateTime->format('d'),
478        ]);
479        $status = ($status && $this->perform(Query\Slot::QUERY_DELETE_SLOT_HIERA));
480        return $status;
481    }
482
483    /**
484     * This function is for debugging
485     */
486    public function readRowsByScopeAndDate(
487        \BO\Zmsentities\Scope $scope,
488        \DateTimeInterface $dateTime
489    ) {
490        $list = $this->fetchAll(Query\Slot::QUERY_SELECT_BY_SCOPE_AND_DAY, [
491            'year' => $dateTime->format('Y'),
492            'month' => $dateTime->format('m'),
493            'day' => $dateTime->format('d'),
494            'scopeID' => $scope->id,
495        ]);
496        return $list;
497    }
498
499    public function writeOptimizedSlotTables()
500    {
501        $queries = [
502            Query\Slot::QUERY_OPTIMIZE_SLOT,
503            Query\Slot::QUERY_OPTIMIZE_SLOT_HIERA,
504            Query\Slot::QUERY_OPTIMIZE_SLOT_PROCESS,
505            Query\Slot::QUERY_OPTIMIZE_PROCESS,
506        ];
507
508        $status = true;
509        foreach ($queries as $query) {
510            try {
511                $status = $status && $this->perform($query);
512            } catch (\PDOException $e) {
513                \App::$log->error("Failed to optimize table with query: $query. Error: " . $e->getMessage(), []);
514
515                return false;
516            }
517        }
518
519        return $status;
520    }
521
522    private function getLastGeneratedSlotDate(AvailabilityEntity $availability)
523    {
524        $date = '1970-01-01 12:00';
525        $last = $this->fetchRow(
526            Query\Slot::QUERY_LAST_IN_AVAILABILITY,
527            [
528                'availabilityID' => $availability->id,
529            ]
530        );
531
532        if (isset($last['dateString'])) {
533            $date = $last['dateString'];
534        }
535
536        return new \DateTimeImmutable($date . \BO\Zmsdb\Connection\Select::$connectionTimezone);
537    }
538}