dbXapp 2.0
RAD, CMS, Module und Runtime-IDE fuer dbXapp
Loading...
Searching...
No Matches
dbxShopChannelConnector.class.php
Go to the documentation of this file.
1<?php
2namespace dbx\dbxShop;
3
5
6 public function test(array $channel): array {
7 $platform = strtolower(trim((string)($channel['platform_type'] ?? $channel['channel_key'] ?? 'custom')));
8 if ($platform === 'shop') {
9 return array('ok' => true, 'message' => 'Interner Shop-Channel ist verfuegbar.');
10 }
11 if ($platform === 'ebay') {
12 return $this->testEbay($channel);
13 }
14 if ($platform === 'amazon') {
15 return $this->testAmazon($channel);
16 }
17 if ($platform === 'mobile') {
18 return $this->testMobile($channel);
19 }
20 if ($platform === 'kleinanzeigen') {
21 return $this->testKleinanzeigen($channel);
22 }
23 return $this->testGeneric($channel);
24 }
25
26 public function normalizeWebhookPayload(array $channel, array $payload): array {
27 $platform = strtolower(trim((string)($channel['platform_type'] ?? $channel['channel_key'] ?? 'custom')));
28 if ($platform === 'ebay') {
29 return $this->normalizeEbayPayload($payload);
30 }
31 if ($platform === 'amazon') {
32 return $this->normalizeAmazonPayload($payload);
33 }
34 if ($platform === 'mobile') {
35 return $this->normalizeMobilePayload($payload);
36 }
37 return $payload;
38 }
39
40 public function exportProduct(array $channel, array $product, array $productChannel = array()): array {
41 $platform = strtolower(trim((string)($channel['platform_type'] ?? $channel['channel_key'] ?? 'custom')));
42 if ($platform === 'shop') {
43 return array(
44 'ok' => true,
45 'status' => 'ready',
46 'message' => 'Interner Shop-Channel: kein externer Export notwendig.',
47 'payload' => $this->standardProductPayload($channel, $product, $productChannel),
48 );
49 }
50 if ($platform === 'ebay') {
51 return $this->exportEbayProduct($channel, $product, $productChannel);
52 }
53 if ($platform === 'amazon') {
54 return $this->exportAmazonProduct($channel, $product, $productChannel);
55 }
56 if ($platform === 'mobile') {
57 return $this->exportMobileProduct($channel, $product, $productChannel);
58 }
59 if ($platform === 'kleinanzeigen') {
60 return $this->exportKleinanzeigenProduct($channel, $product, $productChannel);
61 }
62 return $this->exportMiddlewareProduct($channel, $product, $productChannel, 'custom');
63 }
64
65 private function missing(array $channel, array $fields): array {
66 $missing = array();
67 foreach ($fields as $field => $label) {
68 if (trim((string)($channel[$field] ?? '')) === '') {
69 $missing[] = $label;
70 }
71 }
72 return $missing;
73 }
74
75 private function scopes(string $value): array {
76 return array_values(array_filter(array_map('trim', preg_split('/[\s,]+/', $value) ?: array())));
77 }
78
79 private function baseUrl(array $channel, string $fallback): string {
80 $baseUrl = trim((string)($channel['api_base_url'] ?? ''));
81 return rtrim($baseUrl !== '' ? $baseUrl : $fallback, '/');
82 }
83
84 private function curl(string $method, string $url, array $headers = array(), ?string $body = null, ?string $user = null, ?string $password = null): array {
85 if (!function_exists('curl_init')) {
86 throw new \RuntimeException('cURL ist in PHP nicht verfuegbar.');
87 }
88 $ch = curl_init($url);
89 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
90 curl_setopt($ch, CURLOPT_CUSTOMREQUEST, strtoupper($method));
91 curl_setopt($ch, CURLOPT_TIMEOUT, 25);
92 curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
93 curl_setopt($ch, CURLOPT_USERAGENT, 'dbxShop Channel Connector');
94 if ($user !== null) {
95 curl_setopt($ch, CURLOPT_USERPWD, $user . ':' . (string)$password);
96 }
97 if ($body !== null) {
98 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
99 }
100 $raw = curl_exec($ch);
101 $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
102 $err = (string)curl_error($ch);
103 curl_close($ch);
104 if ($raw === false || $err !== '') {
105 throw new \RuntimeException($err !== '' ? $err : 'HTTP-Aufruf fehlgeschlagen.');
106 }
107 $json = json_decode((string)$raw, true);
108 return array('status' => $status, 'raw' => (string)$raw, 'json' => is_array($json) ? $json : array());
109 }
110
111 private function jsonRequest(string $method, string $url, array $headers, array $payload, ?string $user = null, ?string $password = null): array {
112 $headers[] = 'Content-Type: application/json';
113 $body = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
114 if ($body === false) {
115 throw new \RuntimeException('Payload konnte nicht als JSON erzeugt werden.');
116 }
117 return $this->curl($method, $url, $headers, $body, $user, $password);
118 }
119
120 private function productSku(array $product, array $productChannel): string {
121 $sku = trim((string)($productChannel['channel_sku'] ?? ''));
122 if ($sku === '') {
123 $sku = trim((string)($product['sku'] ?? ''));
124 }
125 return $sku;
126 }
127
128 private function channelPrice(array $product, array $productChannel): float {
129 $price = (float)($productChannel['price_gross'] ?? -1);
130 return $price >= 0 ? $price : (float)($product['price_gross'] ?? 0);
131 }
132
133 private function channelShipping(array $product, array $productChannel): float {
134 $shipping = (float)($productChannel['shipping_gross'] ?? -1);
135 return $shipping >= 0 ? $shipping : (float)($product['effective_shipping_gross'] ?? $product['shipping_gross'] ?? 0);
136 }
137
138 private function quantity(array $product): int {
139 $type = (string)($product['product_type'] ?? '');
140 if ($type === 'physical') {
141 return max(0, (int)($product['stock'] ?? 0));
142 }
143 return 999;
144 }
145
146 private function currency(array $product): string {
147 $currency = strtoupper(substr((string)($product['currency'] ?? 'EUR'), 0, 3));
148 return $currency !== '' ? $currency : 'EUR';
149 }
150
151 private function imageUrls(array $product): array {
152 $base = function_exists('dbx') ? rtrim((string)dbx()->get_base_url(), '/') . '/' : '';
153 $urls = array();
154 foreach ((array)($product['images'] ?? array()) as $image) {
155 $mediaId = (int)($image['media_id'] ?? 0);
156 if ($mediaId > 0 && $base !== '') {
157 $urls[] = $base . 'index.php?dbx_modul=dbxContent&dbx_run1=media&dbx_mid=' . $mediaId;
158 continue;
159 }
160 $path = trim(str_replace('\\', '/', (string)($image['image_path'] ?? '')));
161 if ($path === '') {
162 continue;
163 }
164 if (preg_match('~^https?://~i', $path)) {
165 $urls[] = $path;
166 } elseif ($base !== '') {
167 $urls[] = $base . ltrim($path, '/');
168 }
169 }
170 return array_values(array_unique($urls));
171 }
172
173 private function aspects(array $product): array {
174 $aspects = array();
175 foreach ((array)($product['attributes'] ?? array()) as $attribute) {
176 $key = trim((string)($attribute['title'] ?? $attribute['attr_key'] ?? ''));
177 $value = trim((string)($attribute['display_value'] ?? $attribute['value_text'] ?? ''));
178 if ($key !== '' && $value !== '') {
179 $aspects[$key] = array($value);
180 }
181 }
182 return $aspects;
183 }
184
185 private function productChannelNoteData(array $productChannel): array {
186 $note = trim((string)($productChannel['note'] ?? ''));
187 if ($note === '') {
188 return array();
189 }
190 $data = json_decode($note, true);
191 return is_array($data) ? $data : array();
192 }
193
194 private function productGroupChannelDefaults(string $platform, array $product): array {
195 $group = is_array(($product['groups'][0] ?? null)) ? $product['groups'][0] : array();
196 if ($group === array()) {
197 return array();
198 }
199 if ($platform === 'ebay') {
200 $category = trim((string)($group['ebay_category_id'] ?? ''));
201 return $category !== '' ? array('category_id' => $category) : array();
202 }
203 if ($platform === 'amazon') {
204 $productType = trim((string)($group['amazon_product_type'] ?? ''));
205 return $productType !== '' ? array('productType' => $productType) : array();
206 }
207 if ($platform === 'kleinanzeigen') {
208 $category = trim((string)($group['kleinanzeigen_category_id'] ?? ''));
209 return $category !== '' ? array('category_id' => $category) : array();
210 }
211 if ($platform === 'mobile') {
212 $category = trim((string)($group['mobile_category_id'] ?? ''));
213 return $category !== '' ? array('mobile_vehicle' => array('category' => $category)) : array();
214 }
215 return array();
216 }
217
218 private function mergeDefaults(array $defaults, array $values): array {
219 foreach ($defaults as $key => $value) {
220 if (is_array($value)) {
221 $current = is_array($values[$key] ?? null) ? $values[$key] : array();
222 $values[$key] = $this->mergeDefaults($value, $current);
223 continue;
224 }
225 if (!array_key_exists($key, $values) || trim((string)$values[$key]) === '') {
226 $values[$key] = $value;
227 }
228 }
229 return $values;
230 }
231
232 private function resolvedChannelNoteData(string $platform, array $product, array $productChannel): array {
233 return $this->mergeDefaults($this->productGroupChannelDefaults($platform, $product), $this->productChannelNoteData($productChannel));
234 }
235
236 private function standardProductPayload(array $channel, array $product, array $productChannel): array {
237 $sku = $this->productSku($product, $productChannel);
238 $platform = strtolower(trim((string)($channel['platform_type'] ?? $channel['channel_key'] ?? 'custom')));
239 $noteData = $this->resolvedChannelNoteData($platform, $product, $productChannel);
240 $categoryId = (string)($noteData['category_id'] ?? $noteData['mobile_vehicle']['category'] ?? $channel['category_id'] ?? '');
241 return array(
242 'sku' => $sku,
243 'title' => (string)($product['title'] ?? $sku),
244 'summary' => (string)($product['summary'] ?? ''),
245 'description' => (string)($product['description'] ?? $product['summary'] ?? ''),
246 'price_gross' => $this->channelPrice($product, $productChannel),
247 'shipping_gross' => $this->channelShipping($product, $productChannel),
248 'currency' => $this->currency($product),
249 'quantity' => $this->quantity($product),
250 'product_type' => (string)($product['product_type'] ?? ''),
251 'category' => (string)($product['category'] ?? ''),
252 'category_id' => $categoryId,
253 'images' => $this->imageUrls($product),
254 'attributes' => $this->aspects($product),
255 'channel_key' => (string)($channel['channel_key'] ?? ''),
256 );
257 }
258
259 private function cut(string $value, int $length): string {
260 if (function_exists('mb_substr')) {
261 return mb_substr($value, 0, $length);
262 }
263 return substr($value, 0, $length);
264 }
265
266 private function testEbay(array $channel): array {
267 $missing = $this->missing($channel, array(
268 'api_client_id' => 'Client-ID/App-ID',
269 'api_client_secret' => 'Client-Secret/Cert-ID',
270 'marketplace_id' => 'Marketplace-ID',
271 'location_key' => 'Location-Key',
272 'category_id' => 'Kategorie-ID',
273 'payment_policy_id' => 'Payment-Policy',
274 'fulfillment_policy_id' => 'Fulfillment-Policy',
275 'return_policy_id' => 'Return-Policy',
276 ));
277 if (trim((string)($channel['api_refresh_token'] ?? '')) === '' && trim((string)($channel['api_access_token'] ?? '')) === '') {
278 $missing[] = 'Refresh-Token oder Access-Token';
279 }
280 $scopes = $this->scopes((string)($channel['api_scope'] ?? ''));
281 foreach (array('sell.inventory', 'sell.fulfillment') as $required) {
282 $found = false;
283 foreach ($scopes as $scope) {
284 if (strpos($scope, $required) !== false) {
285 $found = true;
286 break;
287 }
288 }
289 if (!$found) {
290 $missing[] = 'Scope ' . $required;
291 }
292 }
293 if ($missing !== array()) {
294 return array('ok' => false, 'message' => 'eBay-Konfiguration unvollstaendig: ' . implode(', ', array_unique($missing)) . '.');
295 }
296
297 try {
298 $token = trim((string)($channel['api_access_token'] ?? ''));
299 if ($token === '') {
300 $token = $this->ebayRefreshAccessToken($channel, $scopes);
301 }
302 $base = $this->baseUrl($channel, 'https://api.ebay.com');
303 $result = $this->curl('GET', $base . '/sell/inventory/v1/inventory_item?limit=1', array(
304 'Authorization: Bearer ' . $token,
305 'Accept: application/json',
306 'Content-Type: application/json',
307 'Content-Language: de-DE',
308 ));
309 $ok = $result['status'] >= 200 && $result['status'] < 300;
310 return array(
311 'ok' => $ok,
312 'message' => $ok
313 ? 'eBay Sell API erreichbar. Inventory/Fulfillment-Zugang ist grundsaetzlich nutzbar.'
314 : 'eBay API antwortet mit HTTP ' . $result['status'] . '.',
315 );
316 } catch (\Throwable $e) {
317 return array('ok' => false, 'message' => 'eBay-Test fehlgeschlagen: ' . $e->getMessage());
318 }
319 }
320
321 private function ebayRefreshAccessToken(array $channel, array $scopes): string {
322 $base = $this->baseUrl($channel, 'https://api.ebay.com');
323 $body = http_build_query(array(
324 'grant_type' => 'refresh_token',
325 'refresh_token' => trim((string)($channel['api_refresh_token'] ?? '')),
326 'scope' => implode(' ', $scopes),
327 ));
328 $result = $this->curl('POST', $base . '/identity/v1/oauth2/token', array(
329 'Content-Type: application/x-www-form-urlencoded',
330 'Accept: application/json',
331 ), $body, trim((string)($channel['api_client_id'] ?? '')), trim((string)($channel['api_client_secret'] ?? '')));
332 if ($result['status'] < 200 || $result['status'] >= 300 || empty($result['json']['access_token'])) {
333 throw new \RuntimeException('OAuth Refresh wurde abgelehnt, HTTP ' . $result['status'] . '.');
334 }
335 return (string)$result['json']['access_token'];
336 }
337
338 private function ebayAccessToken(array $channel): string {
339 $token = trim((string)($channel['api_access_token'] ?? ''));
340 if ($token !== '') {
341 return $token;
342 }
343 return $this->ebayRefreshAccessToken($channel, $this->scopes((string)($channel['api_scope'] ?? '')));
344 }
345
346 private function exportEbayProduct(array $channel, array $product, array $productChannel): array {
347 $sku = $this->productSku($product, $productChannel);
348 $noteData = $this->resolvedChannelNoteData('ebay', $product, $productChannel);
349 $categoryId = trim((string)($noteData['category_id'] ?? $channel['category_id'] ?? ''));
350 $locationKey = trim((string)($channel['location_key'] ?? ''));
351 $paymentPolicyId = trim((string)($noteData['payment_policy_id'] ?? $channel['payment_policy_id'] ?? ''));
352 $fulfillmentPolicyId = trim((string)($noteData['fulfillment_policy_id'] ?? $channel['fulfillment_policy_id'] ?? ''));
353 $returnPolicyId = trim((string)($noteData['return_policy_id'] ?? $channel['return_policy_id'] ?? ''));
354 $checkChannel = array_replace($channel, array(
355 'category_id' => $categoryId,
356 'location_key' => $locationKey,
357 'payment_policy_id' => $paymentPolicyId,
358 'fulfillment_policy_id' => $fulfillmentPolicyId,
359 'return_policy_id' => $returnPolicyId,
360 ));
361 $missing = $this->missing($checkChannel, array(
362 'api_client_id' => 'Client-ID/App-ID',
363 'api_client_secret' => 'Client-Secret/Cert-ID',
364 'marketplace_id' => 'Marketplace-ID',
365 'location_key' => 'Location-Key',
366 'category_id' => 'Kategorie-ID',
367 'payment_policy_id' => 'Payment-Policy',
368 'fulfillment_policy_id' => 'Fulfillment-Policy',
369 'return_policy_id' => 'Return-Policy',
370 ));
371 if ($sku === '') $missing[] = 'Channel-SKU/Artikelnummer';
372 if (trim((string)($channel['api_refresh_token'] ?? '')) === '' && trim((string)($channel['api_access_token'] ?? '')) === '') {
373 $missing[] = 'Refresh-Token oder Access-Token';
374 }
375 $images = $this->imageUrls($product);
376 if ($images === array()) {
377 $missing[] = 'mindestens ein oeffentlich erreichbares Artikelbild';
378 }
379 if ($missing !== array()) {
380 return array(
381 'ok' => false,
382 'status' => 'failed',
383 'message' => 'eBay-Export nicht moeglich: ' . implode(', ', array_unique($missing)) . '.',
384 'payload' => $this->standardProductPayload($channel, $product, $productChannel),
385 );
386 }
387
388 try {
389 $token = $this->ebayAccessToken($channel);
390 $base = $this->baseUrl($channel, 'https://api.ebay.com');
391 $headers = array(
392 'Authorization: Bearer ' . $token,
393 'Accept: application/json',
394 'Content-Language: de-DE',
395 );
396 $quantity = $this->quantity($product);
397 $price = number_format($this->channelPrice($product, $productChannel), 2, '.', '');
398 $shipping = $this->channelShipping($product, $productChannel);
399 $currency = $this->currency($product);
400 $description = trim((string)($product['description'] ?? $product['summary'] ?? ''));
401 if ($description === '') {
402 $description = (string)($product['title'] ?? $sku);
403 }
404 $inventoryPayload = array(
405 'availability' => array(
406 'shipToLocationAvailability' => array('quantity' => max(0, $quantity)),
407 ),
408 'condition' => 'NEW',
409 'product' => array(
410 'title' => $this->cut((string)($product['title'] ?? $sku), 80),
411 'description' => $this->cut($description, 4000),
412 'imageUrls' => $images,
413 ),
414 );
415 $aspects = $this->aspects($product);
416 if (is_array($noteData['aspects'] ?? null)) {
417 foreach ($noteData['aspects'] as $aspectName => $aspectValue) {
418 $aspectName = trim((string)$aspectName);
419 if ($aspectName === '') continue;
420 $values = is_array($aspectValue) ? $aspectValue : array($aspectValue);
421 $values = array_values(array_filter(array_map('strval', $values), fn($v) => trim($v) !== ''));
422 if ($values !== array()) {
423 $aspects[$aspectName] = $values;
424 }
425 }
426 }
427 if ($aspects !== array()) {
428 $inventoryPayload['product']['aspects'] = $aspects;
429 }
430 if (trim((string)($noteData['condition'] ?? '')) !== '') {
431 $inventoryPayload['condition'] = trim((string)$noteData['condition']);
432 }
433 $this->jsonRequest('PUT', $base . '/sell/inventory/v1/inventory_item/' . rawurlencode($sku), $headers, $inventoryPayload);
434
435 $offerPayload = array(
436 'sku' => $sku,
437 'marketplaceId' => (string)$channel['marketplace_id'],
438 'format' => 'FIXED_PRICE',
439 'availableQuantity' => max(0, $quantity),
440 'categoryId' => $categoryId,
441 'merchantLocationKey' => $locationKey,
442 'listingPolicies' => array(
443 'fulfillmentPolicyId' => $fulfillmentPolicyId,
444 'paymentPolicyId' => $paymentPolicyId,
445 'returnPolicyId' => $returnPolicyId,
446 ),
447 'pricingSummary' => array(
448 'price' => array('value' => $price, 'currency' => $currency),
449 ),
450 );
451 $offerPayload['listingDescription'] = $this->cut($description, 4000);
452 $offerId = trim((string)($productChannel['external_offer_id'] ?? ''));
453 if ($offerId !== '') {
454 $this->jsonRequest('PUT', $base . '/sell/inventory/v1/offer/' . rawurlencode($offerId), $headers, $offerPayload);
455 } else {
456 $created = $this->jsonRequest('POST', $base . '/sell/inventory/v1/offer', $headers, $offerPayload);
457 $offerId = (string)($created['json']['offerId'] ?? $created['json']['offer']['offerId'] ?? '');
458 }
459 if ($offerId === '') {
460 throw new \RuntimeException('eBay hat keine Offer-ID geliefert.');
461 }
462
463 $published = $this->jsonRequest('POST', $base . '/sell/inventory/v1/offer/' . rawurlencode($offerId) . '/publish', $headers, array());
464 $listingId = (string)($published['json']['listingId'] ?? $published['json']['listing']['listingId'] ?? $productChannel['external_listing_id'] ?? '');
465 return array(
466 'ok' => true,
467 'status' => 'published',
468 'message' => 'eBay-Angebot wurde exportiert und veroeffentlicht' . ($listingId !== '' ? ' (Listing ' . $listingId . ')' : '') . '.',
469 'external_offer_id' => $offerId,
470 'external_listing_id' => $listingId,
471 'payload' => array('inventory' => $inventoryPayload, 'offer' => $offerPayload, 'publish' => $published['json']),
472 );
473 } catch (\Throwable $e) {
474 return array(
475 'ok' => false,
476 'status' => 'failed',
477 'message' => 'eBay-Export fehlgeschlagen: ' . $e->getMessage(),
478 'payload' => $this->standardProductPayload($channel, $product, $productChannel),
479 );
480 }
481 }
482
483 private function testAmazon(array $channel): array {
484 $missing = $this->missing($channel, array(
485 'api_client_id' => 'LWA Client-ID',
486 'api_client_secret' => 'LWA Client-Secret',
487 'api_refresh_token' => 'LWA Refresh-Token',
488 'seller_id' => 'Seller-ID',
489 'marketplace_id' => 'Marketplace-ID',
490 ));
491 if ($missing !== array()) {
492 return array('ok' => false, 'message' => 'Amazon-SP-API-Konfiguration unvollstaendig: ' . implode(', ', $missing) . '.');
493 }
494
495 try {
496 $token = $this->amazonAccessToken($channel);
497 $base = $this->baseUrl($channel, 'https://sellingpartnerapi-eu.amazon.com');
498 $createdAfter = gmdate('Y-m-d\TH:i:s\Z', time() - 86400 * 7);
499 $url = $base . '/orders/v0/orders?MarketplaceIds=' . rawurlencode((string)$channel['marketplace_id']) . '&CreatedAfter=' . rawurlencode($createdAfter);
500 $result = $this->curl('GET', $url, array(
501 'Accept: application/json',
502 'x-amz-access-token: ' . $token,
503 ));
504 $ok = $result['status'] >= 200 && $result['status'] < 300;
505 return array(
506 'ok' => $ok,
507 'message' => $ok
508 ? 'Amazon SP-API erreichbar. LWA Token und Orders-Zugriff sind grundsaetzlich nutzbar.'
509 : 'Amazon SP-API antwortet mit HTTP ' . $result['status'] . '.',
510 );
511 } catch (\Throwable $e) {
512 return array('ok' => false, 'message' => 'Amazon-SP-API-Test fehlgeschlagen: ' . $e->getMessage());
513 }
514 }
515
516 private function amazonAccessToken(array $channel): string {
517 $body = http_build_query(array(
518 'grant_type' => 'refresh_token',
519 'refresh_token' => trim((string)($channel['api_refresh_token'] ?? '')),
520 'client_id' => trim((string)($channel['api_client_id'] ?? '')),
521 'client_secret' => trim((string)($channel['api_client_secret'] ?? '')),
522 ));
523 $result = $this->curl('POST', 'https://api.amazon.com/auth/o2/token', array(
524 'Content-Type: application/x-www-form-urlencoded;charset=UTF-8',
525 'Accept: application/json',
526 ), $body);
527 if ($result['status'] < 200 || $result['status'] >= 300 || empty($result['json']['access_token'])) {
528 throw new \RuntimeException('LWA Token wurde abgelehnt, HTTP ' . $result['status'] . '.');
529 }
530 return (string)$result['json']['access_token'];
531 }
532
533 private function amazonProductType(array $channel, array $productChannel): string {
534 $note = $this->productChannelNoteData($productChannel);
535 $fromNote = trim((string)($note['productType'] ?? $note['product_type'] ?? ''));
536 if ($fromNote !== '') {
537 return $fromNote;
538 }
539 $category = trim((string)($channel['category_id'] ?? ''));
540 if (stripos($category, 'productType:') === 0) {
541 $category = trim(substr($category, strlen('productType:')));
542 }
543 if (strpos($category, '/') !== false) {
544 $category = trim((string)explode('/', $category)[0]);
545 }
546 return strtoupper(trim($category));
547 }
548
549 private function resolvedAmazonProductType(array $channel, array $product, array $productChannel): string {
550 $note = $this->resolvedChannelNoteData('amazon', $product, $productChannel);
551 $fromNote = trim((string)($note['productType'] ?? $note['product_type'] ?? ''));
552 if ($fromNote !== '') {
553 return $fromNote;
554 }
555 return $this->amazonProductType($channel, $productChannel);
556 }
557
558 private function exportAmazonProduct(array $channel, array $product, array $productChannel): array {
559 $sku = $this->productSku($product, $productChannel);
560 $productType = $this->resolvedAmazonProductType($channel, $product, $productChannel);
561 $missing = $this->missing($channel, array(
562 'api_client_id' => 'LWA Client-ID',
563 'api_client_secret' => 'LWA Client-Secret',
564 'api_refresh_token' => 'LWA Refresh-Token',
565 'seller_id' => 'Seller-ID',
566 'marketplace_id' => 'Marketplace-ID',
567 ));
568 if ($sku === '') $missing[] = 'Channel-SKU/Artikelnummer';
569 if ($productType === '') $missing[] = 'Amazon Product Type';
570 if ($missing !== array()) {
571 return array(
572 'ok' => false,
573 'status' => 'failed',
574 'message' => 'Amazon-Export nicht moeglich: ' . implode(', ', $missing) . '. Product Type und Pflichtattribute muessen zum Amazon-Schema passen.',
575 'payload' => $this->standardProductPayload($channel, $product, $productChannel),
576 );
577 }
578
579 $noteData = $this->resolvedChannelNoteData('amazon', $product, $productChannel);
580 $quantity = $this->quantity($product);
581 $currency = $this->currency($product);
582 $price = number_format($this->channelPrice($product, $productChannel), 2, '.', '');
583 $attributes = is_array($noteData['attributes'] ?? null) ? $noteData['attributes'] : array();
584 if (is_array($noteData['simple_attributes'] ?? null)) {
585 foreach ($noteData['simple_attributes'] as $attrKey => $attrValue) {
586 $attrKey = trim((string)$attrKey);
587 $attrValue = trim((string)$attrValue);
588 if ($attrKey !== '' && $attrValue !== '' && !isset($attributes[$attrKey])) {
589 $attributes[$attrKey] = array(array('value' => $attrValue, 'marketplace_id' => (string)$channel['marketplace_id']));
590 }
591 }
592 }
593 $attributes += array(
594 'item_name' => array(array('value' => (string)($product['title'] ?? $sku), 'marketplace_id' => (string)$channel['marketplace_id'])),
595 'product_description' => array(array('value' => (string)($product['description'] ?? $product['summary'] ?? ''), 'marketplace_id' => (string)$channel['marketplace_id'])),
596 'bullet_point' => array(array('value' => (string)($product['summary'] ?? $product['title'] ?? $sku), 'marketplace_id' => (string)$channel['marketplace_id'])),
597 'condition_type' => array(array('value' => 'new_new', 'marketplace_id' => (string)$channel['marketplace_id'])),
598 'purchasable_offer' => array(array(
599 'currency' => $currency,
600 'our_price' => array(array('schedule' => array(array('value_with_tax' => (float)$price)))),
601 'marketplace_id' => (string)$channel['marketplace_id'],
602 )),
603 'fulfillment_availability' => array(array(
604 'fulfillment_channel_code' => 'DEFAULT',
605 'quantity' => max(0, $quantity),
606 'marketplace_id' => (string)$channel['marketplace_id'],
607 )),
608 );
609 if ($this->imageUrls($product) !== array()) {
610 $attributes['main_product_image_locator'] = array(array('media_location' => $this->imageUrls($product)[0], 'marketplace_id' => (string)$channel['marketplace_id']));
611 }
612 $payload = array(
613 'productType' => $productType,
614 'requirements' => (string)($noteData['requirements'] ?? 'LISTING'),
615 'attributes' => $attributes,
616 );
617
618 try {
619 $token = $this->amazonAccessToken($channel);
620 $base = $this->baseUrl($channel, 'https://sellingpartnerapi-eu.amazon.com');
621 $url = $base . '/listings/2021-08-01/items/' . rawurlencode((string)$channel['seller_id']) . '/' . rawurlencode($sku)
622 . '?marketplaceIds=' . rawurlencode((string)$channel['marketplace_id']) . '&issueLocale=de_DE';
623 $result = $this->jsonRequest('PUT', $url, array(
624 'Accept: application/json',
625 'x-amz-access-token: ' . $token,
626 ), $payload);
627 $ok = $result['status'] >= 200 && $result['status'] < 300;
628 return array(
629 'ok' => $ok,
630 'status' => $ok ? 'exported' : 'failed',
631 'message' => $ok ? 'Amazon Listing wurde an die SP-API uebergeben. Amazon validiert die Product-Type-Pflichtattribute asynchron.' : 'Amazon SP-API antwortet mit HTTP ' . $result['status'] . '.',
632 'external_listing_id' => $sku,
633 'payload' => array('request' => $payload, 'response' => $result['json']),
634 );
635 } catch (\Throwable $e) {
636 return array(
637 'ok' => false,
638 'status' => 'failed',
639 'message' => 'Amazon-Export fehlgeschlagen: ' . $e->getMessage(),
640 'payload' => $payload,
641 );
642 }
643 }
644
645 private function testMobile(array $channel): array {
646 $missing = $this->missing($channel, array(
647 'api_username' => 'API-Benutzer',
648 'api_password' => 'API-Passwort',
649 ));
650 if ($missing !== array()) {
651 return array('ok' => false, 'message' => 'mobile.de-Konfiguration unvollstaendig: ' . implode(', ', $missing) . '.');
652 }
653
654 try {
655 $base = $this->baseUrl($channel, 'https://services.mobile.de');
656 if (preg_match('~/seller-api$~', $base)) {
657 $base = substr($base, 0, -11);
658 }
659 $result = $this->curl('GET', $base . '/seller-api/sellers', array(
660 'Accept: application/vnd.de.mobile.api+json',
661 ), null, trim((string)($channel['api_username'] ?? '')), trim((string)($channel['api_password'] ?? '')));
662 $ok = $result['status'] >= 200 && $result['status'] < 300;
663 return array(
664 'ok' => $ok,
665 'message' => $ok
666 ? 'mobile.de Seller API erreichbar. Basic-Auth-Zugang ist nutzbar.'
667 : 'mobile.de Seller API antwortet mit HTTP ' . $result['status'] . '.',
668 );
669 } catch (\Throwable $e) {
670 return array('ok' => false, 'message' => 'mobile.de-Test fehlgeschlagen: ' . $e->getMessage());
671 }
672 }
673
674 private function testKleinanzeigen(array $channel): array {
675 $mode = strtolower((string)($channel['connection_mode'] ?? 'manual'));
676 if ($mode === 'manual') {
677 return array(
678 'ok' => true,
679 'message' => 'Kleinanzeigen ist als manueller/Partner-Channel konfiguriert. Eine frei nutzbare Standard-API wird hier nicht vorausgesetzt.',
680 );
681 }
682 $baseUrl = trim((string)($channel['api_base_url'] ?? ''));
683 $hasCredentials = trim((string)($channel['api_client_id'] ?? $channel['api_username'] ?? '')) !== '';
684 if ($baseUrl === '' || !$hasCredentials) {
685 return array(
686 'ok' => false,
687 'message' => 'Fuer Kleinanzeigen-API/Partnerbetrieb fehlen Middleware-URL und Zugangsdaten. Ohne vertraglich freigegebene Schnittstelle bitte Verbindung auf Manuell stellen.',
688 );
689 }
690 return $this->testGeneric($channel);
691 }
692
693 private function testGeneric(array $channel): array {
694 $baseUrl = trim((string)($channel['api_base_url'] ?? ''));
695 if ($baseUrl === '') {
696 return array('ok' => false, 'message' => 'Keine API-Basis-URL hinterlegt.');
697 }
698 if (!preg_match('~^https?://~i', $baseUrl)) {
699 return array('ok' => false, 'message' => 'API-Basis-URL muss mit http:// oder https:// beginnen.');
700 }
701 try {
702 $headers = array('Accept: application/json');
703 $token = trim((string)($channel['api_access_token'] ?? ''));
704 if ($token !== '') {
705 $headers[] = 'Authorization: Bearer ' . $token;
706 }
707 $result = $this->curl('GET', $baseUrl, $headers);
708 $ok = $result['status'] >= 200 && $result['status'] < 500;
709 return array('ok' => $ok, 'message' => $ok ? 'API-URL erreichbar, HTTP ' . $result['status'] . '.' : 'API-URL nicht erreichbar, HTTP ' . $result['status'] . '.');
710 } catch (\Throwable $e) {
711 return array('ok' => false, 'message' => 'API-Test fehlgeschlagen: ' . $e->getMessage());
712 }
713 }
714
715 private function exportMobileProduct(array $channel, array $product, array $productChannel): array {
716 $noteData = $this->resolvedChannelNoteData('mobile', $product, $productChannel);
717 $category = strtolower((string)($product['category'] ?? '') . ' ' . (string)($channel['category_id'] ?? '') . ' ' . (string)($noteData['mobile_vehicle']['category'] ?? '') . ' ' . (string)($product['product_type'] ?? ''));
718 $isVehicle = preg_match('~fahrzeug|vehicle|auto|car|motorbike|motorrad|commercial~i', $category) === 1;
719 $baseUrl = trim((string)($channel['api_base_url'] ?? ''));
720 if (!$isVehicle && !empty($noteData['mobile_vehicle'])) {
721 $isVehicle = true;
722 }
723 if (!$isVehicle && stripos($baseUrl, 'services.mobile.de') !== false) {
724 return array(
725 'ok' => false,
726 'status' => 'failed',
727 'message' => 'mobile.de exportiert nur Fahrzeuganzeigen. Dieser Artikel ist nicht als Fahrzeug markiert. Fuer nicht fahrzeugbezogene Daten bitte eine eigene Middleware-URL verwenden.',
728 'payload' => $this->standardProductPayload($channel, $product, $productChannel),
729 );
730 }
731 if ($isVehicle && $baseUrl !== '' && stripos($baseUrl, 'services.mobile.de') !== false) {
732 $payload = $noteData['mobile_vehicle'] ?? $noteData;
733 if (!is_array($payload) || $payload === array()) {
734 return array(
735 'ok' => false,
736 'status' => 'failed',
737 'message' => 'mobile.de Fahrzeugdaten fehlen. Bitte im Channel-Hinweis JSON mit mobile.de Fahrzeugfeldern hinterlegen oder eine Middleware nutzen.',
738 'payload' => $this->standardProductPayload($channel, $product, $productChannel),
739 );
740 }
741 $payload += array(
742 'sellerInventoryKey' => $this->productSku($product, $productChannel),
743 'price' => array('consumerPriceGross' => $this->channelPrice($product, $productChannel), 'type' => 'FIXED'),
744 );
745 try {
746 $base = $this->baseUrl($channel, 'https://services.mobile.de');
747 if (preg_match('~/seller-api$~', $base)) {
748 $base = substr($base, 0, -11);
749 }
750 $sellerId = trim((string)($channel['account_id'] ?? $channel['seller_id'] ?? ''));
751 if ($sellerId === '') {
752 throw new \RuntimeException('mobileSellerId/Account-ID fehlt.');
753 }
754 $result = $this->jsonRequest('POST', $base . '/seller-api/sellers/' . rawurlencode($sellerId) . '/ads', array(
755 'Accept: application/vnd.de.mobile.api+json',
756 ), $payload, trim((string)($channel['api_username'] ?? '')), trim((string)($channel['api_password'] ?? '')));
757 $listingId = (string)($result['json']['adId'] ?? $result['json']['mobileAdId'] ?? $result['json']['id'] ?? $this->productSku($product, $productChannel));
758 return array(
759 'ok' => $result['status'] >= 200 && $result['status'] < 300,
760 'status' => $result['status'] >= 200 && $result['status'] < 300 ? 'published' : 'failed',
761 'message' => 'mobile.de Seller API antwortet mit HTTP ' . $result['status'] . '.',
762 'external_listing_id' => $listingId,
763 'payload' => array('request' => $payload, 'response' => $result['json']),
764 );
765 } catch (\Throwable $e) {
766 return array('ok' => false, 'status' => 'failed', 'message' => 'mobile.de-Export fehlgeschlagen: ' . $e->getMessage(), 'payload' => $payload);
767 }
768 }
769 return $this->exportMiddlewareProduct($channel, $product, $productChannel, 'mobile');
770 }
771
772 private function exportKleinanzeigenProduct(array $channel, array $product, array $productChannel): array {
773 $baseUrl = trim((string)($channel['api_base_url'] ?? ''));
774 if ($baseUrl === '' || stripos($baseUrl, 'freigegebener Schnittstelle') !== false || !preg_match('~^https?://~i', $baseUrl)) {
775 return array(
776 'ok' => true,
777 'status' => 'manual_ready',
778 'message' => 'Kleinanzeigen ist wichtig, aber ohne freigegebene Partner-/Middleware-API wird kein automatischer Upload ausgefuehrt. Artikel ist fuer manuelle oder externe Uebergabe vorbereitet.',
779 'payload' => $this->standardProductPayload($channel, $product, $productChannel),
780 );
781 }
782 return $this->exportMiddlewareProduct($channel, $product, $productChannel, 'kleinanzeigen');
783 }
784
785 private function exportMiddlewareProduct(array $channel, array $product, array $productChannel, string $provider): array {
786 $baseUrl = trim((string)($channel['api_base_url'] ?? ''));
787 $payload = $this->standardProductPayload($channel, $product, $productChannel);
788 $payload['provider'] = $provider;
789 $payload['channel_note'] = $this->resolvedChannelNoteData($provider, $product, $productChannel);
790 if ($baseUrl === '' || !preg_match('~^https?://~i', $baseUrl)) {
791 return array('ok' => false, 'status' => 'failed', 'message' => 'Keine gueltige Middleware/API-URL fuer den Export hinterlegt.', 'payload' => $payload);
792 }
793 try {
794 $headers = array('Accept: application/json');
795 $token = trim((string)($channel['api_access_token'] ?? ''));
796 if ($token !== '') {
797 $headers[] = 'Authorization: Bearer ' . $token;
798 }
799 $user = trim((string)($channel['api_username'] ?? ''));
800 $password = trim((string)($channel['api_password'] ?? ''));
801 $result = $this->jsonRequest('POST', $baseUrl, $headers, $payload, $user !== '' ? $user : null, $user !== '' ? $password : null);
802 $listingId = (string)($result['json']['listing_id'] ?? $result['json']['ad_id'] ?? $result['json']['id'] ?? '');
803 return array(
804 'ok' => $result['status'] >= 200 && $result['status'] < 300,
805 'status' => $result['status'] >= 200 && $result['status'] < 300 ? 'exported' : 'failed',
806 'message' => ucfirst($provider) . '-Middleware antwortet mit HTTP ' . $result['status'] . '.',
807 'external_listing_id' => $listingId,
808 'payload' => array('request' => $payload, 'response' => $result['json']),
809 );
810 } catch (\Throwable $e) {
811 return array('ok' => false, 'status' => 'failed', 'message' => ucfirst($provider) . '-Export fehlgeschlagen: ' . $e->getMessage(), 'payload' => $payload);
812 }
813 }
814
815 private function normalizeEbayPayload(array $payload): array {
816 $order = is_array($payload['order'] ?? null) ? $payload['order'] : $payload;
817 $externalId = (string)($order['orderId'] ?? $order['order_id'] ?? $order['external_order_id'] ?? $order['id'] ?? $payload['external_order_id'] ?? $payload['order_id'] ?? $payload['resourceId'] ?? '');
818 $lineItems = is_array($order['lineItems'] ?? null) ? $order['lineItems'] : (is_array($order['items'] ?? null) ? $order['items'] : array());
819 $items = array();
820 foreach ($lineItems as $item) {
821 if (!is_array($item)) continue;
822 $items[] = array(
823 'sku' => (string)($item['sku'] ?? $item['legacyItemId'] ?? $item['itemId'] ?? ''),
824 'title' => (string)($item['title'] ?? $item['lineItemId'] ?? ''),
825 'quantity' => (int)($item['quantity'] ?? 1),
826 'price_gross' => (float)($item['lineItemCost']['value'] ?? $item['total']['value'] ?? $item['price'] ?? 0),
827 'shipping_gross' => (float)($item['deliveryCost']['shippingCost']['value'] ?? 0),
828 );
829 }
830 if ($externalId !== '') {
831 $payload['external_order_id'] = $externalId;
832 }
833 $payload['payment_status'] = (string)($order['orderPaymentStatus'] ?? $order['paymentSummary']['payments'][0]['paymentStatus'] ?? $payload['payment_status'] ?? 'completed');
834 if ($items !== array()) $payload['items'] = $items;
835 return $payload;
836 }
837
838 private function normalizeAmazonPayload(array $payload): array {
839 $change = is_array($payload['OrderChangeNotification'] ?? null) ? $payload['OrderChangeNotification'] : array();
840 $order = is_array($payload['Order'] ?? null) ? $payload['Order'] : $payload;
841 $externalId = (string)($payload['AmazonOrderId'] ?? $change['AmazonOrderId'] ?? $order['AmazonOrderId'] ?? $payload['external_order_id'] ?? $payload['order_id'] ?? '');
842 if ($externalId !== '') {
843 $payload['external_order_id'] = $externalId;
844 }
845 $payload['payment_status'] = (string)($payload['payment_status'] ?? $order['OrderStatus'] ?? 'pending');
846 return $payload;
847 }
848
849 private function normalizeMobilePayload(array $payload): array {
850 $externalId = (string)($payload['leadId'] ?? $payload['eventId'] ?? $payload['external_order_id'] ?? $payload['order_id'] ?? $payload['id'] ?? '');
851 if ($externalId !== '') {
852 $payload['external_order_id'] = $externalId;
853 }
854 $payload['payment_status'] = (string)($payload['payment_status'] ?? 'pending');
855 return $payload;
856 }
857}
858?>
exportProduct(array $channel, array $product, array $productChannel=array())
normalizeWebhookPayload(array $channel, array $payload)
$fields[]
Definition config.dd.php:16
$field
Definition config.dd.php:4
if( $syncRequest)
Definition index.php:520
DBX schema administration.
if(! $db->connect_db_server($server)) $result
foreach( $topics as $file=> $html)