Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.71% covered (success)
90.71%
166 / 183
44.44% covered (danger)
44.44%
4 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
EloquentPropertyImportWriter
90.71% covered (success)
90.71%
166 / 183
44.44% covered (danger)
44.44%
4 / 9
39.16
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 write
84.21% covered (warning)
84.21%
32 / 38
0.00% covered (danger)
0.00%
0 / 1
6.14
 createNew
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
1
 updateExisting
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
1
 buildAttributes
88.33% covered (warning)
88.33%
53 / 60
0.00% covered (danger)
0.00%
0 / 1
11.19
 importPhotos
92.31% covered (success)
92.31%
24 / 26
0.00% covered (danger)
0.00%
0 / 1
6.02
 snapshot
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
4.01
 normalizeDecimal
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
4.13
 nullableNumeric
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3declare(strict_types=1);
4
5namespace ImovelZapAi\Importing\Infrastructure\Persistence;
6
7use App\Models\User;
8use Illuminate\Support\Facades\DB;
9use ImovelZapAi\Importing\Application\Data\ImportWriteResult;
10use ImovelZapAi\Importing\Application\Data\ParsedProperty;
11use ImovelZapAi\Importing\Application\Ports\PropertyImportWriterInterface;
12use ImovelZapAi\Importing\Application\Services\ImportMappingResolver;
13use ImovelZapAi\Importing\Application\Services\SafeRemoteMediaDownloader;
14use ImovelZapAi\Importing\Domain\Enums\ImportItemResult;
15use ImovelZapAi\Importing\Infrastructure\Persistence\Eloquent\PropertyImportSourceModel;
16use ImovelZapAi\Properties\Application\Services\InternalCodeGenerator;
17use ImovelZapAi\Properties\Application\Services\NeighborhoodResolver;
18use ImovelZapAi\Properties\Domain\Enums\PropertyPurpose;
19use ImovelZapAi\Properties\Domain\Enums\PropertyStatus;
20use ImovelZapAi\Properties\Domain\Enums\PropertyType;
21use ImovelZapAi\Properties\Infrastructure\Persistence\Eloquent\CityModel;
22use ImovelZapAi\Properties\Infrastructure\Persistence\Eloquent\PropertyImageModel;
23use ImovelZapAi\Properties\Infrastructure\Persistence\Eloquent\PropertyModel;
24
25final class EloquentPropertyImportWriter implements PropertyImportWriterInterface
26{
27    public function __construct(
28        private readonly ImportMappingResolver $mappingResolver,
29        private readonly NeighborhoodResolver $neighborhoodResolver,
30        private readonly InternalCodeGenerator $internalCodeGenerator,
31        private readonly SafeRemoteMediaDownloader $mediaDownloader,
32    ) {}
33
34    public function write(
35        string $tenantId,
36        User $actor,
37        ParsedProperty $property,
38        array $mapping,
39        string $source,
40    ): ImportWriteResult {
41        return DB::transaction(function () use ($tenantId, $actor, $property, $mapping, $source): ImportWriteResult {
42            $externalId = (string) $property->externalId;
43            $payloadHash = $property->payloadHash();
44
45            /** @var PropertyImportSourceModel|null $identity */
46            $identity = PropertyImportSourceModel::query()
47                ->where('tenant_id', $tenantId)
48                ->where('source', $source)
49                ->where('external_id', $externalId)
50                ->lockForUpdate()
51                ->first();
52
53            if ($identity !== null) {
54                $existing = PropertyModel::query()
55                    ->withTrashed()
56                    ->whereKey($identity->property_id)
57                    ->first();
58
59                if ($existing === null) {
60                    return new ImportWriteResult(
61                        ImportItemResult::Failed,
62                        null,
63                        0,
64                        message: 'Identidade externa aponta para imóvel inexistente.',
65                    );
66                }
67
68                if ($existing->trashed()) {
69                    return new ImportWriteResult(
70                        ImportItemResult::Ignored,
71                        $existing->id,
72                        0,
73                        message: 'Imóvel soft-deleted — confirme restauração manualmente.',
74                    );
75                }
76
77                if ($identity->payload_hash !== null && hash_equals((string) $identity->payload_hash, $payloadHash)) {
78                    $identity->forceFill(['last_seen_at' => now()])->save();
79
80                    return new ImportWriteResult(
81                        ImportItemResult::Unchanged,
82                        $existing->id,
83                        0,
84                    );
85                }
86
87                return $this->updateExisting($existing, $identity, $property, $mapping, $payloadHash);
88            }
89
90            return $this->createNew($tenantId, $actor, $property, $mapping, $source, $payloadHash);
91        });
92    }
93
94    /**
95     * @param  array<string, mixed>  $mapping
96     */
97    private function createNew(
98        string $tenantId,
99        User $actor,
100        ParsedProperty $property,
101        array $mapping,
102        string $source,
103        string $payloadHash,
104    ): ImportWriteResult {
105        $attributes = $this->buildAttributes($property, $mapping, null);
106        $attributes['tenant_id'] = $tenantId;
107        $attributes['created_by'] = $actor->id;
108        $attributes['internal_code'] = $this->internalCodeGenerator->generate($tenantId);
109        $attributes['status'] = PropertyStatus::Available;
110
111        $model = PropertyModel::query()->create($attributes);
112        $photos = $this->importPhotos($model, $property);
113
114        PropertyImportSourceModel::query()->create([
115            'tenant_id' => $tenantId,
116            'property_id' => $model->id,
117            'source' => $source,
118            'external_id' => (string) $property->externalId,
119            'payload_hash' => $payloadHash,
120            'last_seen_at' => now(),
121        ]);
122
123        return new ImportWriteResult(
124            ImportItemResult::Created,
125            $model->id,
126            $photos,
127            diffAfter: $this->snapshot($model->fresh()),
128        );
129    }
130
131    /**
132     * @param  array<string, mixed>  $mapping
133     */
134    private function updateExisting(
135        PropertyModel $existing,
136        PropertyImportSourceModel $identity,
137        ParsedProperty $property,
138        array $mapping,
139        string $payloadHash,
140    ): ImportWriteResult {
141        $before = $this->snapshot($existing);
142        $attributes = $this->buildAttributes($property, $mapping, (string) $existing->internal_code);
143        // Preserve creator and internal_code explicitly.
144        unset($attributes['created_by']);
145
146        $existing->fill($attributes)->save();
147        $photos = $this->importPhotos($existing, $property);
148
149        $identity->forceFill([
150            'payload_hash' => $payloadHash,
151            'last_seen_at' => now(),
152        ])->save();
153
154        $fresh = $existing->fresh();
155
156        return new ImportWriteResult(
157            ImportItemResult::Updated,
158            $existing->id,
159            $photos,
160            diffBefore: $before,
161            diffAfter: $this->snapshot($fresh),
162        );
163    }
164
165    /**
166     * @param  array<string, mixed>  $mapping
167     * @return array<string, mixed>
168     */
169    private function buildAttributes(ParsedProperty $property, array $mapping, ?string $internalCode): array
170    {
171        $typeKey = $this->mappingResolver->normalizeKey((string) $property->propertyType);
172        $purposeKey = $this->mappingResolver->normalizeKey((string) $property->transactionType);
173        $stateKey = $this->mappingResolver->normalizeKey((string) $property->state);
174        $cityKey = $this->mappingResolver->normalizeKey((string) $property->city);
175        $cityComposite = "{$stateKey}|{$cityKey}";
176
177        $typeValue = $mapping['property_types'][$typeKey]
178            ?? $this->mappingResolver->mapPropertyType((string) $property->propertyType)?->value;
179        $purposeValue = $mapping['purposes'][$purposeKey]
180            ?? $this->mappingResolver->mapPurpose((string) $property->transactionType)?->value;
181
182        if ($typeValue === null || $purposeValue === null) {
183            throw new \InvalidArgumentException('Mapeamento incompleto para tipo/finalidade.');
184        }
185
186        $stateId = $mapping['states'][$stateKey]
187            ?? $this->mappingResolver->resolveStateId((string) $property->state);
188
189        $cityId = $mapping['cities'][$cityComposite] ?? null;
190        if ($cityId === null && $stateId !== null) {
191            $cityId = $this->mappingResolver->resolveCityId((string) $stateId, (string) $property->city);
192
193            if ($cityId === null) {
194                $city = CityModel::query()->firstOrCreate(
195                    [
196                        'state_id' => $stateId,
197                        'name' => (string) $property->city,
198                    ],
199                );
200                $cityId = $city->id;
201            }
202        }
203
204        if ($stateId === null || $cityId === null) {
205            throw new \InvalidArgumentException('Mapeamento incompleto para estado/cidade.');
206        }
207
208        $neighborhoodName = (string) ($property->neighborhood ?: 'Centro');
209        $neighborhoodKey = $this->mappingResolver->normalizeKey($neighborhoodName);
210        $neighborhoodComposite = "{$stateKey}|{$cityKey}|{$neighborhoodKey}";
211        $neighborhoodName = $mapping['neighborhoods'][$neighborhoodComposite] ?? $neighborhoodName;
212        $neighborhood = $this->neighborhoodResolver->resolve((string) $cityId, $neighborhoodName);
213
214        $purpose = PropertyPurpose::from((string) $purposeValue);
215        $price = $purpose === PropertyPurpose::Rent
216            ? $this->normalizeDecimal($property->rentalPrice)
217            : $this->normalizeDecimal($property->listPrice);
218
219        return [
220            'internal_code' => $internalCode,
221            'title' => (string) $property->title,
222            'type' => PropertyType::from((string) $typeValue),
223            'purpose' => $purpose,
224            'price' => $price,
225            'cep' => $property->postalCode,
226            'street' => $property->address,
227            'number' => $property->streetNumber,
228            'complement' => $property->complement,
229            'latitude' => $this->nullableNumeric($property->latitude),
230            'longitude' => $this->nullableNumeric($property->longitude),
231            'bedrooms' => (int) ($property->bedrooms ?? 0),
232            'bathrooms' => (int) ($property->bathrooms ?? 0),
233            'suites' => (int) ($property->suites ?? 0),
234            'parking_spaces' => (int) ($property->garage ?? 0),
235            'useful_area' => $this->nullableNumeric($property->livingArea),
236            'total_area' => $this->nullableNumeric($property->lotArea),
237            'short_description' => $property->description !== null ? mb_substr($property->description, 0, 255) : null,
238            'full_description' => $property->description,
239            'listing_url' => $property->detailUrl,
240            'state_id' => $stateId,
241            'city_id' => $cityId,
242            'neighborhood_id' => $neighborhood->id,
243        ];
244    }
245
246    private function importPhotos(PropertyModel $property, ParsedProperty $parsed): int
247    {
248        $imported = 0;
249        $existingUrls = [];
250
251        foreach ($parsed->media as $media) {
252            $normalizedUrl = rtrim($media->url, '/');
253            if (isset($existingUrls[$normalizedUrl])) {
254                continue;
255            }
256            $existingUrls[$normalizedUrl] = true;
257
258            $downloaded = $this->mediaDownloader->download($media->url, $property->id, $media->order);
259            if ($downloaded === null) {
260                continue;
261            }
262
263            $already = PropertyImageModel::query()
264                ->where('property_id', $property->id)
265                ->where('path', $downloaded['path'])
266                ->exists();
267
268            if ($already) {
269                continue;
270            }
271
272            PropertyImageModel::query()->create([
273                'tenant_id' => $property->tenant_id,
274                'property_id' => $property->id,
275                'path' => $downloaded['path'],
276                'caption' => $media->caption,
277                'order' => $media->order,
278                'is_cover' => $media->isPrimary || $media->order === 0,
279            ]);
280
281            $imported++;
282        }
283
284        return $imported;
285    }
286
287    /**
288     * @return array<string, mixed>
289     */
290    private function snapshot(?PropertyModel $property): array
291    {
292        if ($property === null) {
293            return [];
294        }
295
296        return [
297            'id' => $property->id,
298            'internal_code' => $property->internal_code,
299            'title' => $property->title,
300            'price' => (string) $property->price,
301            'type' => $property->type instanceof PropertyType ? $property->type->value : (string) $property->type,
302            'purpose' => $property->purpose instanceof PropertyPurpose ? $property->purpose->value : (string) $property->purpose,
303            'city_id' => $property->city_id,
304            'neighborhood_id' => $property->neighborhood_id,
305        ];
306    }
307
308    private function normalizeDecimal(?string $value): string
309    {
310        if ($value === null || $value === '') {
311            return '0';
312        }
313
314        $normalized = str_replace(['.', ' '], ['', ''], $value);
315        $normalized = str_replace(',', '.', $normalized);
316
317        return is_numeric($normalized) ? (string) $normalized : '0';
318    }
319
320    private function nullableNumeric(?string $value): ?string
321    {
322        if ($value === null || $value === '' || ! is_numeric($value)) {
323            return null;
324        }
325
326        return (string) $value;
327    }
328}