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