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.');
11 if ($platform ===
'ebay') {
12 return $this->testEbay($channel);
14 if ($platform ===
'amazon') {
15 return $this->testAmazon($channel);
17 if ($platform ===
'mobile') {
18 return $this->testMobile($channel);
20 if ($platform ===
'kleinanzeigen') {
21 return $this->testKleinanzeigen($channel);
23 return $this->testGeneric($channel);
27 $platform = strtolower(trim((string)($channel[
'platform_type'] ?? $channel[
'channel_key'] ??
'custom')));
28 if ($platform ===
'ebay') {
29 return $this->normalizeEbayPayload($payload);
31 if ($platform ===
'amazon') {
32 return $this->normalizeAmazonPayload($payload);
34 if ($platform ===
'mobile') {
35 return $this->normalizeMobilePayload($payload);
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') {
46 'message' =>
'Interner Shop-Channel: kein externer Export notwendig.',
47 'payload' => $this->standardProductPayload($channel, $product, $productChannel),
50 if ($platform ===
'ebay') {
51 return $this->exportEbayProduct($channel, $product, $productChannel);
53 if ($platform ===
'amazon') {
54 return $this->exportAmazonProduct($channel, $product, $productChannel);
56 if ($platform ===
'mobile') {
57 return $this->exportMobileProduct($channel, $product, $productChannel);
59 if ($platform ===
'kleinanzeigen') {
60 return $this->exportKleinanzeigenProduct($channel, $product, $productChannel);
62 return $this->exportMiddlewareProduct($channel, $product, $productChannel,
'custom');
65 private function missing(array $channel, array
$fields): array {
68 if (trim((
string)($channel[
$field] ??
'')) ===
'') {
75 private function scopes(
string $value): array {
76 return array_values(array_filter(array_map(
'trim', preg_split(
'/[\s,]+/', $value) ?: array())));
79 private function baseUrl(array $channel,
string $fallback): string {
80 $baseUrl = trim((string)($channel[
'api_base_url'] ??
''));
81 return rtrim($baseUrl !==
'' ? $baseUrl : $fallback,
'/');
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.');
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');
95 curl_setopt($ch, CURLOPT_USERPWD, $user .
':' . (
string)$password);
98 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
100 $raw = curl_exec($ch);
101 $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
102 $err = (string)curl_error($ch);
104 if ($raw ===
false || $err !==
'') {
105 throw new \RuntimeException($err !==
'' ? $err :
'HTTP-Aufruf fehlgeschlagen.');
107 $json = json_decode((
string)$raw,
true);
108 return array(
'status' => $status,
'raw' => (
string)$raw,
'json' => is_array($json) ? $json : array());
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.');
117 return $this->curl($method, $url, $headers, $body, $user, $password);
120 private function productSku(array $product, array $productChannel): string {
121 $sku = trim((string)($productChannel[
'channel_sku'] ??
''));
123 $sku = trim((
string)($product[
'sku'] ??
''));
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);
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);
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));
146 private function currency(array $product): string {
147 $currency = strtoupper(substr((string)($product[
'currency'] ??
'EUR'), 0, 3));
148 return $currency !==
'' ? $currency :
'EUR';
151 private function imageUrls(array $product): array {
152 $base = function_exists(
'dbx') ? rtrim((string)
dbx()->get_base_url(),
'/') .
'/' :
'';
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;
160 $path = trim(str_replace(
'\\',
'/', (
string)($image[
'image_path'] ??
'')));
164 if (preg_match(
'~^https?://~i', $path)) {
166 } elseif (
$base !==
'') {
167 $urls[] =
$base . ltrim($path,
'/');
170 return array_values(array_unique($urls));
173 private function aspects(array $product): 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);
185 private function productChannelNoteData(array $productChannel): array {
186 $note = trim((string)($productChannel[
'note'] ??
''));
190 $data = json_decode($note,
true);
191 return is_array($data) ? $data : array();
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()) {
199 if ($platform ===
'ebay') {
200 $category = trim((
string)($group[
'ebay_category_id'] ??
''));
201 return $category !==
'' ? array(
'category_id' => $category) : array();
203 if ($platform ===
'amazon') {
204 $productType = trim((
string)($group[
'amazon_product_type'] ??
''));
205 return $productType !==
'' ? array(
'productType' => $productType) : array();
207 if ($platform ===
'kleinanzeigen') {
208 $category = trim((
string)($group[
'kleinanzeigen_category_id'] ??
''));
209 return $category !==
'' ? array(
'category_id' => $category) : array();
211 if ($platform ===
'mobile') {
212 $category = trim((
string)($group[
'mobile_category_id'] ??
''));
213 return $category !==
'' ? array(
'mobile_vehicle' => array(
'category' => $category)) : array();
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);
225 if (!array_key_exists($key, $values) || trim((
string)$values[$key]) ===
'') {
226 $values[$key] = $value;
232 private function resolvedChannelNoteData(
string $platform, array $product, array $productChannel): array {
233 return $this->mergeDefaults($this->productGroupChannelDefaults($platform, $product), $this->productChannelNoteData($productChannel));
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'] ??
'');
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'] ??
''),
259 private function cut(
string $value,
int $length): string {
260 if (function_exists(
'mb_substr')) {
261 return mb_substr($value, 0, $length);
263 return substr($value, 0, $length);
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',
277 if (trim((
string)($channel[
'api_refresh_token'] ??
'')) ===
'' && trim((
string)($channel[
'api_access_token'] ??
'')) ===
'') {
278 $missing[] =
'Refresh-Token oder Access-Token';
280 $scopes = $this->scopes((
string)($channel[
'api_scope'] ??
''));
281 foreach (array(
'sell.inventory',
'sell.fulfillment') as $required) {
283 foreach ($scopes as $scope) {
284 if (strpos($scope, $required) !==
false) {
290 $missing[] =
'Scope ' . $required;
293 if ($missing !== array()) {
294 return array(
'ok' =>
false,
'message' =>
'eBay-Konfiguration unvollstaendig: ' . implode(
', ', array_unique($missing)) .
'.');
298 $token = trim((
string)($channel[
'api_access_token'] ??
''));
300 $token = $this->ebayRefreshAccessToken($channel, $scopes);
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',
313 ?
'eBay Sell API erreichbar. Inventory/Fulfillment-Zugang ist grundsaetzlich nutzbar.'
314 :
'eBay API antwortet mit HTTP ' .
$result[
'status'] .
'.',
316 }
catch (\Throwable $e) {
317 return array(
'ok' =>
false,
'message' =>
'eBay-Test fehlgeschlagen: ' . $e->getMessage());
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),
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'] ??
'')));
333 throw new \RuntimeException(
'OAuth Refresh wurde abgelehnt, HTTP ' .
$result[
'status'] .
'.');
335 return (
string)
$result[
'json'][
'access_token'];
338 private function ebayAccessToken(array $channel): string {
339 $token = trim((string)($channel[
'api_access_token'] ??
''));
343 return $this->ebayRefreshAccessToken($channel, $this->scopes((
string)($channel[
'api_scope'] ??
'')));
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,
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',
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';
375 $images = $this->imageUrls($product);
376 if ($images === array()) {
377 $missing[] =
'mindestens ein oeffentlich erreichbares Artikelbild';
379 if ($missing !== array()) {
382 'status' =>
'failed',
383 'message' =>
'eBay-Export nicht moeglich: ' . implode(
', ', array_unique($missing)) .
'.',
384 'payload' => $this->standardProductPayload($channel, $product, $productChannel),
389 $token = $this->ebayAccessToken($channel);
390 $base = $this->baseUrl($channel,
'https://api.ebay.com');
392 'Authorization: Bearer ' . $token,
393 'Accept: application/json',
394 'Content-Language: de-DE',
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);
404 $inventoryPayload = array(
405 'availability' => array(
406 'shipToLocationAvailability' => array(
'quantity' => max(0, $quantity)),
408 'condition' =>
'NEW',
410 'title' => $this->cut((
string)($product[
'title'] ?? $sku), 80),
411 'description' => $this->cut($description, 4000),
412 'imageUrls' => $images,
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;
427 if ($aspects !== array()) {
428 $inventoryPayload[
'product'][
'aspects'] = $aspects;
430 if (trim((
string)($noteData[
'condition'] ??
'')) !==
'') {
431 $inventoryPayload[
'condition'] = trim((
string)$noteData[
'condition']);
433 $this->jsonRequest(
'PUT',
$base .
'/sell/inventory/v1/inventory_item/' . rawurlencode($sku), $headers, $inventoryPayload);
435 $offerPayload = array(
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,
447 'pricingSummary' => array(
448 'price' => array(
'value' => $price,
'currency' => $currency),
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);
456 $created = $this->jsonRequest(
'POST',
$base .
'/sell/inventory/v1/offer', $headers, $offerPayload);
457 $offerId = (string)(
$created[
'json'][
'offerId'] ??
$created[
'json'][
'offer'][
'offerId'] ??
'');
459 if ($offerId ===
'') {
460 throw new \RuntimeException(
'eBay hat keine Offer-ID geliefert.');
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'] ??
'');
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']),
473 }
catch (\Throwable $e) {
476 'status' =>
'failed',
477 'message' =>
'eBay-Export fehlgeschlagen: ' . $e->getMessage(),
478 'payload' => $this->standardProductPayload($channel, $product, $productChannel),
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',
491 if ($missing !== array()) {
492 return array(
'ok' =>
false,
'message' =>
'Amazon-SP-API-Konfiguration unvollstaendig: ' . implode(
', ', $missing) .
'.');
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,
508 ?
'Amazon SP-API erreichbar. LWA Token und Orders-Zugriff sind grundsaetzlich nutzbar.'
509 :
'Amazon SP-API antwortet mit HTTP ' .
$result[
'status'] .
'.',
511 }
catch (\Throwable $e) {
512 return array(
'ok' =>
false,
'message' =>
'Amazon-SP-API-Test fehlgeschlagen: ' . $e->getMessage());
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'] ??
'')),
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',
528 throw new \RuntimeException(
'LWA Token wurde abgelehnt, HTTP ' .
$result[
'status'] .
'.');
530 return (
string)
$result[
'json'][
'access_token'];
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 !==
'') {
539 $category = trim((
string)($channel[
'category_id'] ??
''));
540 if (stripos($category,
'productType:') === 0) {
541 $category = trim(substr($category, strlen(
'productType:')));
543 if (strpos($category,
'/') !==
false) {
544 $category = trim((
string)explode(
'/', $category)[0]);
546 return strtoupper(trim($category));
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 !==
'') {
555 return $this->amazonProductType($channel, $productChannel);
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',
568 if ($sku ===
'') $missing[] =
'Channel-SKU/Artikelnummer';
569 if ($productType ===
'') $missing[] =
'Amazon Product Type';
570 if ($missing !== array()) {
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),
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']));
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'],
603 'fulfillment_availability' => array(array(
604 'fulfillment_channel_code' =>
'DEFAULT',
605 'quantity' => max(0, $quantity),
606 'marketplace_id' => (
string)$channel[
'marketplace_id'],
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']));
613 'productType' => $productType,
614 'requirements' => (
string)($noteData[
'requirements'] ??
'LISTING'),
615 'attributes' => $attributes,
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,
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']),
635 }
catch (\Throwable $e) {
638 'status' =>
'failed',
639 'message' =>
'Amazon-Export fehlgeschlagen: ' . $e->getMessage(),
640 'payload' => $payload,
645 private function testMobile(array $channel): array {
646 $missing = $this->missing($channel, array(
647 'api_username' =>
'API-Benutzer',
648 'api_password' =>
'API-Passwort',
650 if ($missing !== array()) {
651 return array(
'ok' =>
false,
'message' =>
'mobile.de-Konfiguration unvollstaendig: ' . implode(
', ', $missing) .
'.');
655 $base = $this->baseUrl($channel,
'https://services.mobile.de');
656 if (preg_match(
'~/seller-api$~',
$base)) {
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'] ??
'')));
666 ?
'mobile.de Seller API erreichbar. Basic-Auth-Zugang ist nutzbar.'
667 :
'mobile.de Seller API antwortet mit HTTP ' .
$result[
'status'] .
'.',
669 }
catch (\Throwable $e) {
670 return array(
'ok' =>
false,
'message' =>
'mobile.de-Test fehlgeschlagen: ' . $e->getMessage());
674 private function testKleinanzeigen(array $channel): array {
675 $mode = strtolower((string)($channel[
'connection_mode'] ??
'manual'));
676 if ($mode ===
'manual') {
679 'message' =>
'Kleinanzeigen ist als manueller/Partner-Channel konfiguriert. Eine frei nutzbare Standard-API wird hier nicht vorausgesetzt.',
682 $baseUrl = trim((
string)($channel[
'api_base_url'] ??
''));
683 $hasCredentials = trim((
string)($channel[
'api_client_id'] ?? $channel[
'api_username'] ??
'')) !==
'';
684 if ($baseUrl ===
'' || !$hasCredentials) {
687 'message' =>
'Fuer Kleinanzeigen-API/Partnerbetrieb fehlen Middleware-URL und Zugangsdaten. Ohne vertraglich freigegebene Schnittstelle bitte Verbindung auf Manuell stellen.',
690 return $this->testGeneric($channel);
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.');
698 if (!preg_match(
'~^https?://~i', $baseUrl)) {
699 return array(
'ok' =>
false,
'message' =>
'API-Basis-URL muss mit http:// oder https:// beginnen.');
702 $headers = array(
'Accept: application/json');
703 $token = trim((
string)($channel[
'api_access_token'] ??
''));
705 $headers[] =
'Authorization: Bearer ' . $token;
707 $result = $this->curl(
'GET', $baseUrl, $headers);
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());
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'])) {
723 if (!$isVehicle && stripos($baseUrl,
'services.mobile.de') !==
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),
731 if ($isVehicle && $baseUrl !==
'' && stripos($baseUrl,
'services.mobile.de') !==
false) {
732 $payload = $noteData[
'mobile_vehicle'] ?? $noteData;
733 if (!is_array($payload) || $payload === array()) {
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),
742 'sellerInventoryKey' => $this->productSku($product, $productChannel),
743 'price' => array(
'consumerPriceGross' => $this->channelPrice($product, $productChannel),
'type' =>
'FIXED'),
746 $base = $this->baseUrl($channel,
'https://services.mobile.de');
747 if (preg_match(
'~/seller-api$~',
$base)) {
750 $sellerId = trim((
string)($channel[
'account_id'] ?? $channel[
'seller_id'] ??
''));
751 if ($sellerId ===
'') {
752 throw new \RuntimeException(
'mobileSellerId/Account-ID fehlt.');
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));
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']),
765 }
catch (\Throwable $e) {
766 return array(
'ok' =>
false,
'status' =>
'failed',
'message' =>
'mobile.de-Export fehlgeschlagen: ' . $e->getMessage(),
'payload' => $payload);
769 return $this->exportMiddlewareProduct($channel, $product, $productChannel,
'mobile');
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)) {
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),
782 return $this->exportMiddlewareProduct($channel, $product, $productChannel,
'kleinanzeigen');
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);
794 $headers = array(
'Accept: application/json');
795 $token = trim((
string)($channel[
'api_access_token'] ??
''));
797 $headers[] =
'Authorization: Bearer ' . $token;
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'] ??
'');
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']),
810 }
catch (\Throwable $e) {
811 return array(
'ok' =>
false,
'status' =>
'failed',
'message' => ucfirst($provider) .
'-Export fehlgeschlagen: ' . $e->getMessage(),
'payload' => $payload);
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());
820 foreach ($lineItems as $item) {
821 if (!is_array($item))
continue;
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),
830 if ($externalId !==
'') {
831 $payload[
'external_order_id'] = $externalId;
833 $payload[
'payment_status'] = (string)($order[
'orderPaymentStatus'] ?? $order[
'paymentSummary'][
'payments'][0][
'paymentStatus'] ?? $payload[
'payment_status'] ??
'completed');
834 if ($items !== array()) $payload[
'items'] = $items;
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;
845 $payload[
'payment_status'] = (string)($payload[
'payment_status'] ?? $order[
'OrderStatus'] ??
'pending');
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;
854 $payload[
'payment_status'] = (string)($payload[
'payment_status'] ??
'pending');