diff --git a/src/Services/InfoProviderSystem/Providers/BuerklinProvider.php b/src/Services/InfoProviderSystem/Providers/BuerklinProvider.php index 4ce157d6..f4923269 100644 --- a/src/Services/InfoProviderSystem/Providers/BuerklinProvider.php +++ b/src/Services/InfoProviderSystem/Providers/BuerklinProvider.php @@ -38,12 +38,13 @@ use Symfony\Contracts\HttpClient\HttpClientInterface; class BuerklinProvider implements InfoProviderInterface { - private const ENDPOINT_URL = 'https://buerklin.com/buerklinws/v2/buerklin'; + private const ENDPOINT_URL = 'https://www.buerklin.com/buerklinws/v2/buerklin'; public const DISTRIBUTOR_NAME = 'Buerklin'; private const OAUTH_APP_NAME = 'ip_buerklin_oauth'; - public function __construct(private readonly HttpClientInterface $client, + public function __construct( + private readonly HttpClientInterface $client, private readonly OAuthTokenManager $authTokenManager, private readonly CacheItemPoolInterface $partInfoCache, #[Autowire(env: "string:PROVIDER_BUERKLIN_CLIENT_ID")] @@ -54,12 +55,11 @@ class BuerklinProvider implements InfoProviderInterface private readonly string $username = "", #[Autowire(env: "string:PROVIDER_BUERKLIN_PASSWORD")] private readonly string $password = "", - #[Autowire(env: "PROVIDER_BUERKLIN_LANGUAGE")] + #[Autowire(env: "string:PROVIDER_BUERKLIN_LANGUAGE")] private readonly string $language = "en", - #[Autowire(env: "PROVIDER_BUERKLIN_CURRENCY")] + #[Autowire(env: "string:PROVIDER_BUERKLIN_CURRENCY")] private readonly string $currency = "EUR" - ) - { + ) { } @@ -69,34 +69,71 @@ class BuerklinProvider implements InfoProviderInterface */ private function getToken(): string { - if (!$this->authTokenManager->hasToken(self::OAUTH_APP_NAME)) { - $this->authTokenManager->retrieveClientCredentialsToken(self::OAUTH_APP_NAME); - } - - $token = $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME); - - if ($token === null) { - throw new \RuntimeException('Could not retrieve OAuth token for Buerklin'); - } - - return $token; - } + // Cache token to avoid hammering the auth server on every request + $cacheKey = 'buerklin.oauth.token'; + $item = $this->partInfoCache->getItem($cacheKey); + + if ($item->isHit()) { + $token = $item->get(); + if (is_string($token) && $token !== '') { + return $token; + } + } + + // Bürklin OAuth2 password grant (ROPC) + $resp = $this->client->request('POST', 'https://www.buerklin.com/authorizationserver/oauth/token/', [ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/x-www-form-urlencoded', + ], + 'body' => [ + 'grant_type' => 'password', + 'client_id' => $this->clientId, + 'client_secret' => $this->secret, + 'username' => $this->username, + 'password' => $this->password, + ], + ]); + + $data = $resp->toArray(false); + + if (!isset($data['access_token'])) { + throw new \RuntimeException( + 'Invalid token response from Bürklin: HTTP ' . $resp->getStatusCode() . ' body=' . $resp->getContent(false) + ); + } + + $token = (string) $data['access_token']; + + // Cache for (expires_in - 30s) if available + $ttl = 300; + if (isset($data['expires_in']) && is_numeric($data['expires_in'])) { + $ttl = max(60, (int) $data['expires_in'] - 30); + } + + $item->set($token); + $item->expiresAfter($ttl); + $this->partInfoCache->save($item); + + return $token; + } - /** - * Make a http get request to the Buerklin API - * @return array - */ private function makeAPICall(string $endpoint, array $queryParams = []): array { try { $response = $this->client->request('GET', self::ENDPOINT_URL . $endpoint, [ 'auth_bearer' => $this->getToken(), - 'query' => $queryParams, + 'headers' => ['Accept' => 'application/json'], + 'query' => array_merge(['curr' => $this->currency ?: 'EUR', 'language' => $this->language ?: 'de'], $queryParams), ]); return $response->toArray(); } catch (\Exception $e) { - throw new \RuntimeException("Buerklin API request failed: " . $e->getMessage()); + throw new \RuntimeException("Buerklin API request failed: " . + "Endpoint: " . $endpoint . + "Token: [redacted] " . + "QueryParams: " . json_encode($queryParams, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . " " . + "Exception message: " . $e->getMessage()); } } @@ -107,6 +144,7 @@ class BuerklinProvider implements InfoProviderInterface 'name' => 'Buerklin', 'description' => 'This provider uses the Buerklin API to search for parts.', 'url' => 'https://www.buerklin.com/', + 'oauth_app_name' => self::OAUTH_APP_NAME, 'disabled_help' => 'Set the environment variables PROVIDER_BUERKLIN_CLIENT_ID, PROVIDER_BUERKLIN_SECRET, PROVIDER_BUERKLIN_USERNAME and PROVIDER_BUERKLIN_PASSWORD.' ]; } @@ -120,7 +158,10 @@ class BuerklinProvider implements InfoProviderInterface public function isActive(): bool { //The client ID has to be set and a token has to be available (user clicked connect) - return $this->clientId !== '' && $this->secret !== '' && $this->username !== '' && $this->password !== ''; + return $this->clientId !== '' + && $this->secret !== '' + && $this->username !== '' + && $this->password !== ''; } /** @@ -129,9 +170,10 @@ class BuerklinProvider implements InfoProviderInterface */ private function queryDetail(string $id): PartDetailDTO { - $arr = $this->makeAPICall('/products', ['sku' => $id]); - $product = $arr['result'] ?? null; - + $product = $this->makeAPICall('/products/' . rawurlencode($id) . '/', [ + 'curr' => $this->currency, + 'language' => $this->language, + ]); if ($product === null) { throw new \RuntimeException('Could not find product code: ' . $id); } @@ -160,41 +202,96 @@ class BuerklinProvider implements InfoProviderInterface */ private function getPartDetail(array $product): PartDetailDTO { - // Get product images in advance - $product_images = $this->getProductImages($product['images'] ?? null); - $product['productImageUrl'] ??= null; - - // If the product does not have a product image but otherwise has attached images, use the first one which should be thumbnail. - if (count($product_images) > 0) { - $product['productImageUrl'] ??= self::ENDPOINT_URL . $product_images[0]->url; + // If this is a search-result object, it may not contain prices/features/images -> reload full detail. + if ((!isset($product['price']) && !isset($product['volumePrices'])) && isset($product['code'])) { + try { + $product = $this->makeAPICall('/products/' . rawurlencode((string) $product['code']) . '/'); + } catch (\Throwable $e) { + // If reload fails, keep the partial product data and continue. + } } - // Find the footprint in classifications->features. en: name='Design'; de: name='Bauform' - if (isset($product['classifications']['features'])) { - foreach ($product['classifications']['features'] as $feature) { - if (isset($feature['name']) && ($feature['name'] === 'Design' || $feature['name'] === 'Bauform')) { - $footprint = $feature['featureValues']['value'] ?? null; + // Images (already absolute + dedup in getProductImages()) + $productImages = $this->getProductImages($product['images'] ?? null); + + // Preview image: DO NOT prefix ENDPOINT_URL here (images are already absolute) + $preview = $productImages[0]->url ?? null; + + // Features live in classifications[0].features in Bürklin JSON + $features = $product['classifications'][0]['features'] ?? []; + $group = $product['classifications'][0]['name'] ?? null; + + + // 1) Feature-Parameter (aus classifications->features) + $featureParams = $this->attributesToParameters($features, $group); + + // 2) Compliance-Parameter (aus Top-Level Feldern wie RoHS/SVHC/…) + $complianceParams = $this->complianceToParameters($product, 'Compliance'); + + // 3) Zusammenführen + $allParams = array_merge($featureParams, $complianceParams); + + // Footprint: "Design" (en) / "Bauform" (de) + $footprint = null; + if (is_array($features)) { + foreach ($features as $feature) { + $name = $feature['name'] ?? null; + if ($name === 'Design' || $name === 'Bauform') { + $footprint = $feature['featureValues'][0]['value'] ?? null; break; } } } + // Prices: prefer volumePrices, fallback to single price + $code = (string) ($product['orderNumber'] ?? $product['code'] ?? ''); + $prices = $product['volumePrices'] ?? null; + + if (!is_array($prices) || count($prices) === 0) { + $pVal = $product['price']['value'] ?? null; + $pCur = $product['price']['currencyIso'] ?? ($this->currency ?: 'EUR'); + + if (is_numeric($pVal)) { + $prices = [ + [ + 'minQuantity' => 1, + 'value' => (float) $pVal, + 'currencyIso' => (string) $pCur, + ] + ]; + } else { + $prices = []; + } + } + return new PartDetailDTO( provider_key: $this->getProviderKey(), - provider_id: $product['code'], - name: $product['name'], - description: $this->sanitizeField($product['description']), - category: $this->sanitizeField($product['classifications'][0]['name'] ?? null), + provider_id: (string) ($product['code'] ?? $code), + + name: (string) ($product['manufacturerProductId'] ?? $code), + description: $this->sanitizeField($product['description'] ?? null), + + category: $this->sanitizeField($product['classifications'][0]['name'] ?? ($product['categories'][0]['name'] ?? null)), manufacturer: $this->sanitizeField($product['manufacturer'] ?? null), mpn: $this->sanitizeField($product['manufacturerProductId'] ?? null), - preview_image_url: $product['productImageUrl'], + + preview_image_url: $preview, manufacturing_status: null, - provider_url: $this->getProductShortURL($product['code']), - footprint: $footprint ?? null, - datasheets: null, //datasheet urls not found in API responses - images: $product_images, - parameters: $this->attributesToParameters($product['classifications']['features'] ?? []), - vendor_infos: $this->pricesToVendorInfo($product['code'], $this->getProductShortURL($product['code']), $product['productPriceList'] ?? []), + + provider_url: $this->getProductShortURL((string) ($product['code'] ?? $code)), + footprint: $footprint, + + datasheets: null, // not in /products/{code}/ JSON; you decided to skip for now + images: $productImages, + + parameters: $allParams, + + vendor_infos: $this->pricesToVendorInfo( + sku: $code, + url: $this->getProductShortURL($code), + prices: $prices + ), + mass: $product['weight'] ?? null, ); } @@ -208,12 +305,22 @@ class BuerklinProvider implements InfoProviderInterface */ private function pricesToVendorInfo(string $sku, string $url, array $prices): array { - $priceDTOs = array_map(fn($price) => new PriceDTO( - minimum_discount_amount: $price['minQuantity'], - price: $price['value'], - currency_iso_code: $price['currencyIso'], - includes_tax: false - ), $prices); + $priceDTOs = array_map(function ($price) { + $val = $price['value'] ?? null; + $valStr = is_numeric($val) + ? number_format((float) $val, 6, '.', '') // 6 Nachkommastellen, trailing zeros ok + : (string) $val; + + // Optional: weich kürzen (z.B. 75.550000 -> 75.55) + $valStr = rtrim(rtrim($valStr, '0'), '.'); + + return new PriceDTO( + minimum_discount_amount: (float) ($price['minQuantity'] ?? 1), + price: $valStr, + currency_iso_code: (string) ($price['currencyIso'] ?? $this->currency ?? 'EUR'), + includes_tax: false + ); + }, $prices); return [ new PurchaseInfoDTO( @@ -233,80 +340,214 @@ class BuerklinProvider implements InfoProviderInterface */ private function getProductShortURL(string $product_code): string { - return 'https://www.buerklin.com/de/p/' . $product_code .'/'; + return 'https://www.buerklin.com/de/p/' . $product_code . '/'; } /** - * Returns a FileDTO array with a list of product images - * @param array|null $images - * @return FileDTO[] + * Returns a deduplicated list of product images as FileDTOs. + * + * Bürklin liefert oft mehrere Einträge mit gleicher URL (und verschiedene "format"s). + * Diese Variante: + * - nimmt nur echte URL-Strings + * - macht relative URLs absolut + * - dedupliziert nach URL + * - bevorzugt zoom/product vor thumbnail + * + * @param array|null $images + * @return \App\Services\InfoProviderSystem\DTOs\FileDTO[] */ private function getProductImages(?array $images): array { - return array_map(static fn($image) => new FileDTO($image), $images ?? []); + if (!is_array($images)) + return []; + + // 1) Nur echte Image-Arrays + $imgs = array_values(array_filter($images, fn($i) => is_array($i) && !empty($i['url']))); + + // 2) Bevorzuge zoom; wenn vorhanden, nimm ausschließlich zoom + $zoom = array_values(array_filter($imgs, fn($i) => ($i['format'] ?? null) === 'zoom')); + $chosen = count($zoom) > 0 + ? $zoom + : array_values(array_filter($imgs, fn($i) => ($i['format'] ?? null) === 'product')); + + // 3) Falls auch keine product-Bilder da sind, nimm alles (letzter Fallback) + if (count($chosen) === 0) { + $chosen = $imgs; + } + + // 4) Dedupliziere nach URL + relativ -> absolut + $byUrl = []; + foreach ($chosen as $img) { + $url = (string) $img['url']; + + if (!str_starts_with($url, 'http://') && !str_starts_with($url, 'https://')) { + $url = 'https://www.buerklin.com' . $url; + } + if (!filter_var($url, FILTER_VALIDATE_URL)) + continue; + + $byUrl[$url] = $url; + } + + return array_map( + fn($url) => new \App\Services\InfoProviderSystem\DTOs\FileDTO($url), + array_values($byUrl) + ); } + + /** * @param array|null $attributes * @return ParameterDTO[] */ - private function attributesToParameters(?array $attributes): array + private function attributesToParameters(array $features, ?string $group = null): array { - $result = []; - - foreach ($attributes as $attribute) { - if (!isset($attribute['name'], $attribute['featureValues']['value'])) { + if (!is_array($features)) { + return []; + } + + $out = []; + + foreach ($features as $f) { + if (!is_array($f)) continue; - } - - $value = trim((string)$attribute['featureValues']['value']); - if ($value === '' || $value === '-') { + + $name = $f['name'] ?? null; + if (!is_string($name) || trim($name) === '') continue; + + // Bürklin: featureValues ist ein Array von { value: "..." } + $vals = []; + foreach (($f['featureValues'] ?? []) as $fv) { + if (is_array($fv) && isset($fv['value']) && is_string($fv['value']) && trim($fv['value']) !== '') { + $vals[] = trim($fv['value']); + } } - - $result[] = ParameterDTO::parseValueIncludingUnit( - name: $attribute['name'], + if (count($vals) === 0) + continue; + + // Mehrfachwerte zusammenführen + $value = implode(', ', array_values(array_unique($vals))); + + // Unit/Symbol aus Bürklin (optional) + $unit = $f['featureUnit']['symbol'] ?? null; + if (!is_string($unit) || trim($unit) === '') { + $unit = null; + } + + // ParameterDTO kann Zahl/Range/Unit parsing selbst + $out[] = ParameterDTO::parseValueField( + name: $name, value: $value, - group: null + unit: $unit, + symbol: null, + group: $group ); } - - return $result; + + // Dedupe nach Name (falls Bürklin doppelt liefert) + $byName = []; + foreach ($out as $p) { + $byName[$p->name] ??= $p; + } + + return array_values($byName); } - + public function searchByKeyword(string $keyword): array { - $response = $this->makeAPICall('/products/search', [ - 'curr' => $this->currency, - 'language' => $this->language, - 'pageSize' => '50', - 'currentPage' => '1', - 'keyword' => $keyword, - 'sort' => 'relevance']); - - return array_map(fn($product) => $this->getPartDetail($product), $response['products'] ?? []); + $keyword = strtoupper(trim($keyword)); + if ($keyword === '') { + return []; + } + + $response = $this->makeAPICall('/products/search/', [ + 'pageSize' => 50, + 'currentPage' => 1, + 'query' => $keyword, + 'sort' => 'relevance', + ]); + + $products = $response['products'] ?? []; + + // Normalfall: Search liefert Treffer + if (is_array($products) && count($products) > 0) { + return array_map(fn($p) => $this->getPartDetail($p), $products); + } + + // Fallback: Bestellnummer/Code direkt abfragen + // (funktioniert bei deinen Postman-Tests für /products/{code}/) + try { + $product = $this->makeAPICall('/products/' . rawurlencode($keyword) . '/'); + return [$this->getPartDetail($product)]; + } catch (\Throwable $e) { + return []; + } } + + public function getDetails(string $id): PartDetailDTO { - $response = $this->makeAPICall("/products", ['sku' => $id]); - - if (empty($response['result'])) { - throw new \RuntimeException("No part found with ID $id"); - } - - return $this->getPartDetail($response['result']); + // Detail endpoint is /products/{code}/ + $response = $this->makeAPICall('/products/' . rawurlencode($id) . '/', [ + 'curr' => $this->currency, + 'language' => $this->language, + ]); + + return $this->getPartDetail($response); } public function getCapabilities(): array { return [ ProviderCapabilities::BASIC, - ProviderCapabilities::FOOTPRINT, ProviderCapabilities::PICTURE, ProviderCapabilities::DATASHEET, ProviderCapabilities::PRICE, + ProviderCapabilities::FOOTPRINT, ]; } + private function complianceToParameters(array $product, ?string $group = 'Compliance'): array + { + $params = []; + + $add = function (string $name, $value) use (&$params, $group) { + if ($value === null) + return; + + if (is_bool($value)) { + $value = $value ? 'Yes' : 'No'; + } elseif (is_array($value) || is_object($value)) { + // avoid dumping huge structures + return; + } else { + $value = trim((string) $value); + if ($value === '') + return; + } + + $params[] = ParameterDTO::parseValueField( + name: $name, + value: (string) $value, + unit: null, + symbol: null, + group: $group + ); + }; + + $add('RoHS', $product['labelRoHS'] ?? null); // "Ja" + $add('RoHS date', $product['dateRoHS'] ?? null); // ISO string + $add('SVHC', $product['SVHC'] ?? null); // bool + $add('Hazardous good', $product['hazardousGood'] ?? null); // bool + $add('Hazardous materials', $product['hazardousMaterials'] ?? null); // bool + + // Optional, oft nützlich: + $add('Country of origin', $product['countryOfOrigin'] ?? null); + $add('Customs code', $product['articleCustomsCode'] ?? null); + + return $params; + } }