Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.41% covered (success)
97.41%
113 / 116
62.50% covered (warning)
62.50%
5 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
GroupOlxListingMapper
97.41% covered (success)
97.41%
113 / 116
62.50% covered (warning)
62.50%
5 / 8
37
0.00% covered (danger)
0.00%
0 / 1
 map
98.44% covered (success)
98.44%
63 / 64
0.00% covered (danger)
0.00%
0 / 1
7
 mapMany
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 validateRequired
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
16
 characteristicMap
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 media
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 country
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 optionalUrl
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 isValidUrl
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace ImovelZapAi\Publishing\Connectors\GroupOlx\Application\Mappers;
6
7use ImovelZapAi\Publishing\Connectors\GroupOlx\Application\Data\GroupOlxMappedListingData;
8use ImovelZapAi\Publishing\Connectors\GroupOlx\Application\Ports\GroupOlxListingMapperInterface;
9use ImovelZapAi\Publishing\Connectors\GroupOlx\Domain\Enums\GroupOlxPropertyType;
10use ImovelZapAi\Publishing\Connectors\GroupOlx\Domain\Enums\GroupOlxTransactionType;
11use ImovelZapAi\Publishing\Connectors\GroupOlx\Domain\Exceptions\GroupOlxMappingException;
12use ImovelZapAi\Publishing\Domain\Canonical\NormalizedListing;
13use ImovelZapAi\Publishing\Domain\Canonical\NormalizedListingCharacteristic;
14use ImovelZapAi\Publishing\Domain\Canonical\NormalizedListingPhoto;
15use ImovelZapAi\Publishing\Domain\Feeds\VrSync\ListingDataFeedDetails;
16use ImovelZapAi\Publishing\Domain\Feeds\VrSync\ListingDataFeedListing;
17use ImovelZapAi\Publishing\Domain\Feeds\VrSync\ListingDataFeedLocation;
18use ImovelZapAi\Publishing\Domain\Feeds\VrSync\ListingDataFeedMediaItem;
19use ImovelZapAi\Publishing\Domain\Feeds\VrSync\VrSyncEnumerations;
20
21/**
22 * NormalizedListing → DTO VRSync (Grupo OLX).
23 *
24 * Aplica enums oficiais, valida obrigatórios e não gera XML.
25 */
26final class GroupOlxListingMapper implements GroupOlxListingMapperInterface
27{
28    public function map(NormalizedListing $listing): GroupOlxMappedListingData
29    {
30        $errors = $this->validateRequired($listing);
31
32        $transactionType = GroupOlxTransactionType::tryFromPurpose($listing->purpose);
33        if ($transactionType === null) {
34            $errors[] = 'Purpose/TransactionType inválido. Use Venda, Aluguel ou Sale/Rent.';
35        }
36
37        $propertyType = GroupOlxPropertyType::tryFromInternal($listing->type);
38        if ($propertyType === null) {
39            $errors[] = "PropertyType \"{$listing->type}\" não mapeado para VRSync.";
40        }
41
42        $currency = strtoupper(trim($listing->currency));
43        if (! VrSyncEnumerations::isCurrency($currency)) {
44            $errors[] = 'Currency deve ser BRL.';
45        }
46
47        if ($errors !== []) {
48            throw GroupOlxMappingException::withErrors($errors);
49        }
50
51        /** @var GroupOlxTransactionType $transactionType */
52        /** @var GroupOlxPropertyType $propertyType */
53        $now = now()->format('Y-m-d\TH:i:s');
54        $address = $listing->address;
55        $characteristics = $this->characteristicMap($listing->characteristics);
56        $formattedPrice = number_format((float) $listing->price, 2, '.', '');
57        $listPrice = $transactionType->usesListPrice() ? $formattedPrice : null;
58        $rentalPrice = $transactionType->usesRentalPrice() ? $formattedPrice : null;
59
60        $vrSyncListing = new ListingDataFeedListing(
61            listingId: trim($listing->code),
62            title: trim($listing->title),
63            transactionType: $transactionType->value,
64            purpose: $transactionType->label(),
65            status: trim($listing->status),
66            details: new ListingDataFeedDetails(
67                propertyType: $propertyType->value,
68                description: trim($listing->description),
69                listPrice: $listPrice,
70                currency: $currency,
71                bedrooms: $characteristics['bedrooms'] ?? null,
72                suites: $characteristics['suites'] ?? null,
73                bathrooms: $characteristics['bathrooms'] ?? null,
74                garage: $characteristics['parking_spaces'] ?? null,
75                livingArea: $characteristics['useful_area'] ?? null,
76                lotArea: $characteristics['total_area'] ?? null,
77                condominiumFee: $characteristics['condominium_fee'] ?? null,
78                iptu: $characteristics['iptu'] ?? null,
79            ),
80            location: new ListingDataFeedLocation(
81                country: $this->country($address->country),
82                state: trim($address->state),
83                city: trim($address->city),
84                neighborhood: trim($address->neighborhood),
85                postalCode: trim($address->postalCode),
86                street: trim($address->street),
87                number: trim($address->number),
88                complement: trim($address->complement),
89                address: $address->formatted(),
90                latitude: $address->latitude,
91                longitude: $address->longitude,
92            ),
93            media: $this->media($listing),
94            detailUrl: $this->optionalUrl($listing->detailUrl),
95            listDate: $now,
96            lastUpdateDate: $now,
97        );
98
99        return new GroupOlxMappedListingData(
100            listing: $vrSyncListing,
101            transactionType: $transactionType,
102            propertyType: $propertyType,
103            usageType: $propertyType->usageType(),
104            listPrice: $listPrice,
105            rentalPrice: $rentalPrice,
106        );
107    }
108
109    public function mapMany(array $listings): array
110    {
111        return array_map(
112            fn (NormalizedListing $listing): GroupOlxMappedListingData => $this->map($listing),
113            $listings,
114        );
115    }
116
117    /** @return list<string> */
118    private function validateRequired(NormalizedListing $listing): array
119    {
120        $errors = [];
121
122        if (trim($listing->code) === '') {
123            $errors[] = 'ListingID é obrigatório.';
124        }
125
126        if (trim($listing->title) === '') {
127            $errors[] = 'Title é obrigatório.';
128        }
129
130        if (trim($listing->description) === '') {
131            $errors[] = 'Description é obrigatória.';
132        }
133
134        if ($listing->price === null || trim($listing->price) === '' || (float) $listing->price <= 0) {
135            $errors[] = 'Preço válido é obrigatório.';
136        }
137
138        if (trim($listing->type) === '') {
139            $errors[] = 'PropertyType é obrigatório.';
140        }
141
142        if (trim($listing->purpose) === '') {
143            $errors[] = 'Purpose/TransactionType é obrigatório.';
144        }
145
146        if (trim($listing->address->city) === '') {
147            $errors[] = 'City é obrigatória.';
148        }
149
150        if (trim($listing->address->state) === '') {
151            $errors[] = 'State é obrigatório.';
152        }
153
154        if ($listing->detailUrl !== null && trim($listing->detailUrl) !== '' && ! $this->isValidUrl($listing->detailUrl)) {
155            $errors[] = 'DetailUrl deve ser uma URL válida.';
156        }
157
158        foreach ($listing->photos as $index => $photo) {
159            if (! $this->isValidUrl($photo->url)) {
160                $errors[] = 'Media #'.($index + 1).': URL inválida.';
161            }
162        }
163
164        return $errors;
165    }
166
167    /**
168     * @param  list<NormalizedListingCharacteristic>  $characteristics
169     * @return array<string, string>
170     */
171    private function characteristicMap(array $characteristics): array
172    {
173        $map = [];
174
175        foreach ($characteristics as $characteristic) {
176            $value = trim($characteristic->value);
177
178            if ($value === '') {
179                continue;
180            }
181
182            $map[$characteristic->key] = $value;
183        }
184
185        return $map;
186    }
187
188    /**
189     * @return list<ListingDataFeedMediaItem>
190     */
191    private function media(NormalizedListing $listing): array
192    {
193        return array_values(array_map(
194            static fn (NormalizedListingPhoto $photo): ListingDataFeedMediaItem => new ListingDataFeedMediaItem(
195                url: trim($photo->url),
196                isPrimary: $photo->isPrimary,
197                caption: $photo->caption !== null ? trim($photo->caption) : null,
198            ),
199            $listing->photos,
200        ));
201    }
202
203    private function country(string $country): string
204    {
205        $normalized = strtoupper(trim($country));
206
207        return match ($normalized) {
208            '', 'BR', 'BRA', 'BRASIL' => 'Brasil',
209            default => trim($country),
210        };
211    }
212
213    private function optionalUrl(?string $url): ?string
214    {
215        if ($url === null || trim($url) === '') {
216            return null;
217        }
218
219        return trim($url);
220    }
221
222    private function isValidUrl(string $url): bool
223    {
224        return filter_var(trim($url), FILTER_VALIDATE_URL) !== false
225            && in_array(parse_url(trim($url), PHP_URL_SCHEME), ['http', 'https'], true);
226    }
227}