Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.96% covered (success)
95.96%
95 / 99
50.00% covered (danger)
50.00%
2 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
ImportListingValidator
95.96% covered (success)
95.96%
95 / 99
50.00% covered (danger)
50.00%
2 / 4
37
0.00% covered (danger)
0.00%
0 / 1
 validate
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
6
 validateOne
94.64% covered (success)
94.64%
53 / 56
0.00% covered (danger)
0.00%
0 / 1
28.12
 isRent
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 error
100.00% covered (success)
100.00%
8 / 8
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\Importing\Application\Data\ParseIssue;
9use ImovelZapAi\Importing\Domain\Enums\ImportIssueSeverity;
10
11final class ImportListingValidator
12{
13    /**
14     * @param  list<ParsedProperty>  $properties
15     * @return list<ParseIssue>
16     */
17    public function validate(array $properties): array
18    {
19        $issues = [];
20        $seenHashes = [];
21        $seenIds = [];
22
23        foreach ($properties as $property) {
24            $issues = array_merge($issues, $this->validateOne($property));
25
26            $externalId = $property->externalId;
27            if ($externalId === null || $externalId === '') {
28                continue;
29            }
30
31            $hash = $property->payloadHash();
32
33            if (isset($seenIds[$externalId])) {
34                $previousHash = $seenHashes[$externalId] ?? null;
35                if ($previousHash === $hash) {
36                    $issues[] = new ParseIssue(
37                        ImportIssueSeverity::Warning,
38                        'duplicate_identical',
39                        "ListingID \"{$externalId}\" repetido com payload idêntico — repetição será ignorada.",
40                        externalId: $externalId,
41                        listingIndex: $property->index,
42                        path: 'Listing/ListingID',
43                    );
44                } else {
45                    $issues[] = new ParseIssue(
46                        ImportIssueSeverity::Error,
47                        'duplicate_conflict',
48                        "ListingID \"{$externalId}\" repetido com payload diferente.",
49                        externalId: $externalId,
50                        listingIndex: $property->index,
51                        path: 'Listing/ListingID',
52                    );
53                }
54            } else {
55                $seenIds[$externalId] = $property->index;
56                $seenHashes[$externalId] = $hash;
57            }
58        }
59
60        return $issues;
61    }
62
63    /**
64     * @return list<ParseIssue>
65     */
66    private function validateOne(ParsedProperty $property): array
67    {
68        $issues = [];
69
70        if ($property->externalId === null || $property->externalId === '') {
71            $issues[] = $this->error($property, 'missing_listing_id', 'ListingID é obrigatório.', 'Listing/ListingID');
72        }
73
74        if ($property->title === null || $property->title === '') {
75            $issues[] = $this->error($property, 'missing_title', 'Title é obrigatório.', 'Listing/Title');
76        }
77
78        if ($property->propertyType === null || $property->propertyType === '') {
79            $issues[] = $this->error($property, 'missing_property_type', 'PropertyType é obrigatório.', 'Listing/PropertyType');
80        }
81
82        if ($property->transactionType === null || $property->transactionType === '') {
83            $issues[] = $this->error($property, 'missing_transaction_type', 'TransactionType é obrigatório.', 'Listing/TransactionType');
84        }
85
86        $isRent = $this->isRent($property->transactionType);
87        $price = $isRent ? $property->rentalPrice : $property->listPrice;
88        $pricePath = $isRent ? 'Listing/RentalPrice' : 'Listing/ListPrice';
89
90        if ($price === null || $price === '' || ! is_numeric(str_replace(['.', ','], ['', '.'], $price))) {
91            $issues[] = $this->error($property, 'missing_price', 'Preço obrigatório para a finalidade informada.', $pricePath);
92        }
93
94        if ($property->city === null || $property->city === '') {
95            $issues[] = $this->error($property, 'missing_city', 'City é obrigatória.', 'Listing/Location/City');
96        }
97
98        if ($property->state === null || $property->state === '') {
99            $issues[] = $this->error($property, 'missing_state', 'State é obrigatório.', 'Listing/Location/State');
100        }
101
102        if ($property->media === []) {
103            $issues[] = new ParseIssue(
104                ImportIssueSeverity::Warning,
105                'missing_media',
106                'Nenhuma foto informada.',
107                externalId: $property->externalId,
108                listingIndex: $property->index,
109                path: 'Listing/Media',
110            );
111        }
112
113        foreach ($property->media as $media) {
114            if (! filter_var($media->url, FILTER_VALIDATE_URL) || ! preg_match('#^https?://#i', $media->url)) {
115                $issues[] = $this->error(
116                    $property,
117                    'invalid_media_url',
118                    "URL de mídia inválida: {$media->url}",
119                    'Listing/Media/Item',
120                );
121            }
122        }
123
124        if ($property->latitude !== null && $property->longitude !== null) {
125            if (! is_numeric($property->latitude) || ! is_numeric($property->longitude)) {
126                $issues[] = new ParseIssue(
127                    ImportIssueSeverity::Warning,
128                    'invalid_coordinates',
129                    'Coordenadas inválidas serão ignoradas.',
130                    externalId: $property->externalId,
131                    listingIndex: $property->index,
132                    path: 'Listing/Location',
133                );
134            }
135        }
136
137        if (
138            isset($property->raw['DetailViewUrl'], $property->raw['DetailUrl'])
139            && $property->raw['DetailViewUrl'] !== $property->raw['DetailUrl']
140        ) {
141            $issues[] = new ParseIssue(
142                ImportIssueSeverity::Warning,
143                'alias_conflict',
144                'DetailViewUrl e DetailUrl conflitantes — usando DetailViewUrl.',
145                externalId: $property->externalId,
146                listingIndex: $property->index,
147                path: 'Listing/DetailViewUrl',
148            );
149        }
150
151        return $issues;
152    }
153
154    private function isRent(?string $transactionType): bool
155    {
156        if ($transactionType === null) {
157            return false;
158        }
159
160        $normalized = mb_strtolower(trim($transactionType));
161
162        return in_array($normalized, ['aluguel', 'rent', 'for rent', 'locação', 'locacao'], true);
163    }
164
165    private function error(ParsedProperty $property, string $code, string $message, string $path): ParseIssue
166    {
167        return new ParseIssue(
168            ImportIssueSeverity::Error,
169            $code,
170            $message,
171            externalId: $property->externalId,
172            listingIndex: $property->index,
173            path: $path,
174        );
175    }
176}