Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.43% covered (success)
96.43%
135 / 140
78.57% covered (warning)
78.57%
11 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
VrsyncXmlBuilder
96.43% covered (success)
96.43%
135 / 140
78.57% covered (warning)
78.57%
11 / 14
47
0.00% covered (danger)
0.00%
0 / 1
 build
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 buildFromMapped
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 render
90.00% covered (success)
90.00%
27 / 30
0.00% covered (danger)
0.00%
0 / 1
6.04
 headerElement
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 listingElement
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
8
 detailsElement
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
6
 locationElement
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 mediaElement
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
5.03
 appendText
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 appendOptionalText
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 appendCurrencyAmount
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 appendIptu
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 appendCdata
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 sanitizeCdata
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace ImovelZapAi\Publishing\Connectors\GroupOlx\Application\Services;
6
7use DOMDocument;
8use DOMElement;
9use ImovelZapAi\Publishing\Connectors\GroupOlx\Application\Data\GroupOlxMappedListingData;
10use ImovelZapAi\Publishing\Connectors\GroupOlx\Application\Ports\VrsyncXmlBuilderInterface;
11use ImovelZapAi\Publishing\Connectors\GroupOlx\Domain\Exceptions\GroupOlxXmlBuildException;
12use ImovelZapAi\Publishing\Connectors\GroupOlx\Domain\GroupOlxConnector;
13use ImovelZapAi\Publishing\Domain\Feeds\VrSync\ListingDataFeed;
14use ImovelZapAi\Publishing\Domain\Feeds\VrSync\ListingDataFeedDetails;
15use ImovelZapAi\Publishing\Domain\Feeds\VrSync\ListingDataFeedHeader;
16use ImovelZapAi\Publishing\Domain\Feeds\VrSync\ListingDataFeedListing;
17use ImovelZapAi\Publishing\Domain\Feeds\VrSync\ListingDataFeedLocation;
18use 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 */
25final 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}