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