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    public const array 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    public const array 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    public const array 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    public const array 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    public const array LOCATIONS_SHARED_BOOKING = [
63        [10489, 10313237, 10500, 10491] // 10491 now has some of the same services as 10489 10502 10313237 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    public const array 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 string $publicUrl;
141    protected ?LoggerInterface $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        /** @psalm-suppress RiskyTruthyFalsyComparison */
160        $proxy = getenv('HTTPS_PROXY') ?: getenv('https_proxy')
161            ?: getenv('HTTP_PROXY') ?: getenv('http_proxy');
162        if ($proxy !== false && $proxy !== '') {
163            $parts = parse_url($proxy);
164            /** @psalm-suppress RiskyTruthyFalsyComparison */
165            if (!empty($parts['host'])) {
166                $port = isset($parts['port']) ? $parts['port'] : 80;
167                $req->useProxy($parts['host'], $port);
168            }
169        }
170        return $req;
171    }
172
173    /**
174     * Fetch latest Munich SADB export and return the data
175     */
176    public function fetchLatestExport(string $indexUrl): array
177    {
178        try {
179            $response = $this->requestWithOptionalProxy($indexUrl)->timeout(45)->send();
180            if ((int)($response->code ?? 0) !== 200) {
181                throw new \RuntimeException("Index fetch failed with status {$response->code}");
182            }
183            $content = $response->raw_body;
184
185            // Extract JSON export URLs using regex and pick the last one (latest)
186            /** @psalm-suppress RiskyTruthyFalsyComparison */
187            if (!preg_match_all('#https://[^"\'\'\s<>]+\\.json#i', $content, $matches) || empty($matches[0])) {
188                throw new \RuntimeException('No JSON export links found on index page');
189            }
190            $latestUrl = end($matches[0]);
191
192            $this->logger?->info('Fetching Munich export', ['url' => $latestUrl]);
193
194            $exportResponse = $this->requestWithOptionalProxy($latestUrl)->timeout(120)->send();
195            if ((int)($exportResponse->code ?? 0) !== 200) {
196                throw new \RuntimeException("Export fetch failed with status {$exportResponse->code}");
197            }
198
199            $body = $exportResponse->raw_body;
200            try {
201                return json_decode($body, true, 512, JSON_THROW_ON_ERROR);
202            } catch (\JsonException $e) {
203                $snippet = preg_replace('/\s+/', ' ', substr((string) $body, 0, 400));
204                throw new \RuntimeException(
205                    'Export JSON parse failed (' . $e->getMessage() . '). '
206                    . 'Often means the HTTP response was not JSON (e.g. block page or missing HTTPS_PROXY in cron). '
207                    . 'Body preview: ' . (string) $snippet,
208                    0,
209                    $e
210                );
211            }
212        } catch (\Throwable $e) {
213            $this->logger?->error('Failed to fetch Munich export', ['error' => $e->getMessage(), 'url' => $indexUrl]);
214            throw $e;
215        }
216    }
217
218    /**
219     * Path to bundled SADB overwrite (ex-dldb-mapper prod.json).
220     * Local extras: Passkalender 10502 (Pass services) + KVR-II/2252 10313237
221     * (Haushaltsbescheinigung 1080843, Wohnsitzanmeldung 1063475, Wohnsitzanmeldung Familie 10224132).
222     * Wohnsitz abmelden 1063486 at 10313237 stays SADB-internal (public false).
223     */
224    public static function defaultSadbOverwritePath(): string
225    {
226        return dirname(__DIR__, 3) . '/resources/munich_sadb_overwrite.json';
227    }
228
229    /**
230     * Merge overwrite JSON into raw SADB export — same role as dldb-mapper mapImport($overwrite).
231     * - Services: merge by id, fields merged by name (overwrite wins).
232     * - Locations: merge by id (extendedServiceReferences by refId) or append if missing (e.g. 10502).
233     *
234     * @SuppressWarnings(PHPMD.NPathComplexity)
235     */
236    public function applySadbOverwrite(array $data, ?string $overwritePath = null): array
237    {
238        /** @psalm-suppress RiskyTruthyFalsyComparison */
239        $path = $overwritePath ?: self::defaultSadbOverwritePath();
240        if (!is_readable($path)) {
241            $this->logger?->warning('Munich SADB overwrite not readable, skipping merge', ['path' => $path]);
242            return $data;
243        }
244        $raw = file_get_contents($path);
245        if ($raw === false) {
246            return $data;
247        }
248        try {
249            $additional = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
250        } catch (\JsonException $e) {
251            $this->logger?->error('Munich SADB overwrite JSON invalid', ['path' => $path, 'error' => $e->getMessage()]);
252            return $data;
253        }
254
255        $data['services'] = $data['services'] ?? [];
256        $data['locations'] = $data['locations'] ?? [];
257
258        foreach ($additional['services'] ?? [] as $svc) {
259            $id = $svc['id'] ?? null;
260            if ($id === null) {
261                continue;
262            }
263            foreach ($data['services'] as $k => $existing) {
264                if ((string)($existing['id'] ?? '') !== (string)$id) {
265                    continue;
266                }
267                $origFields = $existing['fields'] ?? [];
268                $newFields = $svc['fields'] ?? [];
269                $data['services'][$k] = array_merge($existing, $svc);
270                $data['services'][$k]['fields'] = $this->mergeListByKey($origFields, $newFields, 'name');
271                break;
272            }
273        }
274
275        foreach ($additional['locations'] ?? [] as $ov) {
276            $oid = (string)($ov['id'] ?? '');
277            if ($oid === '') {
278                continue;
279            }
280            $idx = null;
281            foreach ($data['locations'] as $k => $loc) {
282                if ((string)($loc['id'] ?? '') === $oid) {
283                    $idx = $k;
284                    break;
285                }
286            }
287            if ($idx === null) {
288                $data['locations'][] = $ov;
289                $this->logger?->info('Munich SADB overwrite: appended location', ['id' => $oid]);
290                continue;
291            }
292            $existing = $data['locations'][$idx];
293            $origRefs = $existing['extendedServiceReferences'] ?? [];
294            $newRefs = $ov['extendedServiceReferences'] ?? [];
295            $data['locations'][$idx] = array_merge($existing, $ov);
296            $data['locations'][$idx]['extendedServiceReferences'] = $this->mergeListByKey($origRefs, $newRefs, 'refId');
297            $this->logger?->info('Munich SADB overwrite: merged location', ['id' => $oid]);
298        }
299
300        return $data;
301    }
302
303    /**
304     * @param array<int, array<string, mixed>> $originalList
305     * @param array<int, array<string, mixed>> $additionalList
306     * @return array<int, array<string, mixed>>
307     */
308    private function mergeListByKey(array $originalList, array $additionalList, string $key): array
309    {
310        $by = [];
311        foreach ($originalList as $el) {
312            if (isset($el[$key])) {
313                $by[(string)$el[$key]] = $el;
314            }
315        }
316        foreach ($additionalList as $el) {
317            if (isset($el[$key])) {
318                $by[(string)$el[$key]] = $el;
319            }
320        }
321        return array_values($by);
322    }
323
324    /**
325     * Transform Munich SADB format to Berlin-compatible services format
326     */
327    public function transformServices(array $data): array
328    {
329        $timestamp = date('Y-m-d\TH:i:s');
330        $mappedServices = [];
331
332        foreach ($data['services'] ?? [] as $service) {
333            $mappedService = [
334                'id' => $service['id'],
335                'name' => $service['name'],
336                'description' => $service['description'] ?? '',
337                'meta' => [
338                    'lastupdate' => $timestamp,
339                    'url' => $this->publicUrl . "/services/{serviceId}",
340                    'locale' => 'de',
341                    'keywords' => implode(', ', $service['synonyms'] ?? []),
342                    'translated' => true,
343                    'hash' => '',
344                    'id' => $service['id']
345                ],
346                'authorities' => [['id' => '1', 'name' => 'Stadtverwaltung München', 'webinfo' => 'https://muenchen.de']],
347                'locations' => [],
348                'leika' => $service['leikaId'] ?? null,
349                'public' => true,
350                'links' => [],
351                'forms' => [],
352                'appointment' => [
353                    'link' => $this->publicUrl . "/services/{serviceId}"
354                ],
355                'maxQuantity' => 1,
356                'showOnStartPage' => true,
357                'duration' => 30, // Default duration
358            ];
359
360            // Get combinable services
361            $combinableServices = $this->getServiceCombinations($service['id']);
362            if ($combinableServices) {
363                $mappedService['combinable'] = $combinableServices;
364            }
365
366            $mappedService['showOnStartPage'] = !in_array($service['id'], self::DONT_SHOW_SERVICE_ON_START_PAGE);
367
368            // Extract ZMS-specific fields
369            foreach ($service['fields'] ?? [] as $field) {
370                if (empty($field)) {
371                    continue;
372                }
373
374                if ($field['name'] === 'ZMS_MAX_ANZAHL') {
375                    $mappedService['maxQuantity'] = $field['value'];
376                }
377                if ($field['name'] === 'ZMS_DAUER') {
378                    $mappedService['duration'] = $field['value'];
379                }
380                if ($field['name'] === 'ZMS_INTERN') {
381                    $mappedService['public'] = !$field['value'];
382                }
383                if ($field['name'] === 'GEBUEHRENRAHMEN') {
384                    $mappedService['fees'] = $field['value'];
385                }
386                if ($field['name'] === 'FORMULARE_INFORMATIONEN') {
387                    foreach ($field['values'] ?? [] as $form) {
388                        $formData = ['name' => $form['label'], 'link' => $form['uri'], 'description' => false];
389                        $mappedService['forms'][] = $formData;
390                        $mappedService['links'][] = $formData;
391                    }
392                }
393            }
394
395            $mappedServices[] = $mappedService;
396        }
397
398        return [
399            'data' => $mappedServices,
400            'meta' => [
401                'generated' => $timestamp,
402                'datacount' => count($mappedServices),
403                'hash' => md5((string) json_encode($mappedServices))
404            ]
405        ];
406    }
407
408    /**
409     * Transform Munich SADB format to Berlin-compatible locations format.
410     * When $servicesData is provided, also returns services with each service's
411     * `locations` filled (same shape as dldb-mapper mapImport services output).
412     *
413     * @return array{locations: array, services: ?array{data: array, meta: array}}
414     */
415    public function transformLocations(array $data, ?array $servicesData = null): array
416    {
417        $mappedServices = $this->indexServicesByIds($servicesData);
418        $mappedLocations = [];
419
420        foreach ($data['locations'] ?? [] as $location) {
421            $processed = $this->processLocation($location, $mappedServices);
422            /** @psalm-suppress RiskyTruthyFalsyComparison */
423            if ($processed) {
424                $mappedLocations[] = $processed;
425            }
426        }
427
428        $mergedServicesPayload = null;
429        if ($servicesData !== null) {
430            $servicesList = [];
431            foreach ($servicesData['data'] ?? [] as $service) {
432                $id = $service['id'];
433                if (isset($mappedServices[$id])) {
434                    $servicesList[] = $mappedServices[$id];
435                }
436            }
437            $timestamp = date('Y-m-d\TH:i:s');
438            $mergedServicesPayload = [
439                'data' => $servicesList,
440                'meta' => [
441                    'generated' => $timestamp,
442                    'datacount' => count($servicesList),
443                    'hash' => md5((string) json_encode($servicesList)),
444                ],
445            ];
446        }
447
448        return [
449            'locations' => $this->buildLocationResponse($mappedLocations),
450            'services' => $mergedServicesPayload,
451        ];
452    }
453
454    protected function indexServicesByIds(?array $servicesData): array
455    {
456        $mappedServices = [];
457        /** @psalm-suppress RiskyTruthyFalsyComparison */
458        if ($servicesData) {
459            foreach ($servicesData['data'] ?? [] as $service) {
460                $mappedServices[$service['id']] = $service;
461            }
462        }
463        return $mappedServices;
464    }
465
466    private function processLocation(array $location, array &$mappedServices): ?array
467    {
468        if (!isset($location['altname2'])) {
469            return null;
470        }
471
472        $mappedLocation = $this->buildLocationMetadata($location);
473
474        $this->applyLocationRules($mappedLocation, $mappedLocation['displayName']);
475
476        $durationCommonDivisor = $this->processServiceReferences($location, $mappedLocation, $mappedServices);
477
478        $this->calculateSlotTimes($mappedLocation, $durationCommonDivisor);
479
480        return $mappedLocation;
481    }
482
483    private function buildLocationMetadata(array $location): array
484    {
485        $name = $location['altname2'];
486        $fullName = $name . (isset($location['altname1']) ? ' (' . $location['altname1'] . ')' : '');
487
488        return [
489            'id' => $location['id'],
490            'name' => $fullName,
491            'displayName' => $name,
492            'displayNameAlternatives' => $location['names'] ?? [],
493            'organization' => $location['organisation'] ?? null,
494            'organizationUnit' => $location['orgUnit'] ?? null,
495            'public' => $location['public'] ?? true,
496            'meta' => [
497                'url' => $this->publicUrl . "/locations/{locationId}",
498                'lastupdate' => date('Y-m-d\TH:i:s'),
499                'locale' => 'de',
500                'keywords' => implode(', ', $location['names'] ?? []),
501                'translated' => true,
502                'hash' => '',
503                'id' => $location['id']
504            ],
505            'address' => [
506                'house_number' => $location['address']['streetNumber'] ?? '',
507                'city' => $location['address']['city'] ?? 'München',
508                'postal_code' => $location['address']['zip'] ?? '',
509                'street' => $location['address']['street'] ?? '',
510                'hint' => false
511            ],
512            'geo' => isset($location['coordinate']) ? [
513                'lat' => $location['coordinate']['lat'],
514                'lon' => $location['coordinate']['lon']
515            ] : null,
516            'contact' => [
517                'email' => $location['email'] ?? '',
518                'fax' => $location['fax'] ?? '',
519                'phone' => $location['phone'] ?? '',
520                'signed_mail' => '0',
521                'signed_maillink' => '',
522                'webinfo' => '',
523                'competence' => ''
524            ],
525            'services' => []
526        ];
527    }
528
529    private function applyLocationRules(array &$mappedLocation, string $displayName): void
530    {
531        if (isset(self::LOCATION_PRIO_BY_DISPLAY_NAME[$displayName])) {
532            $mappedLocation['prio'] = self::LOCATION_PRIO_BY_DISPLAY_NAME[$displayName];
533        }
534
535        $mappedLocation['showAlternativeLocations'] = !in_array($mappedLocation['id'], self::EXCLUSIVE_LOCATIONS);
536
537        foreach (self::DONT_SHOW_LOCATION_BY_SERVICES as $avoidByServices) {
538            if (in_array((int) $mappedLocation['id'], $avoidByServices['locations'])) {
539                $mappedLocation['dontShowByServices'] = $avoidByServices['services'];
540                break;
541            }
542        }
543
544        // Mark locations that participate in the "exclusive vs mixed" disabled-services logic.
545        // Output group IDs so JumpIn with one office can auto-select the equivalent.
546        foreach (self::LOCATIONS_ALLOW_DISABLED_MIX as $group) {
547            if (in_array((int) $mappedLocation['id'], $group, true)) {
548                $mappedLocation['allowDisabledServicesMix'] = $group;
549                break;
550            }
551        }
552
553        // Peer offices that collapse to one Ort but share calendar / booking capacity.
554        foreach (self::LOCATIONS_SHARED_BOOKING as $group) {
555            if (in_array((int) $mappedLocation['id'], $group, true)) {
556                $mappedLocation['sharedBookingOfficeIds'] = $group;
557                break;
558            }
559        }
560    }
561
562    private function processServiceReferences(array $location, array &$mappedLocation, array &$mappedServices): ?int
563    {
564        $durationCommonDivisor = null;
565
566        foreach ($location['extendedServiceReferences'] ?? [] as $reference) {
567            if (!isset($mappedServices[$reference['refId']])) {
568                continue;
569            }
570
571            $serviceRef = [
572                'service' => $reference['refId'],
573                'contact' => [],
574                'hint' => false,
575                'url' => str_replace(['{serviceId}', '{locationId}'], [$reference['refId'], $mappedLocation['id']], $this->publicUrl . "/services/{serviceId}/locations/{locationId}"),
576                'appointment' => [
577                    'link' => str_replace(['{serviceId}', '{locationId}'], [$reference['refId'], $mappedLocation['id']], $this->publicUrl . "/services/{serviceId}/locations/{locationId}"),
578                    'slots' => '1',
579                    'external' => false,
580                    'allowed' => true
581                ],
582                'onlineprocessing' => [
583                    'description' => null,
584                    'link' => str_replace('{serviceId}', $reference['refId'], $this->publicUrl . "/services/{serviceId}")
585                ],
586                'duration' => $mappedServices[$reference['refId']]['duration'] ?? 30
587            ];
588
589            $locationRef = [
590                'location' => $mappedLocation['id'],
591                'authority' => [
592                    'id' => '1',
593                    'name' => 'Stadtverwaltung München',
594                    'webinfo' => 'https://muenchen.de'
595                ],
596                'url' => str_replace(['{serviceId}', '{locationId}'], [$reference['refId'], $mappedLocation['id']], $this->publicUrl . "/services/{serviceId}/locations/{locationId}"),
597                'appointment' => [
598                    'link' => str_replace(['{serviceId}', '{locationId}'], [$reference['refId'], $mappedLocation['id']], $this->publicUrl . "/services/{serviceId}/locations/{locationId}"),
599                    'slots' => '0',
600                    'external' => false,
601                    'multiple' => '1',
602                    'allowed' => true
603                ],
604                'responsibility' => null,
605                'responsibility_hint' => null,
606                'hint' => false
607            ];
608
609            if (isset($reference['public'])) {
610                $serviceRef['public'] = $reference['public'];
611            }
612
613            if (isset($reference['fields'])) {
614                foreach ($reference['fields'] as $field) {
615                    if ($field['name'] === 'ZMS_DAUER') {
616                        $serviceRef['duration'] = $field['value'];
617                    }
618                    if ($field['name'] === 'ZMS_MAX_ANZAHL') {
619                        $serviceRef['maxQuantity'] = $field['value'];
620                    }
621                    if ($field['name'] === 'ZMS_INTERN') {
622                        $serviceRef['public'] = !$field['value'];
623                    }
624                }
625            }
626
627            if ($durationCommonDivisor === null) {
628                $durationCommonDivisor = $serviceRef['duration'];
629            } else {
630                $durationCommonDivisor = $this->getSlotTime($durationCommonDivisor, $serviceRef['duration']);
631            }
632
633            $mappedLocation['services'][] = $serviceRef;
634            $mappedServices[$reference['refId']]['locations'][] = $locationRef;
635        }
636
637        return $durationCommonDivisor;
638    }
639
640    private function calculateSlotTimes(array &$mappedLocation, ?int $durationCommonDivisor): void
641    {
642        foreach ($mappedLocation['services'] as $key => $service) {
643            /** @psalm-suppress RiskyTruthyFalsyComparison */
644            if ($durationCommonDivisor && isset($service['duration'])) {
645                $mappedLocation['services'][$key]['appointment']['slots'] = (string) ((int)($service['duration'] / $durationCommonDivisor));
646            }
647        }
648
649        $mappedLocation['slotTimeInMinutes'] = $durationCommonDivisor;
650        $mappedLocation['forceSlotTimeUpdate'] = true;
651    }
652
653    private function buildLocationResponse(array $mappedLocations): array
654    {
655        return [
656            'data' => $mappedLocations,
657            'meta' => [
658                'generated' => date('Y-m-d\TH:i:s'),
659                'datacount' => count($mappedLocations),
660                'hash' => md5((string) json_encode($mappedLocations))
661            ]
662        ];
663    }
664
665    /**
666     * Get service combinations (services that can be booked together).
667     * Aligned with dldb-mapper map.php: if no SERVICE_COMBINATIONS row starts with this id,
668     * return a singleton list so combinable is always present in mapped services JSON.
669     */
670    protected function getServiceCombinations(int $serviceId): array
671    {
672        foreach (self::SERVICE_COMBINATIONS as $combo) {
673            /** @psalm-suppress TypeDoesNotContainType */
674            if (empty($combo)) {
675                continue;
676            }
677
678            if ($combo[0] === $serviceId) {
679                $list = array_merge([$serviceId], array_slice($combo, 1));
680                $list = array_map('intval', $list);
681                return array_values(array_unique($list, SORT_NUMERIC));
682            }
683        }
684
685        return [$serviceId];
686    }
687
688    /**
689     * Calculate greatest common divisor for slot times
690     */
691    protected function getSlotTime(int|string $a, int|string $b): int
692    {
693        $a = (int) $a;
694        $b = (int) $b;
695
696        if ($a <= 0 || $b <= 0) {
697            return 5;
698        }
699
700        $slotTime = $this->getGreatestCommonDivisor($a, $b);
701
702        if ($slotTime >= 5 && $slotTime % 5 === 0) {
703            return $slotTime;
704        }
705
706        return 5;
707    }
708
709    protected function getGreatestCommonDivisor(int $a, int $b): int
710    {
711        while ($b !== 0) {
712            $tmp = $b;
713            $b = $a % $b;
714            $a = $tmp;
715        }
716
717        return abs($a);
718    }
719}