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