Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
97.41% |
113 / 116 |
|
62.50% |
5 / 8 |
CRAP | |
0.00% |
0 / 1 |
| GroupOlxListingMapper | |
97.41% |
113 / 116 |
|
62.50% |
5 / 8 |
37 | |
0.00% |
0 / 1 |
| map | |
98.44% |
63 / 64 |
|
0.00% |
0 / 1 |
7 | |||
| mapMany | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| validateRequired | |
100.00% |
23 / 23 |
|
100.00% |
1 / 1 |
16 | |||
| characteristicMap | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
3.03 | |||
| media | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
2 | |||
| country | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
| optionalUrl | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
3.33 | |||
| isValidUrl | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace ImovelZapAi\Publishing\Connectors\GroupOlx\Application\Mappers; |
| 6 | |
| 7 | use ImovelZapAi\Publishing\Connectors\GroupOlx\Application\Data\GroupOlxMappedListingData; |
| 8 | use ImovelZapAi\Publishing\Connectors\GroupOlx\Application\Ports\GroupOlxListingMapperInterface; |
| 9 | use ImovelZapAi\Publishing\Connectors\GroupOlx\Domain\Enums\GroupOlxPropertyType; |
| 10 | use ImovelZapAi\Publishing\Connectors\GroupOlx\Domain\Enums\GroupOlxTransactionType; |
| 11 | use ImovelZapAi\Publishing\Connectors\GroupOlx\Domain\Exceptions\GroupOlxMappingException; |
| 12 | use ImovelZapAi\Publishing\Domain\Canonical\NormalizedListing; |
| 13 | use ImovelZapAi\Publishing\Domain\Canonical\NormalizedListingCharacteristic; |
| 14 | use ImovelZapAi\Publishing\Domain\Canonical\NormalizedListingPhoto; |
| 15 | use ImovelZapAi\Publishing\Domain\Feeds\VrSync\ListingDataFeedDetails; |
| 16 | use ImovelZapAi\Publishing\Domain\Feeds\VrSync\ListingDataFeedListing; |
| 17 | use ImovelZapAi\Publishing\Domain\Feeds\VrSync\ListingDataFeedLocation; |
| 18 | use ImovelZapAi\Publishing\Domain\Feeds\VrSync\ListingDataFeedMediaItem; |
| 19 | use 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 | */ |
| 26 | final 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 | } |