Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.13% covered (success)
90.13%
502 / 557
15.38% covered (danger)
15.38%
4 / 26
CRAP
0.00% covered (danger)
0.00%
0 / 1
ZmsApiFacadeService
90.13% covered (success)
90.13%
502 / 557
15.38% covered (danger)
15.38%
4 / 26
223.39
0.00% covered (danger)
0.00%
0 / 1
 setLanguageContext
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getError
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setMappedCache
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 getOffices
96.23% covered (success)
96.23%
51 / 53
0.00% covered (danger)
0.00%
0 / 1
18
 getScopes
97.67% covered (success)
97.67%
42 / 43
0.00% covered (danger)
0.00%
0 / 1
14
 getServices
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
8.02
 getServicesAndOffices
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
4.00
 getScopeByOfficeId
95.89% covered (success)
95.89%
70 / 73
0.00% covered (danger)
0.00%
0 / 1
14
 getOfficeListByServiceId
92.50% covered (success)
92.50%
37 / 40
0.00% covered (danger)
0.00%
0 / 1
16.11
 getScopeById
97.83% covered (success)
97.83%
45 / 46
0.00% covered (danger)
0.00%
0 / 1
14
 getServicesByOfficeId
96.15% covered (success)
96.15%
25 / 26
0.00% covered (danger)
0.00%
0 / 1
13
 getBookableFreeDays
92.73% covered (success)
92.73%
51 / 55
0.00% covered (danger)
0.00%
0 / 1
13.07
 getFreeAppointments
89.29% covered (warning)
89.29%
25 / 28
0.00% covered (danger)
0.00%
0 / 1
7.06
 processFreeSlots
51.52% covered (warning)
51.52%
17 / 33
0.00% covered (danger)
0.00%
0 / 1
65.59
 getAvailableAppointments
91.67% covered (success)
91.67%
33 / 36
0.00% covered (danger)
0.00%
0 / 1
11.07
 reserveTimeslot
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 getProcessById
22.22% covered (danger)
22.22%
2 / 9
0.00% covered (danger)
0.00%
0 / 1
11.53
 getThinnedProcessById
