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