Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.91% covered (success)
90.91%
120 / 132
77.78% covered (warning)
77.78%
7 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
ImportMappingResolver
90.91% covered (success)
90.91%
120 / 132
77.78% covered (warning)
77.78%
7 / 9
41.20
0.00% covered (danger)
0.00%
0 / 1
 resolve
93.59% covered (success)
93.59%
73 / 78
0.00% covered (danger)
0.00%
0 / 1
18.09
 mapPropertyType
46.15% covered (danger)
46.15%
6 / 13
0.00% covered (danger)
0.00%
0 / 1
29.89
 mapPurpose
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 normalizeKey
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 resolveStateId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolveCityId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 findStateId
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 findCityId
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 findNeighborhoodName
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace ImovelZapAi\Importing\Application\Services;
6
7use ImovelZapAi\Importing\Application\Data\ParsedProperty;
8use ImovelZapAi\Properties\Domain\Enums\PropertyPurpose;
9use ImovelZapAi\Properties\Domain\Enums\PropertyType;
10use ImovelZapAi\Properties\Infrastructure\Persistence\Eloquent\CityModel;
11use ImovelZapAi\Properties\Infrastructure\Persistence\Eloquent\NeighborhoodModel;
12use ImovelZapAi\Properties\Infrastructure\Persistence\Eloquent\StateModel;
13
14final class ImportMappingResolver
15{
16    /**
17     * @param  list<ParsedProperty>  $properties
18     * @param  array<string, mixed>  $manualMapping
19     * @return array{
20     *     mapping: array<string, mixed>,
21     *     ambiguities: list<array<string, mixed>>,
22     *     unknown_neighborhoods: list<string>
23     * }
24     */
25    public function resolve(array $properties, array $manualMapping = []): array
26    {
27        $typeMap = $manualMapping['property_types'] ?? [];
28        $purposeMap = $manualMapping['purposes'] ?? [];
29        $stateMap = $manualMapping['states'] ?? [];
30        $cityMap = $manualMapping['cities'] ?? [];
31        $neighborhoodMap = $manualMapping['neighborhoods'] ?? [];
32
33        $ambiguities = [];
34        $unknownNeighborhoods = [];
35
36        $autoTypes = [];
37        $autoPurposes = [];
38        $autoStates = [];
39        $autoCities = [];
40        $autoNeighborhoods = [];
41
42        foreach ($properties as $property) {
43            if ($property->propertyType !== null) {
44                $key = $this->normalizeKey($property->propertyType);
45                $autoTypes[$key] = $typeMap[$key] ?? $this->mapPropertyType($property->propertyType)?->value;
46                if ($autoTypes[$key] === null) {
47                    $ambiguities[] = [
48                        'field' => 'property_type',
49                        'raw' => $property->propertyType,
50                        'key' => $key,
51                    ];
52                }
53            }
54
55            if ($property->transactionType !== null) {
56                $key = $this->normalizeKey($property->transactionType);
57                $autoPurposes[$key] = $purposeMap[$key] ?? $this->mapPurpose($property->transactionType)?->value;
58                if ($autoPurposes[$key] === null) {
59                    $ambiguities[] = [
60                        'field' => 'purpose',
61                        'raw' => $property->transactionType,
62                        'key' => $key,
63                    ];
64                }
65            }
66
67            if ($property->state !== null) {
68                $key = $this->normalizeKey($property->state);
69                $autoStates[$key] = $stateMap[$key] ?? $this->findStateId($property->state);
70                if ($autoStates[$key] === null) {
71                    $ambiguities[] = [
72                        'field' => 'state',
73                        'raw' => $property->state,
74                        'key' => $key,
75                    ];
76                }
77            }
78
79            if ($property->city !== null && $property->state !== null) {
80                $stateKey = $this->normalizeKey($property->state);
81                $cityKey = $this->normalizeKey($property->city);
82                $composite = "{$stateKey}|{$cityKey}";
83                $stateId = $autoStates[$stateKey] ?? null;
84                $autoCities[$composite] = $cityMap[$composite] ?? ($stateId ? $this->findCityId((string) $stateId, $property->city) : null);
85                if ($autoCities[$composite] === null) {
86                    $ambiguities[] = [
87                        'field' => 'city',
88                        'raw' => $property->city,
89                        'key' => $composite,
90                        'state' => $property->state,
91                    ];
92                }
93            }
94
95            if ($property->neighborhood !== null && $property->city !== null && $property->state !== null) {
96                $stateKey = $this->normalizeKey($property->state);
97                $cityKey = $this->normalizeKey($property->city);
98                $neighborhoodKey = $this->normalizeKey($property->neighborhood);
99                $composite = "{$stateKey}|{$cityKey}|{$neighborhoodKey}";
100                $cityId = $autoCities["{$stateKey}|{$cityKey}"] ?? null;
101                $resolved = $neighborhoodMap[$composite] ?? ($cityId ? $this->findNeighborhoodName((string) $cityId, $property->neighborhood) : null);
102                $autoNeighborhoods[$composite] = $resolved;
103                if ($resolved === null) {
104                    $unknownNeighborhoods[] = $property->neighborhood;
105                    // Neighborhood can be created on import — not a hard ambiguity.
106                }
107            }
108        }
109
110        $uniqueAmbiguities = [];
111        foreach ($ambiguities as $item) {
112            $uniqueAmbiguities[$item['field'].'|'.$item['key']] = $item;
113        }
114
115        return [
116            'mapping' => [
117                'property_types' => array_filter($autoTypes, static fn ($v) => $v !== null),
118                'purposes' => array_filter($autoPurposes, static fn ($v) => $v !== null),
119                'states' => array_filter($autoStates, static fn ($v) => $v !== null),
120                'cities' => array_filter($autoCities, static fn ($v) => $v !== null),
121                'neighborhoods' => array_filter($autoNeighborhoods, static fn ($v) => $v !== null),
122                'issues' => $manualMapping['issues'] ?? [],
123            ],
124            'ambiguities' => array_values($uniqueAmbiguities),
125            'unknown_neighborhoods' => array_values(array_unique($unknownNeighborhoods)),
126        ];
127    }
128
129    public function mapPropertyType(string $raw): ?PropertyType
130    {
131        $key = $this->normalizeKey($raw);
132
133        return match ($key) {
134            'residential apartment', 'apartment', 'apartamento' => PropertyType::Apartment,
135            'residential home', 'home', 'house', 'casa' => PropertyType::House,
136            'residential condo', 'condo', 'casa condominio', 'casa em condominio' => PropertyType::CondoHouse,
137            'residential land lot', 'land', 'terreno' => PropertyType::Land,
138            'commercial', 'comercial' => PropertyType::Commercial,
139            'farm', 'sitio', 'fazenda', 'sitio fazenda' => PropertyType::Farm,
140            'kitnet' => PropertyType::Kitnet,
141            'studio' => PropertyType::Studio,
142            'penthouse', 'cobertura' => PropertyType::Penthouse,
143            default => PropertyType::tryFrom($key),
144        };
145    }
146
147    public function mapPurpose(string $raw): ?PropertyPurpose
148    {
149        $key = $this->normalizeKey($raw);
150
151        return match ($key) {
152            'sale', 'for sale', 'venda' => PropertyPurpose::Sale,
153            'rent', 'for rent', 'aluguel', 'locacao', 'locação' => PropertyPurpose::Rent,
154            default => PropertyPurpose::tryFrom($key),
155        };
156    }
157
158    public function normalizeKey(string $value): string
159    {
160        $value = mb_strtolower(trim($value));
161        $value = strtr($value, [
162            'á' => 'a', 'à' => 'a', 'ã' => 'a', 'â' => 'a',
163            'é' => 'e', 'ê' => 'e',
164            'í' => 'i',
165            'ó' => 'o', 'ô' => 'o', 'õ' => 'o',
166            'ú' => 'u',
167            'ç' => 'c',
168        ]);
169        $value = str_replace(['/', '-', '_'], ' ', $value);
170        $value = preg_replace('/\s+/', ' ', $value) ?? $value;
171
172        return trim($value);
173    }
174
175    public function resolveStateId(string $raw): ?string
176    {
177        return $this->findStateId($raw);
178    }
179
180    public function resolveCityId(string $stateId, string $raw): ?string
181    {
182        return $this->findCityId($stateId, $raw);
183    }
184
185    private function findStateId(string $raw): ?string
186    {
187        $normalized = $this->normalizeKey($raw);
188        $code = strtoupper(trim($raw));
189
190        $byCode = StateModel::query()->where('code', $code)->first();
191        if ($byCode !== null) {
192            return $byCode->id;
193        }
194
195        return StateModel::query()
196            ->get()
197            ->first(fn (StateModel $state) => $this->normalizeKey($state->name) === $normalized)
198            ?->id;
199    }
200
201    private function findCityId(string $stateId, string $raw): ?string
202    {
203        $normalized = $this->normalizeKey($raw);
204
205        return CityModel::query()
206            ->where('state_id', $stateId)
207            ->get()
208            ->first(fn (CityModel $city) => $this->normalizeKey($city->name) === $normalized)
209            ?->id;
210    }
211
212    private function findNeighborhoodName(string $cityId, string $raw): ?string
213    {
214        $normalized = $this->normalizeKey($raw);
215
216        $neighborhood = NeighborhoodModel::query()
217            ->where('city_id', $cityId)
218            ->get()
219            ->first(fn (NeighborhoodModel $item) => $this->normalizeKey($item->name) === $normalized);
220
221        return $neighborhood?->name;
222    }
223}