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