Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 304
0.00% covered (danger)
0.00%
0 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
Munich
0.00% covered (danger)
0.00%
0 / 304
0.00% covered (danger)
0.00%
0 / 17
8190
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
 requestWithOptionalProxy
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
72
 fetchLatestExport
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
56
 defaultSadbOverwritePath
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 applySadbOverwrite
0.00% covered (danger)
0.00%
0 / 45
0.00% covered (danger)
0.00%
0 / 1
210
 mergeListByKey
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 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 (10492)
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    /** Aligned with dldb-mapper/app/map.php DONT_SHOW_SERVICE_ON_START_PAGE */
63    const DONT_SHOW_SERVICE_ON_START_PAGE = [
64        10396802,
65        1063648,
66        1063731,
67        1071907,
68        10502137,
69        10314100,
70        10416410,
71        10323113,
72    ];
73
74    const SERVICE_COMBINATIONS = [
75        //BB
76        [10295182],
77        [10242339, 1063475, 1063441, 1063453, 10308996, 10224136, 10225205, 10225181, 1063426, 1063428, 10306925, 10225119, 1080843, 1076889, 1078273, 1080582, 10225197, 10224132],
78        [10225205, 1063441, 1063453, 10224132, 10242339, 10308996, 10224136, 10225181, 1063426, 1063428, 10306925, 10225119, 1080843, 1076889, 1078273, 1080582, 10225197, 1063475],
79        [10225181, 1063428, 1063426, 1063475, 10242339, 10308996, 10224136, 10225205,  10306925, 10225119, 1080843, 1063441, 1063453, 1076889, 1078273, 1080582, 10225197, 10224132],
80        [1063426, 1063428, 1063475, 1063441, 10242339, 10308996, 10224136, 10225205, 10225181,  10306925, 10225119, 1080843, 1063453, 1076889, 1078273, 1080582, 10225197, 10224132],
81        [10306925, 1063441, 1063453, 1063475, 10242339, 10308996, 10224136, 10225205, 10225181, 1063426,  1063428, 10225119, 1080843, 1076889, 1078273, 1080582, 10225197, 10224132],
82        [1063428, 1063426, 10225181, 1063441, 10242339, 10308996, 10224136, 10225205, 10225189, 10306925, 10225119, 1080843, 1063453, 1076889, 1078273, 1080582, 10225197, 1063475, 10224132],
83        [10225119, 1063475, 10242339, 1063453, 10308996, 10224136, 10225205, 10225181, 1063426,  1063428, 10306925, 1080843,  1063441, 1076889, 1078273, 1080582, 10225197,  10224132],
84        [1063565, 10225129, 1064033, 10297413, 1063576],
85        [10225129, 1063565, 1064033, 10297413, 1063576],
86        [1064033, 1063565, 10225129, 10297413, 1063576],
87        [1080843, 1063475, 10224132, 10308996, 10224136, 10242339, 10225205, 10225181, 1063426,  1063428, 10306925, 10225119,  1063441, 1063453, 1076889, 1078273, 1080582, 10225197, 1063486],
88        [10297413, 1063565, 10225129, 1064033, 1063576],
89        [1063576, 1063565, 10225129, 1064033, 10297413],
90        [1063441, 10225205, 1063453, 1063475, 10242339, 10308996, 10224136,  10225181, 1063426,  1063428, 10306925, 10225119, 1080843, 1076889, 1078273, 1080582, 10225197,  10224132],
91        [10224136, 10242339, 10308996, 10225205, 10225181, 1063426, 1063428, 10306925, 10225119, 1080843,  1063441, 1063453, 1076889, 1078273, 1080582, 10225197,  1063475, 10224132],
92        [1063453, 10225205, 1063441, 1063475, 10242339, 10308996, 10224136, 10225181, 1063426,  1063428, 10306925, 10225119, 1080843, 1076889, 1078273, 1080582, 10225197,  10224132],
93        [1076889, 1063441, 1063453, 1063475, 10242339, 10308996, 10224136, 10225205, 10225181, 1063426,  1063428, 10306925, 10225119, 1080843, 1078273, 1080582, 10225197,  10224132],
94        [1078273, 1063441, 1063453, 1076889, 10242339, 10308996, 10224136, 10225205, 10225181, 1063426,  1063428, 10306925, 10225119, 1080843,  1080582, 10225197, 1063475, 10224132],
95        [1080582, 1063441, 1063453, 1063475, 10242339, 10308996, 10224136, 10225205, 10225181, 1063426,  1063428, 10306925, 10225119, 1080843,  1076889, 1078273, 10225197,  10224132],
96        [10225197, 1063441, 1063453, 10225119, 10242339, 10308996, 10224136, 10225205, 10225181, 1063426, 1063428, 10306925, 1080843, 1076889, 1078273, 1080582, 1063475, 10224132],
97        [1063475, 1063441, 1063453, 10224132, 10242339, 10308996, 10224136, 10225205, 10225181, 1063426,  1063428, 10306925, 10225119, 1080843, 1076889, 1078273, 1080582, 10225197, 1063486],
98        [10224132,10225205, 1063441, 1063453, 10242339, 10308996, 10224136, 10225181, 1063426,  1063428, 10306925, 10225119, 1080843, 1076889, 1078273, 1080582, 10225197, 1063486, 1063475],
99        //KFZ
100        [1064076, 10392406, 10391604],
101        [10392406, 1064076, 10391604],
102        [10391604, 1064076, 10392406],
103        [10115737, 1064268, 1064345, 1064374],
104        [1064268, 10115737, 1064345, 1064374],
105        [1064345, 10115737, 1064268, 1064374],
106        [1064374, 10115737, 1064268, 1064345],
107        [1064121, 1064354, 1064308, 10387573, 1064275, 1064130, 1063425, 1080502, 10387564, 1064271, 1064342, 1064333, 1071959, 1064311, 1064323, 1063424, 1064314, 10391602],
108        [1063425, 1064121, 1064354, 1064308, 10387573, 1064275, 1064342, 1071959, 1064311, 10387564, 1064323, 1064130, 1064333, 1063424, 1064314, 1064271, 10391602, 1080502],
109        [1064342, 1064121, 1064354, 1064308, 10387573, 1064275, 1080502, 1064323, 1063425, 1071959, 1064311, 10387564, 1064130, 1064333, 1063424, 1064314, 1064271, 10391602],
110        [1064354, 1064121, 1064308, 10387573, 1064275, 1064314, 1063424, 1064333, 1064323, 1064130, 1064271, 1064342, 10391602, 1063425, 10387564, 1071959, 1064311, 1080502],
111        [1064308, 1064121, 1064354, 10387573, 1064275, 1064130, 1064333, 1063425, 10391602, 10387564, 1064323, 1080502, 1064342, 1064271, 1071959, 1064311, 1063424, 1064314],
112        [1071959, 1064121, 1064354, 1064308, 10387573, 1064275, 1063425, 1064342, 1064311, 10387564, 1064323, 1064130, 1064333, 1063424, 1064314, 1064271, 10391602, 1080502],
113        [1064311, 1064121, 1064354, 1064308, 10387573, 1064275, 1063425, 1064342, 1071959, 10387564, 1064323, 1064130, 1064333, 1063424, 1064314, 1064271, 10391602, 1080502],
114        [10387564, 1064121, 1064354, 1064308, 10387573, 1064275, 1063425, 1064342, 1071959, 1064311, 1064323, 1064130, 1064333, 1063424, 1064314, 1064271, 10391602, 1080502],
115        [10387573, 1064121, 1064354, 1064308, 1064275, 1063425, 1064342, 1071959, 1064311, 10387564, 1064323, 1064130, 1064333, 1063424, 1064314, 1064271, 10391602, 1080502],
116        [1064323, 1064121, 1064354, 1064308, 10387573, 1064275, 1080502, 1063425, 10387564, 1064342, 1071959, 1064311, 1064130, 1064333, 1063424, 1064314, 1064271, 10391602],
117        [1064130, 1064121, 1064354, 1064308, 10387573, 1064275, 1063425, 1064333, 1080502, 1064323, 1064342, 1071959, 1064311, 10387564, 1063424, 1064314, 1064271, 10391602],
118        [1064333, 1064121, 1064354, 1064308, 10387573, 1064275, 1063425, 1080502, 1064323, 1064342, 1071959, 1064311, 10387564, 1064130, 1063424, 1064314, 1064271, 10391602],
119        [1063424, 1064121, 1064354, 1064308, 10387573, 1064275, 1064314, 1064130, 1064333, 1063425, 10391602, 1064271, 1080502, 1064323, 1064342, 1071959, 1064311, 10387564],
120        [1064314, 1064121, 1064354, 1064308, 10387573, 1064275, 1064130, 1064333, 1063425, 1064323, 10391602, 1064342, 1064271, 1080502, 10387564, 1071959, 1064311, 1063424],
121        [1064271, 1064121, 1064354, 1064308, 10387573, 1064275, 1063425, 1064342, 1071959, 1064311, 10387564, 1064323, 1064130, 1064333, 1063424, 1064314, 10391602, 1080502],
122        [1064275, 1064121, 1064354, 1064308, 10387573, 1064271, 1063425, 1064342, 1071959, 1064311, 10387564, 1064323, 1064130, 1064333, 1063424, 1064314, 10391602, 1080502],
123        [10391602, 1064121, 1064354, 1064308, 10387573, 1064275, 1064323, 1064333, 10387564, 1063425, 1064342, 1071959, 1064311, 1064130, 1063424, 1064314, 1064271, 1080502],
124        [1080502, 1064121, 1064354, 1064308, 10387573, 1064275, 10387564, 1063425, 1064342, 1071959, 1064311, 1064323, 1064130, 1064333, 1063424, 1064314, 1064271, 10391602],
125        // Gewerbeamt
126        [10300817, 10300814],
127        [10300814, 10300817],
128        [10300814, 10300808],
129        [10300808, 10300814],
130        // Fuehrerscheinstelle
131        [1064361, 10383549],
132        [10383549, 1064361],
133    ];
134
135    protected $publicUrl;
136    protected $logger;
137
138    public function __construct(string $publicUrl = '', ?LoggerInterface $logger = null)
139    {
140        if ($publicUrl && !filter_var($publicUrl, FILTER_VALIDATE_URL)) {
141            throw new \InvalidArgumentException('Invalid public URL provided');
142        }
143        $this->publicUrl = $publicUrl ?: 'https://stadt.muenchen.de/en/buergerservice/terminvereinbarung.html/#';
144        $this->logger = $logger;
145    }
146
147    /**
148     * Apply corporate proxy to Httpful request when HTTPS_PROXY / HTTP_PROXY is set.
149     * Cron and CI often do not inherit .bashrc; sourcing in cronjob.hourly helps, this is a second safeguard.
150     */
151    private function requestWithOptionalProxy(string $url): Request
152    {
153        $req = Request::get($url);
154        $proxy = getenv('HTTPS_PROXY') ?: getenv('https_proxy')
155            ?: getenv('HTTP_PROXY') ?: getenv('http_proxy');
156        if ($proxy !== false && $proxy !== '') {
157            $parts = parse_url($proxy);
158            if (!empty($parts['host'])) {
159                $port = isset($parts['port']) ? (int) $parts['port'] : 80;
160                $req->useProxy($parts['host'], $port);
161            }
162        }
163        return $req;
164    }
165
166    /**
167     * Fetch latest Munich SADB export and return the data
168     */
169    public function fetchLatestExport(string $indexUrl): array
170    {
171        try {
172            $response = $this->requestWithOptionalProxy($indexUrl)->timeout(45)->send();
173            if ((int)($response->code ?? 0) !== 200) {
174                throw new \RuntimeException("Index fetch failed with status {$response->code}");
175            }
176            $content = $response->raw_body;
177
178            // Extract JSON export URLs using regex and pick the last one (latest)
179            if (!preg_match_all('#https://[^"\'\'\s<>]+\\.json#i', $content, $matches) || empty($matches[0])) {
180                throw new \RuntimeException('No JSON export links found on index page');
181            }
182            $latestUrl = end($matches[0]);
183
184            $this->logger?->info('Fetching Munich export', ['url' => $latestUrl]);
185
186            $exportResponse = $this->requestWithOptionalProxy($latestUrl)->timeout(120)->send();
187            if ((int)($exportResponse->code ?? 0) !== 200) {
188                throw new \RuntimeException("Export fetch failed with status {$exportResponse->code}");
189            }
190
191            $body = $exportResponse->raw_body;
192            try {
193                return json_decode($body, true, 512, JSON_THROW_ON_ERROR);
194            } catch (\JsonException $e) {
195                $snippet = preg_replace('/\s+/', ' ', substr((string) $body, 0, 400));
196                throw new \RuntimeException(
197                    'Export JSON parse failed (' . $e->getMessage() . '). '
198                    . 'Often means the HTTP response was not JSON (e.g. block page or missing HTTPS_PROXY in cron). '
199                    . 'Body preview: ' . $snippet,
200                    0,
201                    $e
202                );
203            }
204        } catch (\Throwable $e) {
205            $this->logger?->error('Failed to fetch Munich export', ['error' => $e->getMessage(), 'url' => $indexUrl]);
206            throw $e;
207        }
208    }
209
210    /**
211     * Path to bundled SADB overwrite (ex-dldb-mapper prod.json). Passkalender 10502 + Pass services.
212     */
213    public static function defaultSadbOverwritePath(): string
214    {
215        return dirname(__DIR__, 3) . '/resources/munich_sadb_overwrite.json';
216    }
217
218    /**
219     * Merge overwrite JSON into raw SADB export — same role as dldb-mapper mapImport($overwrite).
220     * - Services: merge by id, fields merged by name (overwrite wins).
221     * - Locations: merge by id (extendedServiceReferences by refId) or append if missing (e.g. 10502).
222     *
223     * @SuppressWarnings(PHPMD.NPathComplexity)
224     */
225    public function applySadbOverwrite(array $data, ?string $overwritePath = null): array
226    {
227        $path = $overwritePath ?: self::defaultSadbOverwritePath();
228        if (!is_readable($path)) {
229            $this->logger?->warning('Munich SADB overwrite not readable, skipping merge', ['path' => $path]);
230            return $data;
231        }
232        $raw = file_get_contents($path);
233        if ($raw === false) {
234            return $data;
235        }
236        try {
237            $additional = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
238        } catch (\JsonException $e) {
239            $this->logger?->error('Munich SADB overwrite JSON invalid', ['path' => $path, 'error' => $e->getMessage()]);
240            return $data;
241        }
242
243        $data['services'] = $data['services'] ?? [];
244        $data['locations'] = $data['locations'] ?? [];
245
246        foreach ($additional['services'] ?? [] as $svc) {
247            $id = $svc['id'] ?? null;
248            if ($id === null) {
249                continue;
250            }
251            foreach ($data['services'] as $k => $existing) {
252                if ((string)($existing['id'] ?? '') !== (string)$id) {
253                    continue;
254                }
255                $origFields = $existing['fields'] ?? [];
256                $newFields = $svc['fields'] ?? [];
257                $data['services'][$k] = array_merge($existing, $svc);
258                $data['services'][$k]['fields'] = $this->mergeListByKey($origFields, $newFields, 'name');
259                break;
260            }
261        }
262
263        foreach ($additional['locations'] ?? [] as $ov) {
264            $oid = (string)($ov['id'] ?? '');
265            if ($oid === '') {
266                continue;
267            }
268            $idx = null;
269            foreach ($data['locations'] as $k => $loc) {
270                if ((string)($loc['id'] ?? '') === $oid) {
271                    $idx = $k;
272                    break;
273                }
274            }
275            if ($idx === null) {
276                $data['locations'][] = $ov;
277                $this->logger?->info('Munich SADB overwrite: appended location', ['id' => $oid]);
278                continue;
279            }
280            $existing = $data['locations'][$idx];
281            $origRefs = $existing['extendedServiceReferences'] ?? [];
282            $newRefs = $ov['extendedServiceReferences'] ?? [];
283            $data['locations'][$idx] = array_merge($existing, $ov);
284            $data['locations'][$idx]['extendedServiceReferences'] = $this->mergeListByKey($origRefs, $newRefs, 'refId');
285            $this->logger?->info('Munich SADB overwrite: merged location', ['id' => $oid]);
286        }
287
288        return $data;
289    }
290
291    /**
292     * @param array<int, array<string, mixed>> $originalList
293     * @param array<int, array<string, mixed>> $additionalList
294     * @return array<int, array<string, mixed>>
295     */
296    private function mergeListByKey(array $originalList, array $additionalList, string $key): array
297    {
298        $by = [];
299        foreach ($originalList as $el) {
300            if (isset($el[$key])) {
301                $by[(string)$el[$key]] = $el;
302            }
303        }
304        foreach ($additionalList as $el) {
305            if (isset($el[$key])) {
306                $by[(string)$el[$key]] = $el;
307            }
308        }
309        return array_values($by);
310    }
311
312    /**
313     * Transform Munich SADB format to Berlin-compatible services format
314     */
315    public function transformServices(array $data): array
316    {
317        $timestamp = date('Y-m-d\TH:i:s');
318        $mappedServices = [];
319
320        foreach ($data['services'] ?? [] as $service) {
321            $mappedService = [
322                'id' => $service['id'],
323                'name' => $service['name'],
324                'description' => $service['description'] ?? '',
325                'meta' => [
326                    'lastupdate' => $timestamp,
327                    'url' => $this->publicUrl . "/services/{serviceId}",
328                    'locale' => 'de',
329                    'keywords' => implode(', ', $service['synonyms'] ?? []),
330                    'translated' => true,
331                    'hash' => '',
332                    'id' => $service['id']
333                ],
334                'authorities' => [['id' => '1', 'name' => 'Stadtverwaltung München', 'webinfo' => 'https://muenchen.de']],
335                'locations' => [],
336                'leika' => $service['leikaId'] ?? null,
337                'public' => true,
338                'links' => [],
339                'forms' => [],
340                'appointment' => [
341                    'link' => $this->publicUrl . "/services/{serviceId}"
342                ],
343                'maxQuantity' => 1,
344                'showOnStartPage' => true,
345                'duration' => 30, // Default duration
346            ];
347
348            // Get combinable services
349            $combinableServices = $this->getServiceCombinations($service['id']);
350            if ($combinableServices) {
351                $mappedService['combinable'] = $combinableServices;
352            }
353
354            $mappedService['showOnStartPage'] = !in_array($service['id'], self::DONT_SHOW_SERVICE_ON_START_PAGE);
355
356            // Extract ZMS-specific fields
357            foreach ($service['fields'] ?? [] as $field) {
358                if (empty($field)) {
359                    continue;
360                }
361
362                if ($field['name'] === 'ZMS_MAX_ANZAHL') {
363                    $mappedService['maxQuantity'] = $field['value'];
364                }
365                if ($field['name'] === 'ZMS_DAUER') {
366                    $mappedService['duration'] = $field['value'];
367                }
368                if ($field['name'] === 'ZMS_INTERN') {
369                    $mappedService['public'] = !$field['value'];
370                }
371                if ($field['name'] === 'GEBUEHRENRAHMEN') {
372                    $mappedService['fees'] = $field['value'];
373                }
374                if ($field['name'] === 'FORMULARE_INFORMATIONEN') {
375                    foreach ($field['values'] ?? [] as $form) {
376                        $formData = ['name' => $form['label'], 'link' => $form['uri'], 'description' => false];
377                        $mappedService['forms'][] = $formData;
378                        $mappedService['links'][] = $formData;
379                    }
380                }
381            }
382
383            $mappedServices[] = $mappedService;
384        }
385
386        return [
387            'data' => $mappedServices,
388            'meta' => [
389                'generated' => $timestamp,
390                'datacount' => count($mappedServices),
391                'hash' => md5(json_encode($mappedServices))
392            ]
393        ];
394    }
395
396    /**
397     * Transform Munich SADB format to Berlin-compatible locations format
398     */
399    public function transformLocations(array $data, ?array $servicesData = null): array
400    {
401        $mappedServices = $this->indexServicesByIds($servicesData);
402        $mappedLocations = [];
403
404        foreach ($data['locations'] ?? [] as $location) {
405            $processed = $this->processLocation($location, $mappedServices);
406            if ($processed) {
407                $mappedLocations[] = $processed;
408            }
409        }
410
411        return $this->buildLocationResponse($mappedLocations);
412    }
413
414    protected function indexServicesByIds(?array $servicesData): array
415    {
416        $mappedServices = [];
417        if ($servicesData) {
418            foreach ($servicesData['data'] ?? [] as $service) {
419                $mappedServices[$service['id']] = $service;
420            }
421        }
422        return $mappedServices;
423    }
424
425    private function processLocation(array $location, array &$mappedServices): ?array
426    {
427        if (!isset($location['altname2'])) {
428            return null;
429        }
430
431        $mappedLocation = $this->buildLocationMetadata($location);
432
433        $this->applyLocationRules($mappedLocation, $mappedLocation['displayName']);
434
435        $durationCommonDivisor = $this->processServiceReferences($location, $mappedLocation, $mappedServices);
436
437        $this->calculateSlotTimes($mappedLocation, $durationCommonDivisor);
438
439        return $mappedLocation;
440    }
441
442    private function buildLocationMetadata(array $location): array
443    {
444        $name = $location['altname2'];
445        $fullName = $name . (isset($location['altname1']) ? ' (' . $location['altname1'] . ')' : '');
446
447        return [
448            'id' => $location['id'],
449            'name' => $fullName,
450            'displayName' => $name,
451            'displayNameAlternatives' => $location['names'] ?? [],
452            'organization' => $location['organisation'] ?? null,
453            'organizationUnit' => $location['orgUnit'] ?? null,
454            'public' => $location['public'] ?? true,
455            'meta' => [
456                'url' => $this->publicUrl . "/locations/{locationId}",
457                'lastupdate' => date('Y-m-d\TH:i:s'),
458                'locale' => 'de',
459                'keywords' => implode(', ', $location['names'] ?? []),
460                'translated' => true,
461                'hash' => '',
462                'id' => $location['id']
463            ],
464            'address' => [
465                'house_number' => $location['address']['streetNumber'] ?? '',
466                'city' => $location['address']['city'] ?? 'München',
467                'postal_code' => $location['address']['zip'] ?? '',
468                'street' => $location['address']['street'] ?? '',
469                'hint' => false
470            ],
471            'geo' => isset($location['coordinate']) ? [
472                'lat' => $location['coordinate']['lat'],
473                'lon' => $location['coordinate']['lon']
474            ] : null,
475            'contact' => [
476                'email' => $location['email'] ?? '',
477                'fax' => $location['fax'] ?? '',
478                'phone' => $location['phone'] ?? '',
479                'signed_mail' => '0',
480                'signed_maillink' => '',
481                'webinfo' => '',
482                'competence' => ''
483            ],
484            'services' => []
485        ];
486    }
487
488    private function applyLocationRules(array &$mappedLocation, string $displayName): void
489    {
490        if (isset(self::LOCATION_PRIO_BY_DISPLAY_NAME[$displayName])) {
491            $mappedLocation['prio'] = self::LOCATION_PRIO_BY_DISPLAY_NAME[$displayName];
492        }
493
494        $mappedLocation['showAlternativeLocations'] = !in_array($mappedLocation['id'], self::EXCLUSIVE_LOCATIONS);
495
496        foreach (self::DONT_SHOW_LOCATION_BY_SERVICES as $avoidByServices) {
497            if (in_array((int) $mappedLocation['id'], $avoidByServices['locations'])) {
498                $mappedLocation['dontShowByServices'] = $avoidByServices['services'];
499                break;
500            }
501        }
502
503        // Mark locations that participate in the "exclusive vs mixed" disabled-services logic.
504        // Output group IDs so JumpIn with one office can auto-select the equivalent.
505        foreach (self::LOCATIONS_ALLOW_DISABLED_MIX as $group) {
506            if (in_array((int) $mappedLocation['id'], $group, true)) {
507                $mappedLocation['allowDisabledServicesMix'] = $group;
508                break;
509            }
510        }
511    }
512
513    private function processServiceReferences(array $location, array &$mappedLocation, array &$mappedServices): ?int
514    {
515        $durationCommonDivisor = null;
516
517        foreach ($location['extendedServiceReferences'] ?? [] as $reference) {
518            if (!isset($mappedServices[$reference['refId']])) {
519                continue;
520            }
521
522            $serviceRef = [
523                'service' => $reference['refId'],
524                'contact' => [],
525                'hint' => false,
526                'url' => str_replace(['{serviceId}', '{locationId}'], [$reference['refId'], $mappedLocation['id']], $this->publicUrl . "/services/{serviceId}/locations/{locationId}"),
527                'appointment' => [
528                    'link' => str_replace(['{serviceId}', '{locationId}'], [$reference['refId'], $mappedLocation['id']], $this->publicUrl . "/services/{serviceId}/locations/{locationId}"),
529                    'slots' => '1',
530                    'external' => false,
531                    'allowed' => true
532                ],
533                'onlineprocessing' => [
534                    'description' => null,
535                    'link' => str_replace('{serviceId}', $reference['refId'], $this->publicUrl . "/services/{serviceId}")
536                ],
537                'duration' => $mappedServices[$reference['refId']]['duration'] ?? 30
538            ];
539
540            $locationRef = [
541                'location' => $mappedLocation['id'],
542                'authority' => [
543                    'id' => '1',
544                    'name' => 'Stadtverwaltung München',
545                    'webinfo' => 'https://muenchen.de'
546                ],
547                'url' => str_replace(['{serviceId}', '{locationId}'], [$reference['refId'], $mappedLocation['id']], $this->publicUrl . "/services/{serviceId}/locations/{locationId}"),
548                'appointment' => [
549                    'link' => str_replace(['{serviceId}', '{locationId}'], [$reference['refId'], $mappedLocation['id']], $this->publicUrl . "/services/{serviceId}/locations/{locationId}"),
550                    'slots' => '0',
551                    'external' => false,
552                    'multiple' => '1',
553                    'allowed' => true
554                ],
555                'responsibility' => null,
556                'responsibility_hint' => null,
557                'hint' => false
558            ];
559
560            if (isset($reference['public'])) {
561                $serviceRef['public'] = $reference['public'];
562            }
563
564            if (isset($reference['fields'])) {
565                foreach ($reference['fields'] as $field) {
566                    if ($field['name'] === 'ZMS_DAUER') {
567                        $serviceRef['duration'] = $field['value'];
568                    }
569                    if ($field['name'] === 'ZMS_MAX_ANZAHL') {
570                        $serviceRef['maxQuantity'] = $field['value'];
571                    }
572                    if ($field['name'] === 'ZMS_INTERN') {
573                        $serviceRef['public'] = !$field['value'];
574                    }
575                }
576            }
577
578            if ($durationCommonDivisor === null) {
579                $durationCommonDivisor = $serviceRef['duration'];
580            } else {
581                $durationCommonDivisor = $this->getSlotTime($durationCommonDivisor, $serviceRef['duration']);
582            }
583
584            $mappedLocation['services'][] = $serviceRef;
585            $mappedServices[$reference['refId']]['locations'][] = $locationRef;
586        }
587
588        return $durationCommonDivisor;
589    }
590
591    private function calculateSlotTimes(array &$mappedLocation, ?int $durationCommonDivisor): void
592    {
593        foreach ($mappedLocation['services'] as $key => $service) {
594            if ($durationCommonDivisor && isset($service['duration'])) {
595                $mappedLocation['services'][$key]['appointment']['slots'] = (string) ((int)($service['duration'] / $durationCommonDivisor));
596            }
597        }
598
599        $mappedLocation['slotTimeInMinutes'] = $durationCommonDivisor;
600        $mappedLocation['forceSlotTimeUpdate'] = true;
601    }
602
603    private function buildLocationResponse(array $mappedLocations): array
604    {
605        return [
606            'data' => $mappedLocations,
607            'meta' => [
608                'generated' => date('Y-m-d\TH:i:s'),
609                'datacount' => count($mappedLocations),
610                'hash' => md5(json_encode($mappedLocations))
611            ]
612        ];
613    }
614
615    /**
616     * Get service combinations (services that can be booked together)
617     */
618    protected function getServiceCombinations(int $serviceId): ?array
619    {
620        foreach (self::SERVICE_COMBINATIONS as $combo) {
621            if (empty($combo)) {
622                continue;
623            }
624
625            if ((int) $combo[0] === $serviceId) {
626                $list = array_merge([$serviceId], array_slice($combo, 1));
627                $list = array_map('intval', $list);
628                return array_values(array_unique($list, SORT_NUMERIC));
629            }
630        }
631
632        return null;
633    }
634
635    /**
636     * Calculate greatest common divisor for slot times
637     */
638    protected function getSlotTime(int $a, int $b): int
639    {
640        $slotTimes = [1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 25, 30, 60];
641        $slotTime = 1;
642
643        foreach ($slotTimes as $time) {
644            if ($a % $time === 0 && $b % $time === 0) {
645                $slotTime = $time;
646            }
647        }
648
649        return $slotTime;
650    }
651}