Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 227
0.00% covered (danger)
0.00%
0 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
Munich
0.00% covered (danger)
0.00%
0 / 227
0.00% covered (danger)
0.00%
0 / 13
3540
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
20
 fetchLatestExport
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
42
 transformServices
0.00% covered (danger)
0.00%
0 / 58
0.00% covered (danger)
0.00%
0 / 1
132
 transformLocations
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 indexServicesByIds
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 processLocation
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 buildLocationMetadata
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
12
 applyLocationRules
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 processServiceReferences
0.00% covered (danger)
0.00%
0 / 56
0.00% covered (danger)
0.00%
0 / 1
110
 calculateSlotTimes
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 buildLocationResponse
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 getServiceCombinations
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 getSlotTime
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3namespace BO\Zmsdldb\Transformers;
4
5use Httpful\Request;
6use Psr\Log\LoggerInterface;
7
8/**
9 * Transform Munich's SADB export format to Berlin-compatible format
10 */
11class Munich
12{
13    const EXCLUSIVE_LOCATIONS = [
14        // Standesamt München - registry office locations (exclusive, don't show alternatives)
15        10470, 10351880, 10351882, 1064292, 10351883, 54260, 1061927,
16        10295168, 10469, 102365,
17        // Standesamt München-Pasing
18        10351880, 10351882, 10351883,
19        // Sozialbürgerhaus locations
20        103666, 103633, 101905,
21    ];
22
23    const LOCATION_PRIO_BY_DISPLAY_NAME = [
24        'Bürgerbüro Ruppertstraße' => 100,
25        'Bürgerbüro Orleansplatz' => 90,
26        'Bürgerbüro Pasing' => 80,
27        'Bürgerbüro Riesenfeldstraße' => 70,
28        'Bürgerbüro Forstenrieder Allee' => 60,
29        'Bürgerbüro Leonrodstraße' => 50,
30        'Feuerwache 1 - Hauptfeuerwache im Zentrum' => 10,
31        'Feuerwache 2 - Sendling' => 9,
32        'Feuerwache 3 - Westend' => 8,
33        'Feuerwache 4 - Schwabing' => 7,
34        'Feuerwache 5 - Ramersdorf' => 6,
35        'Feuerwache 6 - Pasing' => 5,
36        'Feuerwache 7 - Milbertshofen' => 4,
37        'Feuerwache 8 - Föhring' => 3,
38        'Feuerwache 9 - Neuperlach' => 2,
39        'Feuerwache 10 - Riem / Neue Messe' => 1
40    ];
41
42    const DONT_SHOW_LOCATION_BY_SERVICES = [
43        [
44            "locations" => [10489], // Bürgerbüro Ruppertstraße
45            "services" => [1063453, 1063441, 1080582] // Reisepass, Personalausweis, Vorläufiger Reisepass
46        ]
47    ];
48
49    const DONT_SHOW_SERVICE_ON_START_PAGE = [
50        10396802, // Anmeldung einer Eheschließung mit Auslandsbezug
51        1063648, // Anmeldung einer Eheschließung ohne Auslandsbezug
52        1063731, // Kirchenaustritt
53        1071907, // Einbürgerung
54    ];
55
56    const SERVICE_COMBINATIONS = [
57        //BB
58        [10295182],
59        [10242339, 1063475, 1063441, 1063453, 10308996, 10224136, 10225205, 10225181, 1063426, 1063428, 10306925, 10225119, 1080843, 1076889, 1078273, 1080582, 10225197, 10224132],
60        [10225205, 1063441, 1063453, 10224132, 10242339, 10308996, 10224136, 10225181, 1063426, 1063428, 10306925, 10225119, 1080843, 1076889, 1078273, 1080582, 10225197, 1063475],
61        [10225181, 1063428, 1063426, 1063475, 10242339, 10308996, 10224136, 10225205,  10306925, 10225119, 1080843, 1063441, 1063453, 1076889, 1078273, 1080582, 10225197, 10224132],
62        [1063426, 1063428, 1063475, 1063441, 10242339, 10308996, 10224136, 10225205, 10225181,  10306925, 10225119, 1080843, 1063453, 1076889, 1078273, 1080582, 10225197, 10224132],
63        [10306925, 1063441, 1063453, 1063475, 10242339, 10308996, 10224136, 10225205, 10225181, 1063426,  1063428, 10225119, 1080843, 1076889, 1078273, 1080582, 10225197, 10224132],
64        [1063428, 1063426, 10225181, 1063441, 10242339, 10308996, 10224136, 10225205, 10225189, 10306925, 10225119, 1080843, 1063453, 1076889, 1078273, 1080582, 10225197, 1063475, 10224132],
65        [10225119, 1063475, 10242339, 1063453, 10308996, 10224136, 10225205, 10225181, 1063426,  1063428, 10306925, 1080843,  1063441, 1076889, 1078273, 1080582, 10225197,  10224132],
66        [1063565, 10225129, 1064033, 10297413, 1063576],
67        [10225129, 1063565, 1064033, 10297413, 1063576],
68        [1064033, 1063565, 10225129, 10297413, 1063576],
69        [1080843, 1063475, 10224132, 10308996, 10224136, 10242339, 10225205, 10225181, 1063426,  1063428, 10306925, 10225119,  1063441, 1063453, 1076889, 1078273, 1080582, 10225197, 1063486],
70        [10297413, 1063565, 10225129, 1064033, 1063576],
71        [1063576, 1063565, 10225129, 1064033, 10297413],
72        [1063441, 10225205, 1063453, 1063475, 10242339, 10308996, 10224136,  10225181, 1063426,  1063428, 10306925, 10225119, 1080843, 1076889, 1078273, 1080582, 10225197,  10224132],
73        [10224136, 10242339, 10308996, 10225205, 10225181, 1063426, 1063428, 10306925, 10225119, 1080843,  1063441, 1063453, 1076889, 1078273, 1080582, 10225197,  1063475, 10224132],
74        [1063453, 10225205, 1063441, 1063475, 10242339, 10308996, 10224136, 10225181, 1063426,  1063428, 10306925, 10225119, 1080843, 1076889, 1078273, 1080582, 10225197,  10224132],
75        [1076889, 1063441, 1063453, 1063475, 10242339, 10308996, 10224136, 10225205, 10225181, 1063426,  1063428, 10306925, 10225119, 1080843, 1078273, 1080582, 10225197,  10224132],
76        [1078273, 1063441, 1063453, 1076889, 10242339, 10308996, 10224136, 10225205, 10225181, 1063426,  1063428, 10306925, 10225119, 1080843,  1080582, 10225197, 1063475, 10224132],
77        [1080582, 1063441, 1063453, 1063475, 10242339, 10308996, 10224136, 10225205, 10225181, 1063426,  1063428, 10306925, 10225119, 1080843,  1076889, 1078273, 10225197,  10224132],
78        [10225197, 1063441, 1063453, 10225119, 10242339, 10308996, 10224136, 10225205, 10225181, 1063426, 1063428, 10306925, 1080843, 1076889, 1078273, 1080582, 1063475, 10224132],
79        [1063475, 1063441, 1063453, 10224132, 10242339, 10308996, 10224136, 10225205, 10225181, 1063426,  1063428, 10306925, 10225119, 1080843, 1076889, 1078273, 1080582, 10225197, 1063486],
80        [10224132,10225205, 1063441, 1063453, 10242339, 10308996, 10224136, 10225181, 1063426,  1063428, 10306925, 10225119, 1080843, 1076889, 1078273, 1080582, 10225197, 1063486, 1063475],
81        //KFZ
82        [1064076, 10392406, 10391604],
83        [10392406, 1064076, 10391604],
84        [10391604, 1064076, 10392406],
85        [10115737, 1064268, 1064345, 1064374],
86        [1064268, 10115737, 1064345, 1064374],
87        [1064345, 10115737, 1064268, 1064374],
88        [1064374, 10115737, 1064268, 1064345],
89        [1064121, 1064354, 1064308, 10387573, 1064275, 1064130, 1063425, 1080502, 10387564, 1064271, 1064342, 1064333, 1071959, 1064311, 1064323, 1063424, 1064314, 10391602],
90        [1063425, 1064121, 1064354, 1064308, 10387573, 1064275, 1064342, 1071959, 1064311, 10387564, 1064323, 1064130, 1064333, 1063424, 1064314, 1064271, 10391602, 1080502],
91        [1064342, 1064121, 1064354, 1064308, 10387573, 1064275, 1080502, 1064323, 1063425, 1071959, 1064311, 10387564, 1064130, 1064333, 1063424, 1064314, 1064271, 10391602],
92        [1064354, 1064121, 1064308, 10387573, 1064275, 1064314, 1063424, 1064333, 1064323, 1064130, 1064271, 1064342, 10391602, 1063425, 10387564, 1071959, 1064311, 1080502],
93        [1064308, 1064121, 1064354, 10387573, 1064275, 1064130, 1064333, 1063425, 10391602, 10387564, 1064323, 1080502, 1064342, 1064271, 1071959, 1064311, 1063424, 1064314],
94        [1071959, 1064121, 1064354, 1064308, 10387573, 1064275, 1063425, 1064342, 1064311, 10387564, 1064323, 1064130, 1064333, 1063424, 1064314, 1064271, 10391602, 1080502],
95        [1064311, 1064121, 1064354, 1064308, 10387573, 1064275, 1063425, 1064342, 1071959, 10387564, 1064323, 1064130, 1064333, 1063424, 1064314, 1064271, 10391602, 1080502],
96        [10387564, 1064121, 1064354, 1064308, 10387573, 1064275, 1063425, 1064342, 1071959, 1064311, 1064323, 1064130, 1064333, 1063424, 1064314, 1064271, 10391602, 1080502],
97        [10387573, 1064121, 1064354, 1064308, 1064275, 1063425, 1064342, 1071959, 1064311, 10387564, 1064323, 1064130, 1064333, 1063424, 1064314, 1064271, 10391602, 1080502],
98        [1064323, 1064121, 1064354, 1064308, 10387573, 1064275, 1080502, 1063425, 10387564, 1064342, 1071959, 1064311, 1064130, 1064333, 1063424, 1064314, 1064271, 10391602],
99        [1064130, 1064121, 1064354, 1064308, 10387573, 1064275, 1063425, 1064333, 1080502, 1064323, 1064342, 1071959, 1064311, 10387564, 1063424, 1064314, 1064271, 10391602],
100        [1064333, 1064121, 1064354, 1064308, 10387573, 1064275, 1063425, 1080502, 1064323, 1064342, 1071959, 1064311, 10387564, 1064130, 1063424, 1064314, 1064271, 10391602],
101        [1063424, 1064121, 1064354, 1064308, 10387573, 1064275, 1064314, 1064130, 1064333, 1063425, 10391602, 1064271, 1080502, 1064323, 1064342, 1071959, 1064311, 10387564],
102        [1064314, 1064121, 1064354, 1064308, 10387573, 1064275, 1064130, 1064333, 1063425, 1064323, 10391602, 1064342, 1064271, 1080502, 10387564, 1071959, 1064311, 1063424],
103        [1064271, 1064121, 1064354, 1064308, 10387573, 1064275, 1063425, 1064342, 1071959, 1064311, 10387564, 1064323, 1064130, 1064333, 1063424, 1064314, 10391602, 1080502],
104        [1064275, 1064121, 1064354, 1064308, 10387573, 1064271, 1063425, 1064342, 1071959, 1064311, 10387564, 1064323, 1064130, 1064333, 1063424, 1064314, 10391602, 1080502],
105        [10391602, 1064121, 1064354, 1064308, 10387573, 1064275, 1064323, 1064333, 10387564, 1063425, 1064342, 1071959, 1064311, 1064130, 1063424, 1064314, 1064271, 1080502],
106        [1080502, 1064121, 1064354, 1064308, 10387573, 1064275, 10387564, 1063425, 1064342, 1071959, 1064311, 1064323, 1064130, 1064333, 1063424, 1064314, 1064271, 10391602],
107        // Gewerbeamt
108        [10300817, 10300814],
109        [10300814, 10300817],
110        [10300814, 10300808],
111        [10300808, 10300814],
112        // Fuehrerscheinstelle
113        [1064361, 10383549],
114        [10383549, 1064361],
115    ];
116
117    protected $publicUrl;
118    protected $logger;
119
120    public function __construct(string $publicUrl = '', ?LoggerInterface $logger = null)
121    {
122        if ($publicUrl && !filter_var($publicUrl, FILTER_VALIDATE_URL)) {
123            throw new \InvalidArgumentException('Invalid public URL provided');
124        }
125        $this->publicUrl = $publicUrl ?: 'https://stadt.muenchen.de/en/buergerservice/terminvereinbarung.html/#';
126        $this->logger = $logger;
127    }
128
129    /**
130     * Fetch latest Munich SADB export and return the data
131     */
132    public function fetchLatestExport(string $indexUrl): array
133    {
134        try {
135            $response = Request::get($indexUrl)->timeout(15)->send();
136            if ((int)($response->code ?? 0) !== 200) {
137                throw new \RuntimeException("Index fetch failed with status {$response->code}");
138            }
139            $content = $response->raw_body;
140
141            // Extract JSON export URLs using regex and pick the last one (latest)
142            if (!preg_match_all('#https://[^"\'\'\s<>]+\\.json#i', $content, $matches) || empty($matches[0])) {
143                throw new \RuntimeException('No JSON export links found on index page');
144            }
145            $latestUrl = end($matches[0]);
146
147            $this->logger?->info('Fetching Munich export', ['url' => $latestUrl]);
148
149            $exportResponse = Request::get($latestUrl)->timeout(30)->send();
150            if ((int)($exportResponse->code ?? 0) !== 200) {
151                throw new \RuntimeException("Export fetch failed with status {$exportResponse->code}");
152            }
153
154            return json_decode($exportResponse->raw_body, true, 512, JSON_THROW_ON_ERROR);
155        } catch (\Throwable $e) {
156            $this->logger?->error('Failed to fetch Munich export', ['error' => $e->getMessage(), 'url' => $indexUrl]);
157            throw $e;
158        }
159    }
160
161    /**
162     * Transform Munich SADB format to Berlin-compatible services format
163     */
164    public function transformServices(array $data): array
165    {
166        $timestamp = date('Y-m-d\TH:i:s');
167        $mappedServices = [];
168
169        foreach ($data['services'] ?? [] as $service) {
170            $mappedService = [
171                'id' => $service['id'],
172                'name' => $service['name'],
173                'description' => $service['description'] ?? '',
174                'meta' => [
175                    'lastupdate' => $timestamp,
176                    'url' => $this->publicUrl . "/services/{serviceId}",
177                    'locale' => 'de',
178                    'keywords' => implode(', ', $service['synonyms'] ?? []),
179                    'translated' => true,
180                    'hash' => '',
181                    'id' => $service['id']
182                ],
183                'authorities' => [['id' => '1', 'name' => 'Stadtverwaltung München', 'webinfo' => 'https://muenchen.de']],
184                'locations' => [],
185                'leika' => $service['leikaId'] ?? null,
186                'public' => true,
187                'links' => [],
188                'forms' => [],
189                'appointment' => [
190                    'link' => $this->publicUrl . "/services/{serviceId}"
191                ],
192                'maxQuantity' => 1,
193                'showOnStartPage' => true,
194                'duration' => 30, // Default duration
195            ];
196
197            // Get combinable services
198            $combinableServices = $this->getServiceCombinations($service['id']);
199            if ($combinableServices) {
200                $mappedService['combinable'] = $combinableServices;
201            }
202
203            $mappedService['showOnStartPage'] = !in_array($service['id'], self::DONT_SHOW_SERVICE_ON_START_PAGE);
204
205            // Extract ZMS-specific fields
206            foreach ($service['fields'] ?? [] as $field) {
207                if (empty($field)) {
208                    continue;
209                }
210
211                if ($field['name'] === 'ZMS_MAX_ANZAHL') {
212                    $mappedService['maxQuantity'] = $field['value'];
213                }
214                if ($field['name'] === 'ZMS_DAUER') {
215                    $mappedService['duration'] = $field['value'];
216                }
217                if ($field['name'] === 'ZMS_INTERN') {
218                    $mappedService['public'] = !$field['value'];
219                }
220                if ($field['name'] === 'GEBUEHRENRAHMEN') {
221                    $mappedService['fees'] = $field['value'];
222                }
223                if ($field['name'] === 'FORMULARE_INFORMATIONEN') {
224                    foreach ($field['values'] ?? [] as $form) {
225                        $formData = ['name' => $form['label'], 'link' => $form['uri'], 'description' => false];
226                        $mappedService['forms'][] = $formData;
227                        $mappedService['links'][] = $formData;
228                    }
229                }
230            }
231
232            $mappedServices[] = $mappedService;
233        }
234
235        return [
236            'data' => $mappedServices,
237            'meta' => [
238                'generated' => $timestamp,
239                'datacount' => count($mappedServices),
240                'hash' => md5(json_encode($mappedServices))
241            ]
242        ];
243    }
244
245    /**
246     * Transform Munich SADB format to Berlin-compatible locations format
247     */
248    public function transformLocations(array $data, ?array $servicesData = null): array
249    {
250        $mappedServices = $this->indexServicesByIds($servicesData);
251        $mappedLocations = [];
252
253        foreach ($data['locations'] ?? [] as $location) {
254            $processed = $this->processLocation($location, $mappedServices);
255            if ($processed) {
256                $mappedLocations[] = $processed;
257            }
258        }
259
260        return $this->buildLocationResponse($mappedLocations);
261    }
262
263    protected function indexServicesByIds(?array $servicesData): array
264    {
265        $mappedServices = [];
266        if ($servicesData) {
267            foreach ($servicesData['data'] ?? [] as $service) {
268                $mappedServices[$service['id']] = $service;
269            }
270        }
271        return $mappedServices;
272    }
273
274    private function processLocation(array $location, array &$mappedServices): ?array
275    {
276        if (!isset($location['altname2'])) {
277            return null;
278        }
279
280        $mappedLocation = $this->buildLocationMetadata($location);
281
282        $this->applyLocationRules($mappedLocation, $mappedLocation['displayName']);
283
284        $durationCommonDivisor = $this->processServiceReferences($location, $mappedLocation, $mappedServices);
285
286        $this->calculateSlotTimes($mappedLocation, $durationCommonDivisor);
287
288        return $mappedLocation;
289    }
290
291    private function buildLocationMetadata(array $location): array
292    {
293        $name = $location['altname2'];
294        $fullName = $name . (isset($location['altname1']) ? ' (' . $location['altname1'] . ')' : '');
295
296        return [
297            'id' => $location['id'],
298            'name' => $fullName,
299            'displayName' => $name,
300            'displayNameAlternatives' => $location['names'] ?? [],
301            'organization' => $location['organisation'] ?? null,
302            'organizationUnit' => $location['orgUnit'] ?? null,
303            'public' => $location['public'] ?? true,
304            'meta' => [
305                'url' => $this->publicUrl . "/locations/{locationId}",
306                'lastupdate' => date('Y-m-d\TH:i:s'),
307                'locale' => 'de',
308                'keywords' => implode(', ', $location['names'] ?? []),
309                'translated' => true,
310                'hash' => '',
311                'id' => $location['id']
312            ],
313            'address' => [
314                'house_number' => $location['address']['streetNumber'] ?? '',
315                'city' => $location['address']['city'] ?? 'München',
316                'postal_code' => $location['address']['zip'] ?? '',
317                'street' => $location['address']['street'] ?? '',
318                'hint' => false
319            ],
320            'geo' => isset($location['coordinate']) ? [
321                'lat' => $location['coordinate']['lat'],
322                'lon' => $location['coordinate']['lon']
323            ] : null,
324            'contact' => [
325                'email' => $location['email'] ?? '',
326                'fax' => $location['fax'] ?? '',
327                'phone' => $location['phone'] ?? '',
328                'signed_mail' => '0',
329                'signed_maillink' => '',
330                'webinfo' => '',
331                'competence' => ''
332            ],
333            'services' => []
334        ];
335    }
336
337    private function applyLocationRules(array &$mappedLocation, string $displayName): void
338    {
339        if (isset(self::LOCATION_PRIO_BY_DISPLAY_NAME[$displayName])) {
340            $mappedLocation['prio'] = self::LOCATION_PRIO_BY_DISPLAY_NAME[$displayName];
341        }
342
343        $mappedLocation['showAlternativeLocations'] = !in_array($mappedLocation['id'], self::EXCLUSIVE_LOCATIONS);
344
345        foreach (self::DONT_SHOW_LOCATION_BY_SERVICES as $avoidByServices) {
346            if (in_array((int) $mappedLocation['id'], $avoidByServices['locations'])) {
347                $mappedLocation['dontShowByServices'] = $avoidByServices['services'];
348                break;
349            }
350        }
351    }
352
353    private function processServiceReferences(array $location, array &$mappedLocation, array &$mappedServices): ?int
354    {
355        $durationCommonDivisor = null;
356
357        foreach ($location['extendedServiceReferences'] ?? [] as $reference) {
358            if (!isset($mappedServices[$reference['refId']])) {
359                continue;
360            }
361
362            $serviceRef = [
363                'service' => $reference['refId'],
364                'contact' => [],
365                'hint' => false,
366                'url' => str_replace(['{serviceId}', '{locationId}'], [$reference['refId'], $mappedLocation['id']], $this->publicUrl . "/services/{serviceId}/locations/{locationId}"),
367                'appointment' => [
368                    'link' => str_replace(['{serviceId}', '{locationId}'], [$reference['refId'], $mappedLocation['id']], $this->publicUrl . "/services/{serviceId}/locations/{locationId}"),
369                    'slots' => '1',
370                    'external' => false,
371                    'allowed' => true
372                ],
373                'onlineprocessing' => [
374                    'description' => null,
375                    'link' => str_replace('{serviceId}', $reference['refId'], $this->publicUrl . "/services/{serviceId}")
376                ],
377                'duration' => $mappedServices[$reference['refId']]['duration'] ?? 30
378            ];
379
380            $locationRef = [
381                'location' => $mappedLocation['id'],
382                'authority' => [
383                    'id' => '1',
384                    'name' => 'Stadtverwaltung München',
385                    'webinfo' => 'https://muenchen.de'
386                ],
387                'url' => str_replace(['{serviceId}', '{locationId}'], [$reference['refId'], $mappedLocation['id']], $this->publicUrl . "/services/{serviceId}/locations/{locationId}"),
388                'appointment' => [
389                    'link' => str_replace(['{serviceId}', '{locationId}'], [$reference['refId'], $mappedLocation['id']], $this->publicUrl . "/services/{serviceId}/locations/{locationId}"),
390                    'slots' => '0',
391                    'external' => false,
392                    'multiple' => '1',
393                    'allowed' => true
394                ],
395                'responsibility' => null,
396                'responsibility_hint' => null,
397                'hint' => false
398            ];
399
400            if (isset($reference['public'])) {
401                $serviceRef['public'] = $reference['public'];
402            }
403
404            if (isset($reference['fields'])) {
405                foreach ($reference['fields'] as $field) {
406                    if ($field['name'] === 'ZMS_DAUER') {
407                        $serviceRef['duration'] = $field['value'];
408                    }
409                    if ($field['name'] === 'ZMS_MAX_ANZAHL') {
410                        $serviceRef['maxQuantity'] = $field['value'];
411                    }
412                    if ($field['name'] === 'ZMS_INTERN') {
413                        $serviceRef['public'] = !$field['value'];
414                    }
415                }
416            }
417
418            if ($durationCommonDivisor === null) {
419                $durationCommonDivisor = $serviceRef['duration'];
420            } else {
421                $durationCommonDivisor = $this->getSlotTime($durationCommonDivisor, $serviceRef['duration']);
422            }
423
424            $mappedLocation['services'][] = $serviceRef;
425            $mappedServices[$reference['refId']]['locations'][] = $locationRef;
426        }
427
428        return $durationCommonDivisor;
429    }
430
431    private function calculateSlotTimes(array &$mappedLocation, ?int $durationCommonDivisor): void
432    {
433        foreach ($mappedLocation['services'] as $key => $service) {
434            if ($durationCommonDivisor && isset($service['duration'])) {
435                $mappedLocation['services'][$key]['appointment']['slots'] = (string) ((int)($service['duration'] / $durationCommonDivisor));
436            }
437        }
438
439        $mappedLocation['slotTimeInMinutes'] = $durationCommonDivisor;
440        $mappedLocation['forceSlotTimeUpdate'] = true;
441    }
442
443    private function buildLocationResponse(array $mappedLocations): array
444    {
445        return [
446            'data' => $mappedLocations,
447            'meta' => [
448                'generated' => date('Y-m-d\TH:i:s'),
449                'datacount' => count($mappedLocations),
450                'hash' => md5(json_encode($mappedLocations))
451            ]
452        ];
453    }
454
455    /**
456     * Get service combinations (services that can be booked together)
457     */
458    protected function getServiceCombinations(int $serviceId): ?array
459    {
460        foreach (self::SERVICE_COMBINATIONS as $combo) {
461            if (empty($combo)) {
462                continue;
463            }
464
465            if ((int) $combo[0] === $serviceId) {
466                $list = array_merge([$serviceId], array_slice($combo, 1));
467                $list = array_map('intval', $list);
468                return array_values(array_unique($list, SORT_NUMERIC));
469            }
470        }
471
472        return null;
473    }
474
475    /**
476     * Calculate greatest common divisor for slot times
477     */
478    protected function getSlotTime(int $a, int $b): int
479    {
480        $slotTimes = [1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 25, 30, 60];
481        $slotTime = 1;
482
483        foreach ($slotTimes as $time) {
484            if ($a % $time === 0 && $b % $time === 0) {
485                $slotTime = $time;
486            }
487        }
488
489        return $slotTime;
490    }
491}