Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
77.74% covered (warning)
77.74%
234 / 301
54.55% covered (warning)
54.55%
12 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
Slot
77.74% covered (warning)
77.74%
234 / 301
54.55% covered (warning)
54.55%
12 / 22
176.36
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.02% covered (success)
93.02%
40 / 43
0.00% covered (danger)
0.00%
0 / 1
10.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
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 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                $slotDateTime = new \DateTimeImmutable(
299                    $time->format('Y-m-d') . ' ' . $slot->getTimeString(),
300                    $timeZone
301                );
302                for ($seat = 1; $seat <= $maxSeat; $seat++) {
303                    $calendar->insertSlot(
304                        $scopeId,
305                        $availabilityId,
306                        $slotDateTime,
307                        $seat,
308                        'free'
309                    );
310                }
311            }
312        }
313        if ($hasAddedSlots) {
314            $availability['processingNote'][] = 'Added ' . $time->format('Y-m-d');
315        }
316        return $status;
317    }
318
319    protected function writeAncestorIDs($slotID, array $ancestors)
320    {
321        $this->perform(Query\Slot::QUERY_DELETE_ANCESTOR, [
322            'slotID' => $slotID,
323        ]);
324        $ancestorLevel = count($ancestors);
325        foreach ($ancestors as $ancestorID) {
326            if ($ancestorLevel <= self::MAX_SLOTS) {
327                $this->perform(Query\Slot::QUERY_INSERT_ANCESTOR, [
328                    'slotID' => $slotID,
329                    'ancestorID' => $ancestorID,
330                    'ancestorLevel' => $ancestorLevel,
331                ]);
332            }
333            $ancestorLevel--;
334        }
335    }
336
337    public function readLastChangedTime()
338    {
339        $last = $this->fetchRow(
340            Query\Slot::QUERY_LAST_CHANGED
341        );
342        if (!$last['dateString']) {
343            $last['dateString'] = '1970-01-01 12:00';
344        }
345        return new \DateTimeImmutable($last['dateString'] . \BO\Zmsdb\Connection\Select::$connectionTimezone);
346    }
347
348    public function readLastChangedTimeByScope(ScopeEntity $scope)
349    {
350        $last = $this->fetchRow(
351            Query\Slot::QUERY_LAST_CHANGED_SCOPE,
352            [
353                'scopeID' => $scope->id,
354            ]
355        );
356        if (!$last['dateString']) {
357            $last['dateString'] = '1970-01-01 12:00';
358        }
359        return new \DateTimeImmutable($last['dateString'] . \BO\Zmsdb\Connection\Select::$connectionTimezone);
360    }
361
362    public function readLastChangedTimeByAvailability(AvailabilityEntity $availabiliy)
363    {
364        $last = $this->fetchRow(
365            Query\Slot::QUERY_LAST_CHANGED_AVAILABILITY,
366            [
367                'availabilityID' => $availabiliy->id,
368            ]
369        );
370        if (!$last['dateString']) {
371            $last['dateString'] = '1970-01-01 12:00';
372        }
373        return new \DateTimeImmutable($last['dateString'] . \BO\Zmsdb\Connection\Select::$connectionTimezone);
374    }
375
376    public function updateSlotProcessMapping($scopeID = null)
377    {
378        if ($scopeID) {
379            $processIdList = $this->fetchAll(
380                Query\Slot::QUERY_SELECT_MISSING_PROCESS
381                . Query\Slot::QUERY_SELECT_MISSING_PROCESS_BY_SCOPE,
382                ['scopeID' => $scopeID]
383            );
384        } else {
385            $processIdList = $this->fetchAll(Query\Slot::QUERY_SELECT_MISSING_PROCESS, []);
386        }
387        // Client side INSERT ... SELECT ... to reduce table locking
388        foreach ($processIdList as $processId) {
389            $this->perform(Query\Slot::QUERY_INSERT_SLOT_PROCESS, array_values($processId));
390        }
391        return count($processIdList);
392    }
393
394    public function deleteSlotProcessOnSlot($scopeID = null)
395    {
396        if ($scopeID) {
397            $this->perform(
398                Query\Slot::QUERY_DELETE_SLOT_PROCESS_CANCELLED
399                . Query\Slot::QUERY_DELETE_SLOT_PROCESS_CANCELLED_BY_SCOPE,
400                ['scopeID' => $scopeID]
401            );
402        } else {
403            $this->perform(Query\Slot::QUERY_DELETE_SLOT_PROCESS_CANCELLED, []);
404        }
405    }
406
407    public function deleteSlotProcessOnProcess($scopeID = null)
408    {
409        if ($scopeID) {
410            $processIdList = $this->fetchAll(
411                Query\Slot::QUERY_SELECT_DELETABLE_SLOT_PROCESS
412                . Query\Slot::QUERY_SELECT_DELETABLE_SLOT_PROCESS_BY_SCOPE,
413                ['scopeID' => $scopeID]
414            );
415        } else {
416            $processIdList = $this->fetchAll(Query\Slot::QUERY_SELECT_DELETABLE_SLOT_PROCESS);
417        }
418        // Client side INSERT ... SELECT ... to reduce table locking
419        foreach ($processIdList as $processId) {
420            $this->perform(Query\Slot::QUERY_DELETE_SLOT_PROCESS_ID, $processId);
421        }
422        return count($processIdList);
423    }
424
425    public function writeSlotProcessMappingFor($processId)
426    {
427        $this->perform(Query\Slot::QUERY_INSERT_SLOT_PROCESS_ID, [
428            'processId' => $processId,
429        ]);
430        return $this;
431    }
432
433    public function deleteSlotProcessMappingFor($processId)
434    {
435        $this->perform(Query\Slot::QUERY_DELETE_SLOT_PROCESS_ID, [
436            'processId' => $processId,
437        ]);
438        return $this;
439    }
440
441    public function writeCanceledByTimeAndScope(\DateTimeInterface $dateTime, \BO\Zmsentities\Scope $scope)
442    {
443        $status = $this->perform(Query\Slot::QUERY_UPDATE_SLOT_MISSING_AVAILABILITY_BY_SCOPE, [
444            'dateString' => $dateTime->format('Y-m-d'),
445            'scopeID' => $scope->id,
446        ]);
447        return $this->perform(Query\Slot::QUERY_CANCEL_SLOT_OLD_BY_SCOPE, [
448            'year' => $dateTime->format('Y'),
449            'month' => $dateTime->format('m'),
450            'day' => $dateTime->format('d'),
451            'time' => $dateTime->format('H:i:s'),
452            'scopeID' => $scope->id,
453        ]) && $status;
454    }
455
456    public function writeCanceledByTime(\DateTimeInterface $dateTime)
457    {
458        $status = $this->perform(Query\Slot::QUERY_UPDATE_SLOT_MISSING_AVAILABILITY, [
459            'dateString' => $dateTime->format('Y-m-d'),
460        ]);
461        return $this->perform(Query\Slot::QUERY_CANCEL_SLOT_OLD, [
462            'year' => $dateTime->format('Y'),
463            'month' => $dateTime->format('m'),
464            'day' => $dateTime->format('d'),
465            'time' => $dateTime->format('H:i:s'),
466        ]) && $status;
467    }
468
469    public function deleteSlotsOlderThan(\DateTimeInterface $dateTime)
470    {
471        $status = $this->perform(Query\Slot::QUERY_DELETE_SLOT_OLD, [
472            'year' => $dateTime->format('Y'),
473            'month' => $dateTime->format('m'),
474            'day' => $dateTime->format('d'),
475        ]);
476        $status = ($status && $this->perform(Query\Slot::QUERY_DELETE_SLOT_HIERA));
477        return $status;
478    }
479
480    /**
481     * This function is for debugging
482     */
483    public function readRowsByScopeAndDate(
484        \BO\Zmsentities\Scope $scope,
485        \DateTimeInterface $dateTime
486    ) {
487        $list = $this->fetchAll(Query\Slot::QUERY_SELECT_BY_SCOPE_AND_DAY, [
488            'year' => $dateTime->format('Y'),
489            'month' => $dateTime->format('m'),
490            'day' => $dateTime->format('d'),
491            'scopeID' => $scope->id,
492        ]);
493        return $list;
494    }
495
496    public function writeOptimizedSlotTables()
497    {
498        $status = true;
499        $status = ($status && $this->perform(Query\Slot::QUERY_OPTIMIZE_SLOT));
500        $status = ($status && $this->perform(Query\Slot::QUERY_OPTIMIZE_SLOT_HIERA));
501        $status = ($status && $this->perform(Query\Slot::QUERY_OPTIMIZE_SLOT_PROCESS));
502        $status = ($status && $this->perform(Query\Slot::QUERY_OPTIMIZE_PROCESS));
503        return $status;
504    }
505
506    private function getLastGeneratedSlotDate(AvailabilityEntity $availability)
507    {
508        $date = '1970-01-01 12:00';
509        $last = $this->fetchRow(
510            Query\Slot::QUERY_LAST_IN_AVAILABILITY,
511            [
512                'availabilityID' => $availability->id,
513            ]
514        );
515
516        if (isset($last['dateString'])) {
517            $date = $last['dateString'];
518        }
519
520        return new \DateTimeImmutable($date . \BO\Zmsdb\Connection\Select::$connectionTimezone);
521    }
522}