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