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