Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
74.74% covered (warning)
74.74%
142 / 190
14.29% covered (danger)
14.29%
1 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
OverallCalendar
74.74% covered (warning)
74.74%
142 / 190
14.29% covered (danger)
14.29%
1 / 7
31.53
0.00% covered (danger)
0.00%
0 / 1
 insertSlotsBulk
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
3.01
 cancelAvailability
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 purgeMissingAvailabilityByScope
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 deleteOlderThan
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 book
90.24% covered (success)
90.24%
74 / 82
0.00% covered (danger)
0.00%
0 / 1
3.01
 unbook
73.53% covered (warning)
73.53%
50 / 68
0.00% covered (danger)
0.00%
0 / 1
13.24
 readSlots
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3namespace BO\Zmsdb;
4
5use BO\Zmsdb\Query\OverallCalendar as Calender;
6use DateInterval;
7use DateTimeImmutable;
8use DateTimeInterface;
9
10class OverallCalendar extends Base
11{
12    public function insertSlotsBulk(array $rows): void
13    {
14        if (!$rows) {
15            return;
16        }
17
18        $placeholders = rtrim(str_repeat('(?,?,?,?,?),', count($rows)), ',');
19        $sql = sprintf(Query\OverallCalendar::UPSERT_MULTI, $placeholders);
20
21        $params = [];
22        foreach ($rows as $r) {
23            $params[] = $r[0];
24            $params[] = $r[1];
25            $params[] = $r[2]->format('Y-m-d H:i:s');
26            $params[] = (int)$r[3];
27            $params[] = $r[4] ?? 'free';
28        }
29        $this->perform($sql, $params);
30    }
31
32    public function cancelAvailability(int $scopeId, int $availabilityId): void
33    {
34        $this->perform(Calender::CANCEL_AVAILABILITY, [
35            'scope_id' => $scopeId,
36            'availability_id' => $availabilityId,
37        ]);
38    }
39
40    public function purgeMissingAvailabilityByScope(
41        \DateTimeInterface $dateTime,
42        int $scopeId
43    ): bool {
44        return (bool) $this->perform(
45            Query\OverallCalendar::PURGE_MISSING_AVAIL_BY_SCOPE,
46            [
47                'dateString' => $dateTime->format('Y-m-d'),
48                'scopeID'    => $scopeId,
49            ]
50        );
51    }
52
53
54    public function deleteOlderThan(DateTimeInterface $date): bool
55    {
56        return (bool) $this->perform(Calender::DELETE_ALL_BEFORE, [
57            'threshold' => $date->format('Y-m-d 00:00:00'),
58        ]);
59    }
60
61    public function book(int $scopeId, string $startTime, int $processId, int $slotUnits): void
62    {
63        $start = new DateTimeImmutable($startTime);
64        $end   = $start->add(new DateInterval('PT' . ($slotUnits * 5) . 'M'));
65
66        $windowBefore = $this->fetchRow('
67            SELECT
68              SUM(status="free")      AS free_cnt,
69              SUM(status="cancelled") AS cancelled_cnt,
70              SUM(status="termin")    AS termin_cnt,
71              COUNT(DISTINCT availability_id) AS availability_ids
72            FROM gesamtkalender
73            WHERE scope_id=:scope AND time>=:start AND time<:end
74        ', [
75            'scope' => $scopeId,
76            'start' => $start->format('Y-m-d H:i:s'),
77            'end'   => $end  ->format('Y-m-d H:i:s'),
78        ]) ?? ['free_cnt' => 0,'cancelled_cnt' => 0,'termin_cnt' => 0,'availability_ids' => 0];
79
80        $availabilityDetails = $this->fetchAll('
81            SELECT DISTINCT
82                   g.availability_id,
83                   a.OeffnungszeitID,
84                   a.Startdatum, a.Endedatum,
85                   a.Anfangszeit, a.Terminanfangszeit,
86                   a.Endzeit, a.Terminendzeit,
87                   a.Timeslot,
88                   a.Anzahlarbeitsplaetze,
89                   a.Anzahlterminarbeitsplaetze
90            FROM gesamtkalender g
91            LEFT JOIN oeffnungszeit a ON a.OeffnungszeitID = g.availability_id
92            WHERE g.scope_id=:scope AND g.time>=:start AND g.time<:end
93        ', [
94            'scope' => $scopeId,
95            'start' => $start->format('Y-m-d H:i:s'),
96            'end'   => $end  ->format('Y-m-d H:i:s'),
97        ]);
98
99        $recentCancelled = (int)$this->fetchValue('
100            SELECT COUNT(*) FROM gesamtkalender
101             WHERE scope_id=:scope AND time>=:start AND time<:end
102               AND status="cancelled" AND updated_at > (NOW() - INTERVAL 2 MINUTE)
103        ', [
104            'scope' => $scopeId,
105            'start' => $start->format('Y-m-d H:i:s'),
106            'end'   => $end  ->format('Y-m-d H:i:s'),
107        ]);
108
109        \App::$log->info('calendar.book.attempt', [
110            'scope_id'        => $scopeId,
111            'process_id'      => $processId,
112            'window'          => ['from' => $start->format('Y-m-d H:i:s'), 'until' => $end->format('Y-m-d H:i:s')],
113            'slot_units'      => $slotUnits,
114            'window_before'   => $windowBefore,
115            'availability'    => $availabilityDetails,
116            'recent_cancelled' => $recentCancelled,
117        ]);
118
119        $seat = $this->fetchValue(Calender::FIND_FREE_SEAT, [
120            'scope' => $scopeId,
121            'start' => $start->format('Y-m-d H:i:s'),
122            'end'   => $end  ->format('Y-m-d H:i:s'),
123            'units' => $slotUnits,
124        ]);
125
126        if (!$seat) {
127            \App::$log->warning('calendar.book.no_seat', [
128                'scope_id'        => $scopeId,
129                'process_id'      => $processId,
130                'window'          => ['from' => $start->format('Y-m-d H:i:s'), 'until' => $end->format('Y-m-d H:i:s')],
131                'slot_units'      => $slotUnits,
132                'window_before'   => $windowBefore,
133                'recent_cancelled' => $recentCancelled,
134            ]);
135            return;
136        }
137
138        try {
139            $this->perform(Calender::BLOCK_SEAT_RANGE, [
140                'pid'   => $processId,
141                'units' => $slotUnits,
142                'scope' => $scopeId,
143                'seat'  => $seat,
144                'start' => $start->format('Y-m-d H:i:s'),
145                'end'   => $end  ->format('Y-m-d H:i:s'),
146            ]);
147        } catch (\PDOException $e) {
148            \App::$log->critical('calendar.book.update_failed', [
149                'scope_id'   => $scopeId,
150                'process_id' => $processId,
151                'seat'       => $seat,
152                'error'      => $e->getMessage()
153            ]);
154            throw $e;
155        }
156
157        $windowAfter = $this->fetchRow('
158            SELECT
159              SUM(status="free")      AS free_cnt,
160              SUM(status="cancelled") AS cancelled_cnt,
161              SUM(status="termin")    AS termin_cnt
162            FROM gesamtkalender
163            WHERE scope_id=:scope AND time>=:start AND time<:end
164        ', [
165            'scope' => $scopeId,
166            'start' => $start->format('Y-m-d H:i:s'),
167            'end'   => $end  ->format('Y-m-d H:i:s'),
168        ]) ?? ['free_cnt' => 0,'cancelled_cnt' => 0,'termin_cnt' => 0];
169
170        $terminByPid = (int)$this->fetchValue('
171            SELECT COUNT(*) FROM gesamtkalender
172             WHERE scope_id=:scope AND time>=:start AND time<:end
173               AND status="termin" AND process_id=:pid
174        ', [
175            'scope' => $scopeId,
176            'start' => $start->format('Y-m-d H:i:s'),
177            'end'   => $end  ->format('Y-m-d H:i:s'),
178            'pid'   => $processId,
179        ]);
180
181        \App::$log->info('calendar.book.result', [
182            'scope_id'       => $scopeId,
183            'process_id'     => $processId,
184            'seat'           => $seat,
185            'window_after'   => $windowAfter,
186            'termin_by_pid'  => $terminByPid,
187            'complete_chain' => ($terminByPid === $slotUnits),
188        ]);
189    }
190
191    public function unbook(int $scopeId, int $processId): void
192    {
193        $row = $this->fetchRow('
194        SELECT
195          MIN(time) AS start_ts,
196          MAX(time) AS end_ts,
197          COUNT(*)  AS units
198        FROM gesamtkalender
199        WHERE scope_id=:scope AND process_id=:pid
200    ', ['scope' => $scopeId,'pid' => $processId]);
201
202        if (!$row || !$row['start_ts']) {
203            \App::$log->warning('calendar.unbook.no_rows', [
204                'scope_id' => $scopeId, 'process_id' => $processId
205            ]);
206            return;
207        }
208
209        $start = new \DateTimeImmutable($row['start_ts']);
210        $end   = (new \DateTimeImmutable($row['end_ts']))->modify('+5 minutes');
211        $slotUnits = (int)$row['units'];
212
213        $seatByPid = $this->fetchAll('
214        SELECT seat, COUNT(*) AS cnt
215          FROM gesamtkalender
216         WHERE scope_id=:scope AND process_id=:pid
217         GROUP BY seat ORDER BY seat
218    ', ['scope' => $scopeId,'pid' => $processId]);
219
220        $windowBefore = $this->fetchRow('
221        SELECT
222          SUM(status="free")      AS free_cnt,
223          SUM(status="cancelled") AS cancelled_cnt,
224          SUM(status="termin")    AS termin_cnt
225        FROM gesamtkalender
226        WHERE scope_id=:scope AND time>=:start AND time<:end
227    ', ['scope' => $scopeId,'start' => $start->format('Y-m-d H:i:s'),'end' => $end->format('Y-m-d H:i:s')])
228            ?? ['free_cnt' => 0,'cancelled_cnt' => 0,'termin_cnt' => 0];
229
230        $availabilityDetails = $this->fetchAll('
231        SELECT DISTINCT
232               g.availability_id,
233               a.OeffnungszeitID,
234               a.Startdatum, a.Endedatum,
235               a.Anfangszeit, a.Terminanfangszeit,
236               a.Endzeit, a.Terminendzeit,
237               a.Timeslot,
238               a.Anzahlarbeitsplaetze,
239               a.Anzahlterminarbeitsplaetze
240          FROM gesamtkalender g
241          LEFT JOIN oeffnungszeit a ON a.OeffnungszeitID = g.availability_id
242         WHERE g.scope_id=:scope AND g.process_id=:pid
243    ', ['scope' => $scopeId,'pid' => $processId]);
244
245        $reasonHint = 'freed';
246        foreach ($availabilityDetails as $a) {
247            if ($a['OeffnungszeitID'] === null) {
248                $reasonHint = 'missing_availability';
249                break;
250            }
251            if ($a['Endedatum'] < date('Y-m-d')) {
252                $reasonHint = 'availability_ended';
253                break;
254            }
255        }
256
257        \App::$log->info('calendar.unbook.attempt', [
258            'scope_id'       => $scopeId,
259            'process_id'     => $processId,
260            'window'         => ['from' => $start->format('Y-m-d H:i:s'),'until' => $end->format('Y-m-d H:i:s')],
261            'slot_units'     => $slotUnits,
262            'seat_by_pid'    => $seatByPid,
263            'window_before'  => $windowBefore,
264            'availability'   => $availabilityDetails,
265            'reason_hint'    => $reasonHint,
266        ]);
267
268        $affected = 0;
269        try {
270            $affected = (int)$this->perform(Calender::UNBOOK_PROCESS, [
271                'scope_id'   => $scopeId,
272                'process_id' => $processId,
273            ]);
274        } catch (\PDOException $e) {
275            \App::$log->critical('calendar.unbook.update_failed', [
276                'scope_id' => $scopeId, 'process_id' => $processId, 'error' => $e->getMessage()
277            ]);
278            throw $e;
279        }
280
281        $windowAfter = $this->fetchRow('
282        SELECT
283          SUM(status="free")      AS free_cnt,
284          SUM(status="cancelled") AS cancelled_cnt,
285          SUM(status="termin")    AS termin_cnt
286        FROM gesamtkalender
287        WHERE scope_id=:scope AND time>=:start AND time<:end
288    ', ['scope' => $scopeId,'start' => $start->format('Y-m-d H:i:s'),'end' => $end->format('Y-m-d H:i:s')])
289            ?? ['free_cnt' => 0,'cancelled_cnt' => 0,'termin_cnt' => 0];
290
291        $statusBySeat = $this->fetchAll('
292        SELECT seat, status, COUNT(*) AS cnt
293          FROM gesamtkalender
294         WHERE scope_id=:scope AND time>=:start AND time<:end
295         GROUP BY seat, status
296         ORDER BY seat, status
297    ', ['scope' => $scopeId,'start' => $start->format('Y-m-d H:i:s'),'end' => $end->format('Y-m-d H:i:s')]);
298
299        $reason = 'freed';
300        $reasonRow = $this->fetchRow('
301        SELECT g.seat, g.availability_id,
302               a.OeffnungszeitID, a.Endedatum, IFNULL(a.Anzahlterminarbeitsplaetze,1) AS cap
303          FROM gesamtkalender g
304          LEFT JOIN oeffnungszeit a ON a.OeffnungszeitID = g.availability_id
305         WHERE g.scope_id=:scope AND g.time>=:start AND g.time<:end
306         LIMIT 1
307    ', ['scope' => $scopeId,'start' => $start->format('Y-m-d H:i:s'),'end' => $end->format('Y-m-d H:i:s')]);
308
309        if ($reasonRow) {
310            if ($reasonRow['OeffnungszeitID'] === null) {
311                $reason = 'missing_availability';
312            } elseif ($reasonRow['Endedatum'] < date('Y-m-d')) {
313                $reason = 'availability_ended';
314            } elseif ($reasonRow['seat'] > (int)$reasonRow['cap']) {
315                $reason = 'seat_over_capacity';
316            }
317        }
318
319        \App::$log->info('calendar.unbook.result', [
320            'scope_id'      => $scopeId,
321            'process_id'    => $processId,
322            'affected'      => $affected,
323            'window_after'  => $windowAfter,
324            'status_by_seat' => $statusBySeat,
325            'reason'        => $reason,
326        ]);
327    }
328
329    public function readSlots(
330        array $scopeIds,
331        string $from,
332        string $until,
333        ?string $updatedAfter = null
334    ): array {
335        if (empty($scopeIds)) {
336            return [];
337        }
338
339        $in_list = implode(',', array_map('intval', $scopeIds));
340        $until = (new \DateTime($until))->modify('+1 day')->format('Y-m-d');
341
342        if ($updatedAfter === null) {
343            $sql = sprintf(Calender::SELECT_RANGE, $in_list);
344            $params = ['from' => $from, 'until' => $until];
345        } else {
346            $sql = sprintf(Calender::SELECT_RANGE_UPDATED, $in_list);
347            $params = [
348                'from'         => $from,
349                'until'        => $until,
350                'updatedAfter' => $updatedAfter
351            ];
352        }
353
354        return $this->fetchAll($sql, $params);
355    }
356}