Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
96.43% |
135 / 140 |
|
78.57% |
11 / 14 |
CRAP | |
0.00% |
0 / 1 |
| VrsyncXmlBuilder | |
96.43% |
135 / 140 |
|
78.57% |
11 / 14 |
47 | |
0.00% |
0 / 1 |
| build | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
| buildFromMapped | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
3 | |||
| render | |
90.00% |
27 / 30 |
|
0.00% |
0 / 1 |
6.04 | |||
| headerElement | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
3 | |||
| listingElement | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
8 | |||
| detailsElement | |
100.00% |
27 / 27 |
|
100.00% |
1 / 1 |
6 | |||
| locationElement | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
2 | |||
| mediaElement | |
90.00% |
9 / 10 |
|
0.00% |
0 / 1 |
5.03 | |||
| appendText | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| appendOptionalText | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
3 | |||
| appendCurrencyAmount | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
| appendIptu | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
| appendCdata | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| sanitizeCdata | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace ImovelZapAi\Publishing\Connectors\GroupOlx\Application\Services; |
| 6 | |
| 7 | use DOMDocument; |
| 8 | use DOMElement; |
| 9 | use ImovelZapAi\Publishing\Connectors\GroupOlx\Application\Data\GroupOlxMappedListingData; |
| 10 | use ImovelZapAi\Publishing\Connectors\GroupOlx\Application\Ports\VrsyncXmlBuilderInterface; |
| 11 | use ImovelZapAi\Publishing\Connectors\GroupOlx\Domain\Exceptions\GroupOlxXmlBuildException; |
| 12 | use ImovelZapAi\Publishing\Connectors\GroupOlx\Domain\GroupOlxConnector; |
| 13 | use ImovelZapAi\Publishing\Domain\Feeds\VrSync\ListingDataFeed; |
| 14 | use ImovelZapAi\Publishing\Domain\Feeds\VrSync\ListingDataFeedDetails; |
| 15 | use ImovelZapAi\Publishing\Domain\Feeds\VrSync\ListingDataFeedHeader; |
| 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 | |
| 20 | /** |
| 21 | * Gera XML VRSync oficial (Grupo OLX) a partir de DTOs. |
| 22 | * |
| 23 | * Não salva arquivos — retorna apenas a string XML UTF-8. |
| 24 | */ |
| 25 | final class VrsyncXmlBuilder implements VrsyncXmlBuilderInterface |
| 26 | { |
| 27 | public function build(ListingDataFeed $feed): string |
| 28 | { |
| 29 | if ($feed->listings === []) { |
| 30 | throw GroupOlxXmlBuildException::emptyFeed(); |
| 31 | } |
| 32 | |
| 33 | return $this->render($feed->header, $feed->listings, []); |
| 34 | } |
| 35 | |
| 36 | public function buildFromMapped(ListingDataFeedHeader $header, array $listings): string |
| 37 | { |
| 38 | if ($listings === []) { |
| 39 | throw GroupOlxXmlBuildException::emptyFeed(); |
| 40 | } |
| 41 | |
| 42 | $vrSyncListings = []; |
| 43 | $enrichment = []; |
| 44 | |
| 45 | foreach ($listings as $index => $mapped) { |
| 46 | $vrSyncListings[] = $mapped->listing; |
| 47 | $enrichment[$index] = $mapped; |
| 48 | } |
| 49 | |
| 50 | return $this->render($header, $vrSyncListings, $enrichment); |
| 51 | } |
| 52 | |
| 53 | /** |
| 54 | * @param list<ListingDataFeedListing> $listings |
| 55 | * @param array<int, GroupOlxMappedListingData> $enrichment |
| 56 | */ |
| 57 | private function render(ListingDataFeedHeader $header, array $listings, array $enrichment): string |
| 58 | { |
| 59 | $document = new DOMDocument('1.0', 'UTF-8'); |
| 60 | $document->formatOutput = true; |
| 61 | $document->encoding = 'UTF-8'; |
| 62 | |
| 63 | $root = $document->createElementNS(GroupOlxConnector::FEED_NAMESPACE, 'ListingDataFeed'); |
| 64 | $root->setAttributeNS( |
| 65 | 'http://www.w3.org/2000/xmlns/', |
| 66 | 'xmlns:xsi', |
| 67 | 'http://www.w3.org/2001/XMLSchema-instance', |
| 68 | ); |
| 69 | $root->setAttributeNS( |
| 70 | 'http://www.w3.org/2001/XMLSchema-instance', |
| 71 | 'xsi:schemaLocation', |
| 72 | ListingDataFeed::SCHEMA_LOCATION, |
| 73 | ); |
| 74 | $document->appendChild($root); |
| 75 | |
| 76 | $root->appendChild($this->headerElement($document, $header)); |
| 77 | |
| 78 | $listingsNode = $document->createElement('Listings'); |
| 79 | $root->appendChild($listingsNode); |
| 80 | |
| 81 | foreach ($listings as $index => $listing) { |
| 82 | $listingsNode->appendChild( |
| 83 | $this->listingElement($document, $listing, $enrichment[$index] ?? null), |
| 84 | ); |
| 85 | } |
| 86 | |
| 87 | $xml = $document->saveXML(); |
| 88 | |
| 89 | if (! is_string($xml) || $xml === '') { |
| 90 | throw GroupOlxXmlBuildException::serializationFailed('DOMDocument::saveXML retornou vazio.'); |
| 91 | } |
| 92 | |
| 93 | if (! str_contains($xml, 'encoding="UTF-8"')) { |
| 94 | throw GroupOlxXmlBuildException::invalidUtf8(); |
| 95 | } |
| 96 | |
| 97 | if (! mb_check_encoding($xml, 'UTF-8')) { |
| 98 | throw GroupOlxXmlBuildException::invalidUtf8(); |
| 99 | } |
| 100 | |
| 101 | return $xml; |
| 102 | } |
| 103 | |
| 104 | private function headerElement(DOMDocument $document, ListingDataFeedHeader $header): DOMElement |
| 105 | { |
| 106 | $node = $document->createElement('Header'); |
| 107 | |
| 108 | $this->appendText($document, $node, 'Provider', $header->provider); |
| 109 | $this->appendText($document, $node, 'Email', $header->email); |
| 110 | |
| 111 | if ($header->contact !== null) { |
| 112 | $this->appendText($document, $node, 'ContactName', $header->contact->name); |
| 113 | } |
| 114 | |
| 115 | $this->appendText($document, $node, 'PublishDate', $header->publishDate); |
| 116 | $this->appendOptionalText($document, $node, 'Logo', $header->logo); |
| 117 | |
| 118 | if ($header->contact !== null) { |
| 119 | $this->appendOptionalText($document, $node, 'Telephone', $header->contact->telephone); |
| 120 | } |
| 121 | |
| 122 | return $node; |
| 123 | } |
| 124 | |
| 125 | private function listingElement( |
| 126 | DOMDocument $document, |
| 127 | ListingDataFeedListing $listing, |
| 128 | ?GroupOlxMappedListingData $mapped, |
| 129 | ): DOMElement { |
| 130 | $node = $document->createElement('Listing'); |
| 131 | |
| 132 | $this->appendText($document, $node, 'ListingID', $listing->listingId); |
| 133 | $this->appendCdata($document, $node, 'Title', $listing->title); |
| 134 | $this->appendText($document, $node, 'TransactionType', $listing->transactionType); |
| 135 | |
| 136 | if ($listing->listDate !== null && $listing->listDate !== '') { |
| 137 | $this->appendText($document, $node, 'ListDate', $listing->listDate); |
| 138 | } |
| 139 | |
| 140 | if ($listing->lastUpdateDate !== null && $listing->lastUpdateDate !== '') { |
| 141 | $this->appendText($document, $node, 'LastUpdateDate', $listing->lastUpdateDate); |
| 142 | } |
| 143 | |
| 144 | $detailUrl = $listing->detailUrl; |
| 145 | if ($detailUrl !== null && $detailUrl !== '') { |
| 146 | // Nome oficial VRSync (Grupo OLX). |
| 147 | $this->appendText($document, $node, 'DetailViewUrl', $detailUrl); |
| 148 | } |
| 149 | |
| 150 | $node->appendChild($this->detailsElement($document, $listing->details, $mapped)); |
| 151 | $node->appendChild($this->locationElement($document, $listing->location)); |
| 152 | |
| 153 | if ($listing->media !== []) { |
| 154 | $node->appendChild($this->mediaElement($document, $listing->media)); |
| 155 | } |
| 156 | |
| 157 | return $node; |
| 158 | } |
| 159 | |
| 160 | private function detailsElement( |
| 161 | DOMDocument $document, |
| 162 | ListingDataFeedDetails $details, |
| 163 | ?GroupOlxMappedListingData $mapped, |
| 164 | ): DOMElement { |
| 165 | $node = $document->createElement('Details'); |
| 166 | |
| 167 | if ($mapped !== null) { |
| 168 | $this->appendText($document, $node, 'UsageType', $mapped->usageType); |
| 169 | } |
| 170 | |
| 171 | $this->appendText($document, $node, 'PropertyType', $details->propertyType); |
| 172 | $this->appendCdata($document, $node, 'Description', $details->description); |
| 173 | |
| 174 | $listPrice = $mapped?->listPrice ?? $details->listPrice; |
| 175 | if ($listPrice !== null && $listPrice !== '') { |
| 176 | $price = $document->createElement('ListPrice'); |
| 177 | $price->setAttribute('currency', $details->currency); |
| 178 | $price->appendChild($document->createTextNode($listPrice)); |
| 179 | $node->appendChild($price); |
| 180 | } |
| 181 | |
| 182 | $rentalPrice = $mapped?->rentalPrice; |
| 183 | if ($rentalPrice !== null && $rentalPrice !== '') { |
| 184 | $price = $document->createElement('RentalPrice'); |
| 185 | $price->setAttribute('currency', $details->currency); |
| 186 | $price->setAttribute('period', 'Monthly'); |
| 187 | $price->appendChild($document->createTextNode($rentalPrice)); |
| 188 | $node->appendChild($price); |
| 189 | } |
| 190 | |
| 191 | $this->appendOptionalText($document, $node, 'Bedrooms', $details->bedrooms); |
| 192 | $this->appendOptionalText($document, $node, 'Suites', $details->suites); |
| 193 | $this->appendOptionalText($document, $node, 'Bathrooms', $details->bathrooms); |
| 194 | $this->appendOptionalText($document, $node, 'Garage', $details->garage); |
| 195 | $this->appendOptionalText($document, $node, 'LivingArea', $details->livingArea); |
| 196 | $this->appendOptionalText($document, $node, 'LotArea', $details->lotArea); |
| 197 | $this->appendCurrencyAmount($document, $node, 'PropertyAdministrationFee', $details->condominiumFee, $details->currency); |
| 198 | $this->appendIptu($document, $node, $details->iptu, $details->currency); |
| 199 | |
| 200 | return $node; |
| 201 | } |
| 202 | |
| 203 | private function locationElement(DOMDocument $document, ListingDataFeedLocation $location): DOMElement |
| 204 | { |
| 205 | $node = $document->createElement('Location'); |
| 206 | $node->setAttribute('displayAddress', $location->displayAddress); |
| 207 | |
| 208 | $this->appendOptionalText($document, $node, 'Country', $location->country); |
| 209 | $this->appendOptionalText($document, $node, 'State', $location->state); |
| 210 | $this->appendOptionalText($document, $node, 'City', $location->city); |
| 211 | $this->appendOptionalText($document, $node, 'Neighborhood', $location->neighborhood); |
| 212 | $this->appendOptionalText($document, $node, 'PostalCode', $location->postalCode); |
| 213 | $this->appendOptionalText($document, $node, 'Address', $location->street !== '' ? $location->street : $location->address); |
| 214 | $this->appendOptionalText($document, $node, 'StreetNumber', $location->number); |
| 215 | $this->appendOptionalText($document, $node, 'Complement', $location->complement); |
| 216 | $this->appendOptionalText($document, $node, 'Latitude', $location->latitude); |
| 217 | $this->appendOptionalText($document, $node, 'Longitude', $location->longitude); |
| 218 | |
| 219 | return $node; |
| 220 | } |
| 221 | |
| 222 | /** |
| 223 | * @param list<ListingDataFeedMediaItem> $items |
| 224 | */ |
| 225 | private function mediaElement(DOMDocument $document, array $items): DOMElement |
| 226 | { |
| 227 | $media = $document->createElement('Media'); |
| 228 | |
| 229 | foreach ($items as $item) { |
| 230 | $node = $document->createElement('Item'); |
| 231 | $node->setAttribute('medium', $item->medium); |
| 232 | $node->setAttribute('primary', $item->isPrimary ? 'true' : 'false'); |
| 233 | |
| 234 | if ($item->caption !== null && $item->caption !== '') { |
| 235 | $node->setAttribute('caption', $item->caption); |
| 236 | } |
| 237 | |
| 238 | $node->appendChild($document->createTextNode($item->url)); |
| 239 | $media->appendChild($node); |
| 240 | } |
| 241 | |
| 242 | return $media; |
| 243 | } |
| 244 | |
| 245 | private function appendText(DOMDocument $document, DOMElement $parent, string $name, string $value): void |
| 246 | { |
| 247 | $element = $document->createElement($name); |
| 248 | $element->appendChild($document->createTextNode($value)); |
| 249 | $parent->appendChild($element); |
| 250 | } |
| 251 | |
| 252 | private function appendOptionalText(DOMDocument $document, DOMElement $parent, string $name, ?string $value): void |
| 253 | { |
| 254 | if ($value === null || $value === '') { |
| 255 | return; |
| 256 | } |
| 257 | |
| 258 | $this->appendText($document, $parent, $name, $value); |
| 259 | } |
| 260 | |
| 261 | private function appendCurrencyAmount( |
| 262 | DOMDocument $document, |
| 263 | DOMElement $parent, |
| 264 | string $name, |
| 265 | ?string $value, |
| 266 | string $currency, |
| 267 | ): void { |
| 268 | if ($value === null || $value === '') { |
| 269 | return; |
| 270 | } |
| 271 | |
| 272 | $element = $document->createElement($name); |
| 273 | $element->setAttribute('currency', $currency); |
| 274 | $element->appendChild($document->createTextNode($value)); |
| 275 | $parent->appendChild($element); |
| 276 | } |
| 277 | |
| 278 | private function appendIptu(DOMDocument $document, DOMElement $parent, ?string $value, string $currency): void |
| 279 | { |
| 280 | if ($value === null || $value === '') { |
| 281 | return; |
| 282 | } |
| 283 | |
| 284 | $element = $document->createElement('Iptu'); |
| 285 | $element->setAttribute('currency', $currency); |
| 286 | $element->setAttribute('period', 'Yearly'); |
| 287 | $element->appendChild($document->createTextNode($value)); |
| 288 | $parent->appendChild($element); |
| 289 | } |
| 290 | |
| 291 | private function appendCdata(DOMDocument $document, DOMElement $parent, string $name, string $value): void |
| 292 | { |
| 293 | $element = $document->createElement($name); |
| 294 | $element->appendChild($document->createCDATASection($this->sanitizeCdata($value))); |
| 295 | $parent->appendChild($element); |
| 296 | } |
| 297 | |
| 298 | private function sanitizeCdata(string $value): string |
| 299 | { |
| 300 | // Evita quebrar a seção CDATA com a sequência de fechamento. |
| 301 | return str_replace(']]>', ']]]]><![CDATA[>', $value); |
| 302 | } |
| 303 | } |