Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.83% covered (warning)
85.83%
212 / 247
52.94% covered (warning)
52.94%
9 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
ProcessStatusFree
85.83% covered (warning)
85.83%
212 / 247
52.94% covered (warning)
52.94%
9 / 17
77.02
0.00% covered (danger)
0.00%
0 / 1
 prepareCalendarAndDays
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 buildDaysList
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 buildDaysListFromCalendarDays
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
6.56
 readFreeProcessesMinimalFromPreparedCalendar
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
4.00
 getProcessDataHandle
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
5
 readFreeProcesses
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
3
 readFreeProcessesMinimalDeduplicated
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 deduplicateWithRoundRobin
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
4
 uniqueCandidatesSortedByScopeId
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
2.00
 resolveRoundRobinGroupKey
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 pickRoundRobinIndex
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 extractProcessInfo
81.48% covered (warning)
81.48%
22 / 27
0.00% covered (danger)
0.00%
0 / 1
10.64
 createMinimalProcess
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
1
 readReservedProcesses
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 writeEntityReserved
70.59% covered (warning)
70.59%
12 / 17
0.00% covered (danger)
0.00%
0 / 1
4.41
 writeEntityReservedAttempt
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
6
 resetWriteTransactionAfterDeadlock
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3namespace BO\Zmsbackend\Process\Service;
4
5use BO\Zmsentities\Process as Entity;
6use BO\Zmsentities\Collection\ProcessList as Collection;
7
8/**
9 * @SuppressWarnings(Coupling)
10 */
11class ProcessStatusFree extends Process
12{
13    private function prepareCalendarAndDays(
14        \BO\Zmsentities\Calendar $calendar,
15        \DateTimeInterface $now,
16        $slotsRequired = null
17    ) {
18        $calendar = (new \BO\Zmsbackend\Calendar\Service\Calendar())->readResolvedEntity($calendar, $now, true);
19        $dayquery = new \BO\Zmsbackend\Day\Service\Day();
20        $dayquery->writeTemporaryScopeList($calendar, $slotsRequired);
21
22        return [$calendar, $dayquery, $this->buildDaysList($calendar)];
23    }
24
25    private function buildDaysList(\BO\Zmsentities\Calendar $calendar): array
26    {
27        $selectedDate = $calendar->getFirstDay();
28        $days = [$selectedDate];
29        if ($calendar->getLastDay(false)) {
30            $days = [];
31            while ($selectedDate <= $calendar->getLastDay(false)) {
32                $days[] = $selectedDate;
33                $selectedDate = $selectedDate->modify('+1 day');
34            }
35        }
36
37        return $days;
38    }
39
40    /**
41     * Prefer concrete days already on the calendar (e.g. bookable days only).
42     * Falls back to the full firstDay→lastDay range when no days are set.
43     */
44    private function buildDaysListFromCalendarDays(\BO\Zmsentities\Calendar $calendar): array
45    {
46        if (!isset($calendar->days) || count($calendar->days) < 1) {
47            return $this->buildDaysList($calendar);
48        }
49
50        $daysByDate = [];
51        foreach ($calendar->days as $day) {
52            if (!$day instanceof \BO\Zmsentities\Day) {
53                $day = new \BO\Zmsentities\Day($day);
54            }
55            $dateTime = $day->toDateTime();
56            $daysByDate[$dateTime->format('Y-m-d')] = $dateTime;
57        }
58
59        if ($daysByDate === []) {
60            return $this->buildDaysList($calendar);
61        }
62
63        ksort($daysByDate);
64
65        return array_values($daysByDate);
66    }
67
68    public function readFreeProcessesMinimalFromPreparedCalendar(
69        \BO\Zmsentities\Calendar $calendar,
70        string $slotType = 'public',
71        ?int $slotsRequired = null,
72        bool $groupData = false
73    ): array {
74        $days = $this->buildDaysListFromCalendarDays($calendar);
75        if ($days === []) {
76            return [];
77        }
78
79        $processData = $this->getProcessDataHandle(
80            $days,
81            $slotType,
82            $slotsRequired,
83            $groupData,
84            true
85        );
86
87        $processInfos = [];
88        while ($item = $processData->fetch(\PDO::FETCH_ASSOC)) {
89            $processInfo = $this->extractProcessInfo($item, $calendar);
90            if ($processInfo) {
91                $processInfos[] = $processInfo;
92            }
93        }
94
95        $processData->closeCursor();
96
97        return $this->deduplicateWithRoundRobin($processInfos);
98    }
99
100    private function getProcessDataHandle(
101        array $days,
102        $slotType,
103        $slotsRequired,
104        $groupData,
105        bool $useAvailabilityQuery = false
106    ) {
107        $query = $useAvailabilityQuery
108            ? \BO\Zmsbackend\Process\Repository\ProcessStatusFree::QUERY_SELECT_PROCESSLIST_DAYS_AVAILABILITY
109            : \BO\Zmsbackend\Process\Repository\ProcessStatusFree::QUERY_SELECT_PROCESSLIST_DAYS;
110
111        return $this->fetchHandle(
112            sprintf(
113                $query,
114                \BO\Zmsbackend\Process\Repository\ProcessStatusFree::buildDaysCondition($days)
115            )
116            . ($groupData ? \BO\Zmsbackend\Process\Repository\ProcessStatusFree::GROUPBY_SELECT_PROCESSLIST_DAY : ''),
117            [
118                'slotType' => $slotType,
119                'forceRequiredSlots' =>
120                    ($slotsRequired === null || $slotsRequired < 1) ? 1 : intval($slotsRequired),
121            ]
122        );
123    }
124
125    public function readFreeProcesses(
126        \BO\Zmsentities\Calendar $calendar,
127        \DateTimeInterface $now,
128        $slotType = 'public',
129        $slotsRequired = null,
130        $groupData = false
131    ) {
132        list($calendar, $dayquery, $days) = $this->prepareCalendarAndDays($calendar, $now, $slotsRequired);
133        $processData = $this->getProcessDataHandle($days, $slotType, $slotsRequired, $groupData);
134        $processList = new Collection();
135        $scopeList = [];
136        while ($item = $processData->fetch(\PDO::FETCH_ASSOC)) {
137            $process = new \BO\Zmsentities\Process($item);
138            $process->requests = $calendar->requests;
139            $process->appointments->getFirst()->setDateByString(
140                $process->appointments->getFirst()->date,
141                'Y-m-d H:i:s'
142            );
143
144            if (! isset($scopeList[$process->scope->id])) {
145                $scopeList[$process->scope->id] = $calendar->scopes->getEntity($process->scope->id);
146            }
147
148            $process->scope = $scopeList[$process->scope->id];
149            $process->queue['withAppointment'] = 1;
150            $process->appointments->getFirst()->scope = $process->scope;
151            $processList->addEntity($process);
152        }
153        $processData->closeCursor();
154        unset($dayquery);
155        return $processList;
156    }
157
158    public function readFreeProcessesMinimalDeduplicated(
159        \BO\Zmsentities\Calendar $calendar,
160        \DateTimeInterface $now,
161        string $slotType = 'public',
162        ?int $slotsRequired = null,
163        bool $groupData = false
164    ): array {
165        list($calendar, $dayquery, $days) = $this->prepareCalendarAndDays($calendar, $now, $slotsRequired);
166        $processData = $this->getProcessDataHandle($days, $slotType, $slotsRequired, $groupData);
167
168        $processInfos = [];
169        while ($item = $processData->fetch(\PDO::FETCH_ASSOC)) {
170            $processInfo = $this->extractProcessInfo($item, $calendar);
171            if ($processInfo) {
172                $processInfos[] = $processInfo;
173            }
174        }
175
176        $processData->closeCursor();
177        unset($dayquery);
178
179        return $this->deduplicateWithRoundRobin($processInfos);
180    }
181
182    /**
183     * Keep one free process per round-robin group + timestamp.
184     *
185     * Default group is the provider id. When provider.data.sharedBookingOfficeIds
186     * is set, all peer providers share one group so the same wall-clock slot is
187     * offered once and successive timeslots round-robin across eligible scopes
188     * of every peer (ZMSKVR-1046). Scopes that cannot fit the slot never appear
189     * here, so fall-through is preserved.
190     *
191     * @param array<int, array<string, mixed>> $processInfos
192     * @return array<int, array<string, mixed>>
193     */
194    private function deduplicateWithRoundRobin(array $processInfos): array
195    {
196        // Composite key: round-robin group (provider or shared-office set) + slot timestamp.
197        $candidatesByGroupTimestampKey = [];
198        $groupTimestampKeyOrder = [];
199        foreach ($processInfos as $processInfo) {
200            $roundRobinGroupKey = self::resolveRoundRobinGroupKey(
201                (string) $processInfo['providerId'],
202                $processInfo['sharedBookingOfficeIds'] ?? null
203            );
204            $groupTimestampKey = $roundRobinGroupKey . '_' . $processInfo['date'];
205            if (!isset($candidatesByGroupTimestampKey[$groupTimestampKey])) {
206                $groupTimestampKeyOrder[] = $groupTimestampKey;
207                $candidatesByGroupTimestampKey[$groupTimestampKey] = [];
208            }
209            $candidatesByGroupTimestampKey[$groupTimestampKey][] = $processInfo;
210        }
211
212        $roundRobinIndexByGroup = [];
213        $deduplicatedProcesses = [];
214        foreach ($groupTimestampKeyOrder as $groupTimestampKey) {
215            $candidates = self::uniqueCandidatesSortedByScopeId(
216                $candidatesByGroupTimestampKey[$groupTimestampKey]
217            );
218            $roundRobinGroupKey = self::resolveRoundRobinGroupKey(
219                (string) $candidates[0]['providerId'],
220                $candidates[0]['sharedBookingOfficeIds'] ?? null
221            );
222            $roundRobinTimeslotIndex = $roundRobinIndexByGroup[$roundRobinGroupKey] ?? 0;
223            $chosenCandidate = $candidates[
224                self::pickRoundRobinIndex($roundRobinTimeslotIndex, count($candidates))
225            ];
226            $roundRobinIndexByGroup[$roundRobinGroupKey] = $roundRobinTimeslotIndex + 1;
227            $deduplicatedProcesses[] = $this->createMinimalProcess($chosenCandidate);
228        }
229
230        return $deduplicatedProcesses;
231    }
232
233    /**
234     * @param array<int, array<string, mixed>> $candidates
235     * @return array<int, array<string, mixed>>
236     */
237    private static function uniqueCandidatesSortedByScopeId(array $candidates): array
238    {
239        $candidatesByScopeId = [];
240        foreach ($candidates as $candidate) {
241            $candidatesByScopeId[(string) $candidate['scopeId']] = $candidate;
242        }
243        $uniqueCandidates = array_values($candidatesByScopeId);
244        usort(
245            $uniqueCandidates,
246            static fn (array $left, array $right): int =>
247                ((int) $left['scopeId']) <=> ((int) $right['scopeId'])
248        );
249
250        return $uniqueCandidates;
251    }
252
253    /**
254     * @internal Exposed for unit tests.
255     * @param array<int, int|string>|null $sharedBookingOfficeIds
256     */
257    public static function resolveRoundRobinGroupKey(
258        string $providerId,
259        ?array $sharedBookingOfficeIds
260    ): string {
261        if (!is_array($sharedBookingOfficeIds) || $sharedBookingOfficeIds === []) {
262            return $providerId;
263        }
264
265        $sortedSharedOfficeIds = array_map('intval', $sharedBookingOfficeIds);
266        sort($sortedSharedOfficeIds, SORT_NUMERIC);
267
268        return implode(',', $sortedSharedOfficeIds);
269    }
270
271    /**
272     * @internal Exposed for unit tests.
273     */
274    public static function pickRoundRobinIndex(int $timeslotIndex, int $candidateCount): int
275    {
276        if ($candidateCount < 1) {
277            throw new \InvalidArgumentException('candidateCount must be >= 1');
278        }
279
280        return $timeslotIndex % $candidateCount;
281    }
282
283    private function extractProcessInfo(array $item, \BO\Zmsentities\Calendar $calendar): ?array
284    {
285        $scopeId = $item['scope__id'] ?? null;
286        $dateString = $item['appointments__0__date'] ?? null;
287
288        if (!$scopeId || !$dateString) {
289            return null;
290        }
291
292        $date = strtotime($dateString);
293        if (!$date) {
294            return null;
295        }
296
297        $scope = $calendar->scopes->getEntity($scopeId);
298        if (!$scope) {
299            return null;
300        }
301
302        $providerId = $scope->getProviderId();
303        if (!$providerId) {
304            return null;
305        }
306
307        $sharedBookingOfficeIds = null;
308        $provider = $scope->getProvider();
309        if (
310            $provider
311            && isset($provider->data['sharedBookingOfficeIds'])
312            && is_array($provider->data['sharedBookingOfficeIds'])
313            && $provider->data['sharedBookingOfficeIds'] !== []
314        ) {
315            $sharedBookingOfficeIds = array_map('intval', $provider->data['sharedBookingOfficeIds']);
316        }
317
318        return [
319            'scopeId' => $scopeId,
320            'source' => $scope->getSource(),
321            'providerId' => $providerId,
322            'sharedBookingOfficeIds' => $sharedBookingOfficeIds,
323            'date' => $date
324        ];
325    }
326
327    private function createMinimalProcess(array $processInfo): array
328    {
329        return [
330            '$schema' => 'https://schema.berlin.de/queuemanagement/process.json',
331            'scope' => [
332                'id' => $processInfo['scopeId'],
333                'source' => $processInfo['source'],
334                'provider' => [
335                    'id' => $processInfo['providerId'],
336                    'source' => $processInfo['source'],
337                ]
338            ],
339            'appointments' => [
340                [
341                    'date' => (string)$processInfo['date'],
342                    'scope' => [
343                        'id' => $processInfo['scopeId'],
344                        'source' => $processInfo['source'],
345                        'provider' => [
346                            'id' => $processInfo['providerId'],
347                            'source' => $processInfo['source'],
348                        ]
349                    ]
350                ]
351            ]
352        ];
353    }
354
355    public function readReservedProcesses($resolveReferences = 2)
356    {
357        $processList = new Collection();
358        $query = new \BO\Zmsbackend\Process\Repository\Process(\BO\Zmsbackend\Query\Base::SELECT);
359        $query
360            ->addResolvedReferences($resolveReferences)
361            ->addEntityMapping()
362            ->addConditionAssigned()
363            ->addConditionIsReserved();
364        $resultData = $this->fetchList($query, new Entity());
365        foreach ($resultData as $process) {
366            if (2 == $resolveReferences) {
367                $process->requests = (new \BO\Zmsbackend\Request\Service\Request())->readRequestByProcessId($process->id, $resolveReferences);
368                $process->scope = (new \BO\Zmsbackend\Scope\Service\Scope())->readEntity($process->getScopeId(), $resolveReferences);
369            }
370            if ($process instanceof Entity) {
371                $processList->addEntity($process);
372            }
373        }
374        return $processList;
375    }
376
377    /**
378     * Insert a new process if there are free slots
379     *
380     * @param \BO\Zmsentities\Process $process
381     * @param \DateTimeInterface $now
382     * @param String $slotType
383     * @param Int $slotsRequired we cannot use process.appointments.0.slotCount, because setting slotsRequired is
384     *        a priviliged operation. Just using the input would be a security flaw to get a wider selection of times
385     *        If slotsRequired = 0, readFreeProcesses() uses the slotsRequired based on request-provider relation
386     */
387    public function writeEntityReserved(
388        \BO\Zmsentities\Process $process,
389        \DateTimeInterface $now,
390        string $slotType = "public",
391        int $slotsRequired = 0,
392        int $resolveReferences = 0,
393        ?\BO\Zmsentities\Useraccount $userAccount = null
394    ): ?\BO\Zmsentities\Process {
395        $maxAttempts = 3;
396        $attempt = 0;
397        while (true) {
398            try {
399                return $this->writeEntityReservedAttempt(
400                    $process,
401                    $now,
402                    $slotType,
403                    $slotsRequired,
404                    $resolveReferences,
405                    $userAccount
406                );
407            } catch (\BO\Zmsbackend\Exception\Pdo\DeadLockFound $exception) {
408                $attempt++;
409                if ($attempt >= $maxAttempts) {
410                    throw $exception;
411                }
412                $this->resetWriteTransactionAfterDeadlock();
413                usleep(50000 * $attempt);
414            }
415        }
416    }
417
418    protected function writeEntityReservedAttempt(
419        \BO\Zmsentities\Process $process,
420        \DateTimeInterface $now,
421        string $slotType = "public",
422        int $slotsRequired = 0,
423        int $resolveReferences = 0,
424        ?\BO\Zmsentities\Useraccount $userAccount = null
425    ): ?\BO\Zmsentities\Process {
426        $process = clone $process;
427        $process->status = 'reserved';
428        $appointment = $process->getAppointments()->getFirst();
429        $slotList = (new \BO\Zmsbackend\Slot\Service\Slot())->readByAppointment(
430            $appointment,
431            $slotsRequired,
432            (null !== $userAccount),
433            true
434        );
435        $freeProcessList = $this->readFreeProcesses($process->toCalendar(), $now, $slotType, $slotsRequired);
436
437        if (!$freeProcessList->getAppointmentList()->hasAppointment($appointment) || ! $slotList) {
438            throw new \BO\Zmsbackend\Process\Exception\ProcessReserveFailed();
439        }
440
441        foreach ($slotList as $slot) {
442            if ($process->id > 99999) {
443                $newProcess = clone $process;
444                $newProcess->getFirstAppointment()->setTime($slot->time);
445                $this->writeNewProcess($newProcess, $now, $process->id, 0, true, $userAccount);
446            } elseif ($process->id === 0) {
447                $process = $this->writeNewProcess($process, $now, 0, count($slotList) - 1, true, $userAccount);
448            } else {
449                throw new \Exception("SQL UPDATE error on inserting new $process on $slot");
450            }
451        }
452        $this->writeRequestsToDb($process);
453        return $this->readEntity($process->getId(), new \BO\Zmsbackend\Helper\NoAuth(), $resolveReferences);
454    }
455
456    /**
457     * After InnoDB aborts a transaction on deadlock, clear PDO state and start a fresh transaction.
458     */
459    protected function resetWriteTransactionAfterDeadlock(): void
460    {
461        $connection = \BO\Zmsbackend\Connection\Select::getWriteConnection();
462        if ($connection->inTransaction()) {
463            try {
464                $connection->rollBack();
465            } catch (\PDOException $exception) {
466                \App::$log->warning('Rollback after deadlock failed', [
467                    'error' => $exception->getMessage(),
468                ]);
469            }
470        }
471        if (!$connection->inTransaction()) {
472            $connection->beginTransaction();
473        }
474    }
475}