97.62% covered (success)
97.62%
41 / 42
0.00% covered (danger)
0.00%
0 / 1
12
 updateClientData
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 preconfirmAppointment
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 confirmAppointment
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 cancelAppointment
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 sendPreconfirmationEmail
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 sendConfirmationEmail
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 sendCancellationEmail
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 getAppointmentsByExternalUserId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace BO\Zmscitizenapi\Services\Core;
6
7use BO\Zmscitizenapi\Exceptions\UnauthorizedException;
8use BO\Zmscitizenapi\Models\AuthenticatedUser;
9use BO\Zmscitizenapi\Utils\DateTimeFormatHelper;
10use BO\Zmscitizenapi\Utils\ErrorMessages;
11use BO\Zmscitizenapi\Models\AvailableAppointmentsByOffice;
12use BO\Zmscitizenapi\Models\AvailableDays;
13use BO\Zmscitizenapi\Models\AvailableAppointments;
14use BO\Zmscitizenapi\Models\AvailableDaysByOffice;
15use BO\Zmscitizenapi\Models\Office;
16use BO\Zmscitizenapi\Models\Service;
17use BO\Zmscitizenapi\Models\ThinnedProcess;
18use BO\Zmscitizenapi\Models\ThinnedScope;
19use BO\Zmscitizenapi\Models\Collections\OfficeList;
20use BO\Zmscitizenapi\Models\Collections\OfficeServiceRelationList;
21use BO\Zmscitizenapi\Models\Collections\OfficeServiceAndRelationList;
22use BO\Zmscitizenapi\Models\Collections\ServiceList;
23use BO\Zmscitizenapi\Models\Collections\ThinnedScopeList;
24use BO\Zmsentities\Calendar;
25use BO\Zmsentities\Collection\RequestRelationList;
26use BO\Zmsentities\Process;
27use BO\Zmsentities\Scope;
28use BO\Zmsentities\Collection\ScopeList;
29use BO\Zmsentities\Collection\ProviderList;
30use BO\Zmsentities\Collection\RequestList;
31use BO\Zmsentities\Collection\ProcessList;
32
33/**
34 * @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
35 * @TODO: Break down this facade into smaller domain-specific facades or use the Command pattern
36 */
37class ZmsApiFacadeService
38{
39    private const CACHE_KEY_OFFICES = 'processed_offices';
40    private const CACHE_KEY_SCOPES = 'processed_scopes';
41    private const CACHE_KEY_SERVICES = 'processed_services';
42    private const CACHE_KEY_OFFICES_AND_SERVICES = 'processed_offices_and_services';
43    private const CACHE_KEY_OFFICES_BY_SERVICE_PREFIX = 'processed_offices_by_service_';
44    private const CACHE_KEY_SERVICES_BY_OFFICE_PREFIX = 'processed_services_by_office_';
45
46    private static ?string $currentLanguage = null;
47    public static function setLanguageContext(?string $language): void
48    {
49        self::$currentLanguage = $language;
50    }
51
52    private static function getError(string $key): array
53    {
54        return ErrorMessages::get($key, self::$currentLanguage);
55    }
56
57    private static function setMappedCache(string $cacheKey, mixed $data): void
58    {
59        if (\App::$cache) {
60            \App::$cache->set($cacheKey, $data, \App::$SOURCE_CACHE_TTL);
61            LoggerService::logInfo('Second-level cache set', [
62                'key' => $cacheKey,
63                'ttl' => \App::$SOURCE_CACHE_TTL
64            ]);
65        }
66    }
67
68    /**
69     * @SuppressWarnings(PHPMD.NPathComplexity)
70     */
71    public static function getOffices(bool $showUnpublished = false): OfficeList
72    {
73        $cacheKey = self::CACHE_KEY_OFFICES . ($showUnpublished ? '_unpublished' : '');
74
75        if (\App::$cache && ($cachedData = \App::$cache->get($cacheKey))) {
76            return $cachedData;
77        }
78
79        $providerList = ZmsApiClientService::getOffices() ?? new ProviderList();
80        $scopeList = ZmsApiClientService::getScopes() ?? new ScopeList();
81        $offices = [];
82        $scopeMap = [];
83        foreach ($scopeList as $scope) {
84            if ($scope->getProvider()) {
85                $scopeMap[$scope->getProvider()->source . '_' . $scope->getProvider()->id] = $scope;
86            }
87        }
88
89        foreach ($providerList as $provider) {
90            if (!$showUnpublished && isset($provider->data['public']) && !(bool) $provider->data['public']) {
91                continue;
92            }
93
94            $matchingScope = $scopeMap[$provider->source . '_' . $provider->id] ?? null;
95            $offices[] = new Office(
96                id: (int) $provider->id,
97                name: $provider->displayName ?? $provider->name,
98                address: $provider->data['address'] ?? null,
99                showAlternativeLocations: $provider->data['showAlternativeLocations'] ?? null,
100                displayNameAlternatives: $provider->data['displayNameAlternatives'] ?? [],
101                organization: $provider->data['organization'] ?? null,
102                organizationUnit: $provider->data['organizationUnit'] ?? null,
103                slotTimeInMinutes: $provider->data['slotTimeInMinutes'] ?? null,
104                geo: $provider->data['geo'] ?? null,
105                scope: $matchingScope ? new ThinnedScope(
106                    id: (int) $matchingScope->id,
107                    provider: MapperService::providerToThinnedProvider($provider),
108                    shortName: (string) $matchingScope->getShortName(),
109                    emailFrom: (string) $matchingScope->getEmailFrom(),
110                    emailRequired: (bool) $matchingScope->getEmailRequired(),
111                    telephoneActivated: (bool) $matchingScope->getTelephoneActivated(),
112                    telephoneRequired: (bool) $matchingScope->getTelephoneRequired(),
113                    customTextfieldActivated: (bool) $matchingScope->getCustomTextfieldActivated(),
114                    customTextfieldRequired: (bool) $matchingScope->getCustomTextfieldRequired(),
115                    customTextfieldLabel: $matchingScope->getCustomTextfieldLabel(),
116                    customTextfield2Activated: (bool) $matchingScope->getCustomTextfield2Activated(),
117                    customTextfield2Required: (bool) $matchingScope->getCustomTextfield2Required(),
118                    customTextfield2Label: $matchingScope->getCustomTextfield2Label(),
119                    captchaActivatedRequired: (bool) $matchingScope->getCaptchaActivatedRequired(),
120                    infoForAppointment: $matchingScope->getInfoForAppointment(),
121                    infoForAllAppointments: $matchingScope->getInfoForAllAppointments(),
122                    slotsPerAppointment: ((string) $matchingScope->getSlotsPerAppointment() === '' ? null : (string) $matchingScope->getSlotsPerAppointment()),
123                    appointmentsPerMail: ((string) $matchingScope->getAppointmentsPerMail() === '' ? null : (string) $matchingScope->getAppointmentsPerMail()),
124                    whitelistedMails: ((string) $matchingScope->getWhitelistedMails() === '' ? null : (string) $matchingScope->getWhitelistedMails()),
125                    activationDuration: MapperService::extractActivationDuration($matchingScope),
126                    reservationDuration: (int) MapperService::extractReservationDuration($matchingScope),
127                    hint: ($matchingScope && trim((string) $matchingScope->getScopeHint()) !== '')  ? (string) $matchingScope->getScopeHint() : null
128                ) : null,
129                maxSlotsPerAppointment: $matchingScope ? ((string) $matchingScope->getSlotsPerAppointment() === '' ? null : (string) $matchingScope->getSlotsPerAppointment()) : null
130            );
131        }
132
133        $result = new OfficeList($offices);
134
135        self::setMappedCache($cacheKey, $result);
136
137        return $result;
138    }
139
140    public static function getScopes(): ThinnedScopeList|array
141    {
142        $cacheKey = self::CACHE_KEY_SCOPES;
143
144        if (\App::$cache && ($cachedData = \App::$cache->get($cacheKey))) {
145            return $cachedData;
146        }
147
148        $providerList = ZmsApiClientService::getOffices() ?? new ProviderList();
149        $scopeList = ZmsApiClientService::getScopes() ?? new ScopeList();
150        $scopeMap = [];
151        foreach ($scopeList as $scope) {
152            $scopeProvider = $scope->getProvider();
153            if ($scopeProvider && $scopeProvider->id && $scopeProvider->source) {
154                $key = $scopeProvider->source . '_' . $scopeProvider->id;
155                $scopeMap[$key] = $scope;
156            }
157        }
158
159        $scopesProjectionList = [];
160        foreach ($providerList as $provider) {
161            $key = $provider->source . '_' . $provider->id;
162            if (isset($scopeMap[$key])) {
163                $matchingScope = $scopeMap[$key];
164                $scopesProjectionList[] = new ThinnedScope(
165                    id: (int) $matchingScope->id,
166                    provider: MapperService::providerToThinnedProvider($provider),
167                    shortName: (string) $matchingScope->getShortName(),
168                    emailFrom: (string) $matchingScope->getEmailFrom(),
169                    emailRequired: (bool) $matchingScope->getEmailRequired(),
170                    telephoneActivated: (bool) $matchingScope->getTelephoneActivated(),
171                    telephoneRequired: (bool) $matchingScope->getTelephoneRequired(),
172                    customTextfieldActivated: (bool) $matchingScope->getCustomTextfieldActivated(),
173                    customTextfieldRequired: (bool) $matchingScope->getCustomTextfieldRequired(),
174                    customTextfieldLabel: $matchingScope->getCustomTextfieldLabel(),
175                    customTextfield2Activated: (bool) $matchingScope->getCustomTextfield2Activated(),
176                    customTextfield2Required: (bool) $matchingScope->getCustomTextfield2Required(),
177                    customTextfield2Label: $matchingScope->getCustomTextfield2Label(),
178                    captchaActivatedRequired: (bool) $matchingScope->getCaptchaActivatedRequired(),
179                    infoForAppointment: $matchingScope->getInfoForAppointment(),
180                    infoForAllAppointments: $matchingScope->getInfoForAllAppointments(),
181                    slotsPerAppointment: ((string) $matchingScope->getSlotsPerAppointment() === '' ? null : (string) $matchingScope->getSlotsPerAppointment()),
182                    appointmentsPerMail: ((string) $matchingScope->getAppointmentsPerMail() === '' ? null : (string) $matchingScope->getAppointmentsPerMail()),
183                    whitelistedMails: ((string) $matchingScope->getWhitelistedMails() === '' ? null : (string) $matchingScope->getWhitelistedMails()),
184                    reservationDuration: (int) MapperService::extractReservationDuration($matchingScope),
185                    activationDuration: MapperService::extractActivationDuration($matchingScope),
186                    hint: ($matchingScope && trim((string) $matchingScope->getScopeHint()) !== '') ? (string) $matchingScope->getScopeHint() : null
187                );
188            }
189        }
190
191        $result = new ThinnedScopeList($scopesProjectionList);
192
193        self::setMappedCache($cacheKey, $result);
194
195        return $result;
196    }
197
198    public static function getServices(bool $showUnpublished = false): ServiceList|array
199    {
200        $cacheKey = self::CACHE_KEY_SERVICES . ($showUnpublished ? '_unpublished' : '');
201
202        if (\App::$cache && ($cachedData = \App::$cache->get($cacheKey))) {
203            return $cachedData;
204        }
205
206        $requestList = ZmsApiClientService::getServices() ?? new RequestList();
207        $services = [];
208        foreach ($requestList as $request) {
209            $additionalData = $request->getAdditionalData();
210            if (
211                !$showUnpublished
212                && isset($additionalData['public'])
213                && !$additionalData['public']
214            ) {
215                continue;
216            }
217
218            $services[] = new Service(id: (int) $request->getId(), name: $request->getName(), maxQuantity: $additionalData['maxQuantity'] ?? 1);
219        }
220
221        $result = new ServiceList($services);
222
223        self::setMappedCache($cacheKey, $result);
224
225        return $result;
226    }
227
228    public static function getServicesAndOffices(bool $showUnpublished = false): OfficeServiceAndRelationList|array
229    {
230        $cacheKey = self::CACHE_KEY_OFFICES_AND_SERVICES . ($showUnpublished ? '_unpublished' : '');
231
232        if (\App::$cache && ($cachedData = \App::$cache->get($cacheKey))) {
233            return $cachedData;
234        }
235
236        $providerList = ZmsApiClientService::getOffices() ?? new ProviderList();
237        $requestList = ZmsApiClientService::getServices() ?? new RequestList();
238        $relationList = ZmsApiClientService::getRequestRelationList() ?? new RequestRelationList();
239
240        $offices = MapperService::mapOfficesWithScope($providerList, $showUnpublished) ?? new OfficeList();
241        $services = MapperService::mapServicesWithCombinations(
242            $requestList,
243            $relationList,
244            $showUnpublished
245        ) ?? new ServiceList();
246        $relations = MapperService::mapRelations($relationList, $showUnpublished) ?? new OfficeServiceRelationList();
247
248        $result = new OfficeServiceAndRelationList($offices, $services, $relations);
249
250        self::setMappedCache($cacheKey, $result);
251
252        return $result;
253    }
254
255    /* Todo add method
256     * getCombinableServicesByIds
257     *
258     *
259     *
260     */
261
262    /**
263     * @SuppressWarnings(PHPMD.NPathComplexity)
264     */
265    public static function getScopeByOfficeId(int $officeId): ThinnedScope|array
266    {
267        $providerList = ZmsApiClientService::getOffices() ?? new ProviderList();
268        $selectedProvider = null;
269        foreach ($providerList as $provider) {
270            if ((int) $provider->id === (int) $officeId) {
271                $selectedProvider = $provider;
272                break;
273            }
274        }
275        if (!$selectedProvider) {
276            return ['errors' => [self::getError('officeNotFound')]];
277        }
278
279        $scopeSource = (string) ($selectedProvider->source ?? '');
280        if ($scopeSource === '') {
281            return ['errors' => [self::getError('scopeNotFound')]];
282        }
283
284        $matchingScope = ZmsApiClientService::getScopesByProviderId($scopeSource, (int) $officeId)
285            ->getIterator()
286            ->current();
287
288        if (!$matchingScope instanceof Scope) {
289            return ['errors' => [self::getError('scopeNotFound')]];
290        }
291
292        $providerMap = [];
293        foreach ($providerList as $prov) {
294            $key = ($prov->source ?? '') . '_' . $prov->id;
295            $providerMap[$key] = $prov;
296        }
297
298        $scopeProvider = $matchingScope->getProvider();
299        $providerKey = $scopeProvider ? (($scopeProvider->source ?? '') . '_' . $scopeProvider->id) : null;
300        $finalProvider = ($providerKey && isset($providerMap[$providerKey]))
301            ? $providerMap[$providerKey]
302            : $scopeProvider;
303        $result = [
304            'id' => $matchingScope->id,
305            'provider' => MapperService::providerToThinnedProvider($finalProvider) ?? null,
306            'shortName' => (string) $matchingScope->getShortName() ?? null,
307            'emailFrom' => (string) $matchingScope->getEmailFrom() ?? null,
308            'emailRequired' => (bool) $matchingScope->getEmailRequired() ?? null,
309            'telephoneActivated' => (bool) $matchingScope->getTelephoneActivated() ?? null,
310            'telephoneRequired' => (bool) $matchingScope->getTelephoneRequired() ?? null,
311            'customTextfieldActivated' => (bool) $matchingScope->getCustomTextfieldActivated() ?? null,
312            'customTextfieldRequired' => (bool) $matchingScope->getCustomTextfieldRequired() ?? null,
313            'customTextfieldLabel' => $matchingScope->getCustomTextfieldLabel() ?? null,
314            'customTextfield2Activated' => (bool) $matchingScope->getCustomTextfield2Activated() ?? null,
315            'customTextfield2Required' => (bool) $matchingScope->getCustomTextfield2Required() ?? null,
316            'customTextfield2Label' => $matchingScope->getCustomTextfield2Label() ?? null,
317            'captchaActivatedRequired' => (bool) $matchingScope->getCaptchaActivatedRequired() ?? null,
318            'infoForAppointment' => $matchingScope->getInfoForAppointment() ?? null,
319            'infoForAllAppointments' => $matchingScope->getInfoForAllAppointments() ?? null,
320            'slotsPerAppointment' => ((string) $matchingScope->getSlotsPerAppointment() === '' ? null : (string) $matchingScope->getSlotsPerAppointment()) ?? null,
321            'appointmentsPerMail' => ((string) $matchingScope->getAppointmentsPerMail() === '' ? null : (string) $matchingScope->getAppointmentsPerMail()) ?? null,
322            'whitelistedMails' => ((string) $matchingScope->getWhitelistedMails() === '' ? null : (string) $matchingScope->getWhitelistedMails()) ?? null,
323            'reservationDuration' => (int) MapperService::extractReservationDuration($matchingScope),
324            'activationDuration' => MapperService::extractActivationDuration($matchingScope),
325            'hint' => (trim((string) ($matchingScope->getScopeHint() ?? '')) === '') ? null : (string) $matchingScope->getScopeHint(),
326        ];
327        return new ThinnedScope(
328            id: (int) $result['id'],
329            provider: $result['provider'],
330            shortName: $result['shortName'],
331            emailFrom: $result['emailFrom'],
332            emailRequired: $result['emailRequired'],
333            telephoneActivated: $result['telephoneActivated'],
334            telephoneRequired: $result['telephoneRequired'],
335            customTextfieldActivated: $result['customTextfieldActivated'],
336            customTextfieldRequired: $result['customTextfieldRequired'],
337            customTextfieldLabel: $result['customTextfieldLabel'],
338            customTextfield2Activated: $result['customTextfield2Activated'],
339            customTextfield2Required: $result['customTextfield2Required'],
340            customTextfield2Label: $result['customTextfield2Label'],
341            captchaActivatedRequired: $result['captchaActivatedRequired'],
342            infoForAppointment: $result['infoForAppointment'],
343            infoForAllAppointments: $result['infoForAllAppointments'],
344            slotsPerAppointment: $result['slotsPerAppointment'],
345            appointmentsPerMail: $result['appointmentsPerMail'],
346            whitelistedMails: $result['whitelistedMails'],
347            reservationDuration: $result['reservationDuration'],
348            activationDuration: $result['activationDuration'],
349            hint: $result['hint']
350        );
351    }
352
353    /* Todo add method
354     * getOfficeById
355     *
356     *
357     *
358     */
359
360    /**
361     * @SuppressWarnings(PHPMD.NPathComplexity)
362     * @TODO: Extract providerMap mapping logic into MapperService
363     */
364    public static function getOfficeListByServiceId(int $serviceId, bool $showUnpublished = false): OfficeList|array
365    {
366        $cacheKey = self::CACHE_KEY_OFFICES_BY_SERVICE_PREFIX . $serviceId . ($showUnpublished ? '_unpublished' : '');
367
368        if (\App::$cache && ($cachedData = \App::$cache->get($cacheKey))) {
369            return $cachedData;
370        }
371
372        $providerList = ZmsApiClientService::getOffices() ?? new ProviderList();
373        $requestRelationList = ZmsApiClientService::getRequestRelationList() ?? new RequestRelationList();
374        $providerMap = [];
375        foreach ($providerList as $provider) {
376            if (!$showUnpublished && isset($provider->data['public']) && !(bool) $provider->data['public']) {
377                continue;
378            }
379
380            $providerMap[$provider->id] = $provider;
381        }
382
383        $offices = [];
384        foreach ($requestRelationList as $relation) {
385            if ((int) $relation->request->id === $serviceId) {
386                $providerId = $relation->provider->id;
387                if (!isset($providerMap[$providerId])) {
388                    continue;
389                }
390
391                $provider = $providerMap[$providerId];
392                $scope = null;
393                $scopeData = self::getScopeByOfficeId((int) $provider->id);
394                if ($scopeData instanceof ThinnedScope) {
395                    $scope = $scopeData;
396                }
397
398                $offices[] = new Office(
399                    id: (int) $provider->id,
400                    name: $provider->name,
401                    showAlternativeLocations: $provider->data['showAlternativeLocations'] ?? null,
402                    displayNameAlternatives: $provider->data['displayNameAlternatives'] ?? [],
403                    organization: $provider->data['organization'] ?? null,
404                    organizationUnit: $provider->data['organizationUnit'] ?? null,
405                    slotTimeInMinutes: $provider->data['slotTimeInMinutes'] ?? null,
406                    address: $provider->address ?? null,
407                    geo: $provider->geo ?? null,
408                    scope: $scope,
409                    maxSlotsPerAppointment: $scope ? ((string) $scope->getSlotsPerAppointment() === '' ? null : (string) $scope->getSlotsPerAppointment()) : null
410                );
411            }
412        }
413
414        $errors = ValidationService::validateOfficesNotFound($offices);
415        if (is_array($errors) && !empty($errors['errors'])) {
416            return $errors;
417        }
418
419        $result = new OfficeList($offices);
420
421        self::setMappedCache($cacheKey, $result);
422
423        return $result;
424    }
425
426    /**
427     * @SuppressWarnings(PHPMD.NPathComplexity)
428     */
429    public static function getScopeById(?int $scopeId): ThinnedScope|array
430    {
431        $scopeList = ZmsApiClientService::getScopes() ?? new ScopeList();
432        $providerList = ZmsApiClientService::getOffices() ?? new ProviderList();
433        $matchingScope = null;
434        foreach ($scopeList as $scope) {
435            if ((int) $scope->id === (int) $scopeId) {
436                $matchingScope = $scope;
437                break;
438            }
439        }
440
441        $tempScopeList = new ScopeList();
442        if ($matchingScope !== null) {
443            $tempScopeList->addEntity($matchingScope);
444        }
445        $errors = ValidationService::validateScopesNotFound($tempScopeList);
446        if (is_array($errors) && !empty($errors['errors'])) {
447            return $errors;
448        }
449
450        $providerMap = [];
451        foreach ($providerList as $provider) {
452            $key = $provider->source . '_' . $provider->id;
453            $providerMap[$key] = $provider;
454        }
455
456        $scopeProvider = $matchingScope->getProvider();
457        $providerKey = $scopeProvider ? ($scopeProvider->source . '_' . $scopeProvider->id) : null;
458        $matchingProv = ($providerKey && isset($providerMap[$providerKey]))
459            ? $providerMap[$providerKey]
460            : $scopeProvider;
461        return new ThinnedScope(
462            id: (int) $matchingScope->id,
463            provider: MapperService::providerToThinnedProvider($matchingProv),
464            shortName: (string) $matchingScope->getShortName() ?? null,
465            emailFrom: (string) $matchingScope->getEmailFrom() ?? null,
466            emailRequired: (bool) $matchingScope->getEmailRequired() ?? null,
467            telephoneActivated: (bool) $matchingScope->getTelephoneActivated() ?? null,
468            telephoneRequired: (bool) $matchingScope->getTelephoneRequired() ?? null,
469            customTextfieldActivated: (bool) $matchingScope->getCustomTextfieldActivated() ?? null,
470            customTextfieldRequired: (bool) $matchingScope->getCustomTextfieldRequired() ?? null,
471            customTextfieldLabel: $matchingScope->getCustomTextfieldLabel() ?? null,
472            customTextfield2Activated: (bool) $matchingScope->getCustomTextfield2Activated() ?? null,
473            customTextfield2Required: (bool) $matchingScope->getCustomTextfield2Required() ?? null,
474            customTextfield2Label: $matchingScope->getCustomTextfield2Label() ?? null,
475            captchaActivatedRequired: (bool) $matchingScope->getCaptchaActivatedRequired() ?? null,
476            infoForAppointment: $matchingScope->getInfoForAppointment() ?? null,
477            infoForAllAppointments: $matchingScope->getInfoForAllAppointments() ?? null,
478            slotsPerAppointment: ((string) $matchingScope->getSlotsPerAppointment() === '' ? null : (string) $matchingScope->getSlotsPerAppointment()) ?? null,
479            appointmentsPerMail: ((string) $matchingScope->getAppointmentsPerMail() === '' ? null : (string) $matchingScope->getAppointmentsPerMail()) ?? null,
480            whitelistedMails: ((string) $matchingScope->getWhitelistedMails() === '' ? null : (string) $matchingScope->getWhitelistedMails()) ?? null,
481            reservationDuration: (int) MapperService::extractReservationDuration($matchingScope),
482            activationDuration: MapperService::extractActivationDuration($matchingScope),
483            hint: ((string) $matchingScope->getScopeHint() === '' ? null : (string) $matchingScope->getScopeHint()) ?? null
484        );
485    }
486
487    public static function getServicesByOfficeId(int $officeId, bool $showUnpublished = false): ServiceList|array
488    {
489        $cacheKey = self::CACHE_KEY_SERVICES_BY_OFFICE_PREFIX . $officeId . ($showUnpublished ? '_unpublished' : '');
490
491        if (\App::$cache && ($cachedData = \App::$cache->get($cacheKey))) {
492            return $cachedData;
493        }
494
495        $requestList = ZmsApiClientService::getServices() ?? new RequestList();
496        $requestRelationList = ZmsApiClientService::getRequestRelationList() ?? new RequestRelationList();
497        $requestMap = [];
498        foreach ($requestList as $request) {
499            $additionalData = $request->getAdditionalData();
500            if (
501                !$showUnpublished
502                && isset($additionalData['public'])
503                && !$additionalData['public']
504            ) {
505                continue;
506            }
507
508            $requestMap[$request->id] = $request;
509        }
510
511        $services = [];
512        foreach ($requestRelationList as $relation) {
513            if ((int) $relation->provider->id === $officeId) {
514                $requestId = $relation->request->id;
515                if (isset($requestMap[$requestId])) {
516                    $request = $requestMap[$requestId];
517                    $services[] = new Service(id: (int) $request->id, name: $request->name, maxQuantity: $request->getAdditionalData()['maxQuantity'] ?? 1);
518                }
519            }
520        }
521
522        $errors = ValidationService::validateServicesNotFound($services);
523        if (is_array($errors) && !empty($errors['errors'])) {
524            return $errors;
525        }
526
527        $result = new ServiceList($services);
528
529        self::setMappedCache($cacheKey, $result);
530
531        return $result;
532    }
533
534    /**
535     * @SuppressWarnings(PHPMD.NPathComplexity)
536     */
537    public static function getBookableFreeDays(
538        array $officeIds,
539        array $serviceIds,
540        array $serviceCounts,
541        string $startDate,
542        string $endDate,
543        ?bool $groupByOffice = false
544    ): AvailableDays|AvailableDaysByOffice|array {
545        $firstDay = DateTimeFormatHelper::getInternalDateFromISO($startDate);
546        $lastDay  = DateTimeFormatHelper::getInternalDateFromISO($endDate);
547
548        $providerList = ZmsApiClientService::getOffices()  ?? new ProviderList();
549        $requestList  = ZmsApiClientService::getServices() ?? new RequestList();
550
551        $providerSource = [];
552        foreach ($providerList as $p) {
553            $providerSource[(string)$p->id] = (string)($p->source ?? '');
554        }
555
556        $requestSource = [];
557        foreach ($requestList as $r) {
558            $requestSource[(string)$r->id] = (string)($r->source ?? '');
559        }
560
561        $services = [];
562        foreach ($serviceIds as $i => $serviceId) {
563            $sid = (string)$serviceId;
564            $src = $requestSource[$sid] ?? null;
565            if (!$src) {
566                return ['errors' => [['message' => 'Unknown service source for ID ' . $sid]]];
567            }
568            $services[] = [
569                'id'        => $serviceId,
570                'source'    => $src,
571                'slotCount' => (int)($serviceCounts[$i] ?? 1),
572            ];
573        }
574
575        $providers = [];
576        foreach ($officeIds as $officeId) {
577            $oid = (string)$officeId;
578            $src = $providerSource[$oid] ?? null;
579            if (!$src) {
580                return ['errors' => [['message' => 'Unknown provider source for ID ' . $oid]]];
581            }
582            $providers[] = [
583                'id'     => $officeId,
584                'source' => $src,
585            ];
586        }
587
588        $freeDays = ZmsApiClientService::getFreeDays(
589            new ProviderList($providers),
590            new RequestList($services),
591            $firstDay,
592            $lastDay
593        ) ?? new Calendar();
594
595        $daysCollection  = $freeDays->days;
596        $formattedDays   = [];
597        $scopeToProvider = [];
598
599        foreach ($freeDays->scopes as $scope) {
600            $scopeToProvider[$scope['id']] = $scope['provider']['id'];
601        }
602
603        foreach ($daysCollection as $day) {
604            $formattedDays[] = [
605                'time'        => sprintf('%04d-%02d-%02d', $day->year, $day->month, $day->day),
606                'providerIDs' => isset($day->scopeIDs)
607                    ? implode(',', array_map(fn($scopeId) => $scopeToProvider[$scopeId], explode(',', $day->scopeIDs)))
608                    : ''
609            ];
610        }
611
612        $errors = ValidationService::validateAppointmentDaysNotFound($formattedDays);
613        if (is_array($errors) && !empty($errors['errors'])) {
614            return $errors;
615        }
616
617        return $groupByOffice
618            ? new AvailableDaysByOffice($formattedDays)
619            : new AvailableDays(array_column($formattedDays, 'time'));
620    }
621
622    public static function getFreeAppointments(int $officeId, array $serviceIds, array $serviceCounts, array $date): ProcessList|array
623    {
624        $providerList = ZmsApiClientService::getOffices()  ?? new ProviderList();
625        $requestList  = ZmsApiClientService::getServices() ?? new RequestList();
626
627        $providerSource = [];
628        foreach ($providerList as $p) {
629            $providerSource[(string)$p->id] = (string)($p->source ?? '');
630        }
631        $requestSource  = [];
632        foreach ($requestList as $r) {
633            $requestSource[(string)$r->id] = (string)($r->source ?? '');
634        }
635
636        $oid = (string)$officeId;
637        $provSrc = $providerSource[$oid] ?? null;
638        if (!$provSrc) {
639            return ['errors' => [['message' => 'Unknown provider source for ID ' . $oid]]];
640        }
641
642        $office = ['id' => $officeId, 'source' => $provSrc];
643
644        $requests = [];
645        foreach ($serviceIds as $id => $serviceId) {
646            $sid   = (string)$serviceId;
647            $reqSrc = $requestSource[$sid] ?? null;
648            if (!$reqSrc) {
649                return ['errors' => [['message' => 'Unknown service source for ID ' . $sid]]];
650            }
651            $count = (int)($serviceCounts[$id] ?? 1);
652            for ($k = 0; $k < $count; $k++) {
653                $requests[] = ['id' => $serviceId, 'source' => $reqSrc];
654            }
655        }
656
657        return ZmsApiClientService::getFreeTimeslots(
658            new ProviderList([$office]),
659            new RequestList($requests),
660            $date,
661            $date
662        );
663    }
664
665    /**
666     * @SuppressWarnings(PHPMD.CyclomaticComplexity)
667     */
668    private static function processFreeSlots(ProcessList $freeSlots, bool $groupByOffice = false): array
669    {
670        $errors = ValidationService::validateGetProcessFreeSlots($freeSlots);
671        if (is_array($errors) && !empty($errors['errors'])) {
672            return $errors;
673        }
674
675        $currentTimestamp = time();
676        if ($groupByOffice) {
677            $grouped = [];
678            foreach ($freeSlots as $slot) {
679                $officeId = (string)($slot->scope->provider->id ?? '');
680                if (!isset($grouped[$officeId])) {
681                    $grouped[$officeId] = [];
682                }
683                if (isset($slot->appointments) && is_iterable($slot->appointments)) {
684                    foreach ($slot->appointments as $appointment) {
685                        if (isset($appointment->date)) {
686                            $timestamp = (int) $appointment->date;
687                            if ($timestamp > $currentTimestamp) {
688                                $grouped[$officeId][] = $timestamp;
689                            }
690                        }
691                    }
692                }
693            }
694            // Sort each office's appointments
695            foreach ($grouped as &$arr) {
696                sort($arr);
697            }
698            unset($arr);
699            // Optionally validate grouped timestamps here if needed
700            return $grouped;
701        } else {
702            $timestamps = [];
703            foreach ($freeSlots as $slot) {
704                if (isset($slot->appointments) && is_iterable($slot->appointments)) {
705                    foreach ($slot->appointments as $appointment) {
706                        if (isset($appointment->date)) {
707                            $timestamp = (int) $appointment->date;
708                            if ($timestamp > $currentTimestamp) {
709                                $timestamps[] = $timestamp;
710                            }
711                        }
712                    }
713                }
714            }
715            sort($timestamps);
716
717            $errors = ValidationService::validateGetProcessByIdTimestamps($timestamps);
718            if (is_array($errors) && !empty($errors['errors'])) {
719                return $errors;
720            }
721
722            return $timestamps;
723        }
724    }
725
726    public static function getAvailableAppointments(
727        string $date,
728        array $officeIds,
729        array $serviceIds,
730        array $serviceCounts,
731        ?bool $groupByOffice = false
732    ): AvailableAppointments|AvailableAppointmentsByOffice|array {
733        $providerList = ZmsApiClientService::getOffices()  ?? new ProviderList();
734        $requestList  = ZmsApiClientService::getServices() ?? new RequestList();
735
736        $providerSource = [];
737        foreach ($providerList as $p) {
738            $providerSource[(string)$p->id] = (string)($p->source ?? '');
739        }
740        $requestSource  = [];
741        foreach ($requestList as $r) {
742            $requestSource[(string)$r->id] = (string)($r->source ?? '');
743        }
744
745        $requests = [];
746        foreach ($serviceIds as $id => $serviceId) {
747            $sid = (string)$serviceId;
748            $src = $requestSource[$sid] ?? null;
749            if (!$src) {
750                return ['errors' => [['message' => 'Unknown service source for ID ' . $sid]]];
751            }
752            $count = (int)($serviceCounts[$id] ?? 1);
753            for ($k = 0; $k < $count; $k++) {
754                $requests[] = ['id' => $serviceId, 'source' => $src];
755            }
756        }
757
758        $providers = [];
759        foreach ($officeIds as $officeId) {
760            $oid = (string)$officeId;
761            $src = $providerSource[$oid] ?? null;
762            if (!$src) {
763                return ['errors' => [['message' => 'Unknown provider source for ID ' . $oid]]];
764            }
765            $providers[] = ['id' => $officeId, 'source' => $src];
766        }
767
768        $freeSlots = ZmsApiClientService::getFreeTimeslots(
769            new ProviderList($providers),
770            new RequestList($requests),
771            DateTimeFormatHelper::getInternalDateFromISO($date),
772            DateTimeFormatHelper::getInternalDateFromISO($date)
773        ) ?? new ProcessList();
774
775        $result = self::processFreeSlots($freeSlots, $groupByOffice);
776        if (isset($result['errors']) && !empty($result['errors'])) {
777            return $result;
778        }
779
780        return $groupByOffice
781            ? new AvailableAppointmentsByOffice($result)
782            : new AvailableAppointments($result);
783    }
784
785    public static function reserveTimeslot(Process $appointmentProcess, array $serviceIds, array $serviceCounts): ThinnedProcess|array
786    {
787        $errors = ValidationService::validateServiceArrays($serviceIds, $serviceCounts);
788        if (!empty($errors)) {
789            return $errors;
790        }
791        $process = ZmsApiClientService::reserveTimeslot($appointmentProcess, $serviceIds, $serviceCounts);
792        return MapperService::processToThinnedProcess($process);
793    }
794
795    public static function getProcessById(?int $processId, ?string $authKey, ?AuthenticatedUser $user): Process
796    {
797        // AuthKey check needs to be first
798        if (!is_null($authKey)) {
799            return ZmsApiClientService::getProcessById($processId, $authKey);
800        } elseif (!is_null($user)) {
801            $externalUserId = $user->getExternalUserId();
802            $process = ZmsApiClientService::getProcessByIdAuthenticated($processId);
803            if ($externalUserId !== $process->getExternalUserId()) {
804                throw new UnauthorizedException();
805            }
806            return $process;
807        } else {
808            throw new UnauthorizedException();
809        }
810    }
811
812    public static function getThinnedProcessById(int $processId, ?string $authKey, ?AuthenticatedUser $user): ThinnedProcess|array
813    {
814        $process = self::getProcessById($processId, $authKey, $user);
815        $errors = ValidationService::validateGetProcessNotFound($process);
816        if (is_array($errors) && !empty($errors['errors'])) {
817            return $errors;
818        }
819        $thinnedProcess = MapperService::processToThinnedProcess($process);
820
821        $providerList = ZmsApiClientService::getOffices() ?? new ProviderList();
822        $providerMap = [];
823        foreach ($providerList as $provider) {
824            $key = $provider->getSource() . '_' . $provider->id;
825            $providerMap[$key] = $provider;
826        }
827
828        $thinnedScope = null;
829        if ($process->scope instanceof Scope) {
830            $scopeProvider = $process->scope->getProvider();
831            $providerKey = $scopeProvider ? ($scopeProvider->getSource() . '_' . $scopeProvider->id) : null;
832            $matchingProvider = $providerKey && isset($providerMap[$providerKey]) ? $providerMap[$providerKey] : $scopeProvider;
833            $thinnedProvider = MapperService::providerToThinnedProvider($matchingProvider);
834            $thinnedScope = new ThinnedScope(
835                id: (int) $process->scope->id,
836                provider: $thinnedProvider,
837                shortName: (string) $process->scope->getShortName() ?? null,
838                emailFrom: (string) $process->scope->getEmailFrom() ?? null,
839                emailRequired: (bool) $process->scope->getEmailRequired() ?? false,
840                telephoneActivated: (bool) $process->scope->getTelephoneActivated() ?? false,
841                telephoneRequired: (bool) $process->scope->getTelephoneRequired() ?? false,
842                customTextfieldActivated: (bool) $process->scope->getCustomTextfieldActivated() ?? false,
843                customTextfieldRequired: (bool) $process->scope->getCustomTextfieldRequired() ?? false,
844                customTextfieldLabel: $process->scope->getCustomTextfieldLabel() ?? null,
845                customTextfield2Activated: (bool) $process->scope->getCustomTextfield2Activated() ?? false,
846                customTextfield2Required: (bool) $process->scope->getCustomTextfield2Required() ?? false,
847                customTextfield2Label: $process->scope->getCustomTextfield2Label() ?? null,
848                captchaActivatedRequired: (bool) $process->scope->getCaptchaActivatedRequired() ?? false,
849                infoForAppointment: $process->scope->getInfoForAppointment() ?? null,
850                infoForAllAppointments: $process->scope->getInfoForAllAppointments() ?? null,
851                slotsPerAppointment: ((string) $process->scope->getSlotsPerAppointment() === '' ? null : (string) $process->scope->getSlotsPerAppointment()) ?? null,
852                appointmentsPerMail: ((string) $process->scope->getAppointmentsPerMail() === '' ? null : (string) $process->scope->getAppointmentsPerMail()) ?? null,
853                whitelistedMails: ((string) $process->scope->getWhitelistedMails() === '' ? null : (string) $process->scope->getWhitelistedMails()) ?? null,
854                reservationDuration: (int) MapperService::extractReservationDuration($process->scope),
855                activationDuration: MapperService::extractActivationDuration($process->scope),
856                hint: ((string) $process->scope->getScopeHint() === '' ? null : (string) $process->scope->getScopeHint()) ?? null
857            );
858        }
859
860        $thinnedProcess->scope = $thinnedScope;
861        return $thinnedProcess;
862    }
863
864    public static function updateClientData(Process $reservedProcess): Process|array
865    {
866        $clientUpdateResult = ZmsApiClientService::submitClientData($reservedProcess);
867        if (isset($clientUpdateResult['error'])) {
868            return $clientUpdateResult;
869        }
870        return $clientUpdateResult;
871    }
872
873    public static function preconfirmAppointment(Process $reservedProcess): Process|array
874    {
875        $clientUpdateResult = ZmsApiClientService::preconfirmProcess($reservedProcess);
876        if (isset($clientUpdateResult['error'])) {
877            return $clientUpdateResult;
878        }
879        return $clientUpdateResult;
880    }
881
882    public static function confirmAppointment(Process $preconfirmedProcess): Process|array
883    {
884        $clientUpdateResult = ZmsApiClientService::confirmProcess($preconfirmedProcess);
885        if (isset($clientUpdateResult['error'])) {
886            return $clientUpdateResult;
887        }
888        return $clientUpdateResult;
889    }
890
891    public static function cancelAppointment(Process $confirmedProcess): Process|array
892    {
893        $clientUpdateResult = ZmsApiClientService::cancelAppointment($confirmedProcess);
894        if (isset($clientUpdateResult['error'])) {
895            return $clientUpdateResult;
896        }
897        return $clientUpdateResult;
898    }
899
900    public static function sendPreconfirmationEmail(Process $reservedProcess): Process|array
901    {
902        $clientUpdateResult = ZmsApiClientService::sendPreconfirmationEmail($reservedProcess);
903        if (isset($clientUpdateResult['error'])) {
904            return $clientUpdateResult;
905        }
906        return $clientUpdateResult;
907    }
908
909    public static function sendConfirmationEmail(Process $preconfirmedProcess): Process|array
910    {
911        $clientUpdateResult = ZmsApiClientService::sendConfirmationEmail($preconfirmedProcess);
912        if (isset($clientUpdateResult['error'])) {
913            return $clientUpdateResult;
914        }
915        return $clientUpdateResult;
916    }
917
918    public static function sendCancellationEmail(Process $confirmedProcess): Process|array
919    {
920        $clientUpdateResult = ZmsApiClientService::sendCancellationEmail($confirmedProcess);
921        if (isset($clientUpdateResult['error'])) {
922            return $clientUpdateResult;
923        }
924        return $clientUpdateResult;
925    }
926
927    public static function getAppointmentsByExternalUserId(string $externalUserId, ?int $filterId = null, ?string $status = null): ProcessList
928    {
929        return ZmsApiClientService::getProcessesByExternalUserId($externalUserId, $filterId, $status);
930    }
931}