Fix API-Access, add image, price and parameter retrieval (Datasheets not yet implemented because it is not available in the API response)

This commit is contained in:
Marc Kreidler 2025-12-06 23:53:14 +01:00
parent 351078fb43
commit 73ecbb53cf

View file

@ -38,12 +38,13 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
class BuerklinProvider implements InfoProviderInterface 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'; public const DISTRIBUTOR_NAME = 'Buerklin';
private const OAUTH_APP_NAME = 'ip_buerklin_oauth'; 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 OAuthTokenManager $authTokenManager,
private readonly CacheItemPoolInterface $partInfoCache, private readonly CacheItemPoolInterface $partInfoCache,
#[Autowire(env: "string:PROVIDER_BUERKLIN_CLIENT_ID")] #[Autowire(env: "string:PROVIDER_BUERKLIN_CLIENT_ID")]
@ -54,12 +55,11 @@ class BuerklinProvider implements InfoProviderInterface
private readonly string $username = "", private readonly string $username = "",
#[Autowire(env: "string:PROVIDER_BUERKLIN_PASSWORD")] #[Autowire(env: "string:PROVIDER_BUERKLIN_PASSWORD")]
private readonly string $password = "", private readonly string $password = "",
#[Autowire(env: "PROVIDER_BUERKLIN_LANGUAGE")] #[Autowire(env: "string:PROVIDER_BUERKLIN_LANGUAGE")]
private readonly string $language = "en", private readonly string $language = "en",
#[Autowire(env: "PROVIDER_BUERKLIN_CURRENCY")] #[Autowire(env: "string:PROVIDER_BUERKLIN_CURRENCY")]
private readonly string $currency = "EUR" private readonly string $currency = "EUR"
) ) {
{
} }
@ -69,34 +69,71 @@ class BuerklinProvider implements InfoProviderInterface
*/ */
private function getToken(): string private function getToken(): string
{ {
if (!$this->authTokenManager->hasToken(self::OAUTH_APP_NAME)) { // Cache token to avoid hammering the auth server on every request
$this->authTokenManager->retrieveClientCredentialsToken(self::OAUTH_APP_NAME); $cacheKey = 'buerklin.oauth.token';
$item = $this->partInfoCache->getItem($cacheKey);
if ($item->isHit()) {
$token = $item->get();
if (is_string($token) && $token !== '') {
return $token;
}
} }
$token = $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME); // 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,
],
]);
if ($token === null) { $data = $resp->toArray(false);
throw new \RuntimeException('Could not retrieve OAuth token for Buerklin');
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; return $token;
} }
/**
* Make a http get request to the Buerklin API
* @return array
*/
private function makeAPICall(string $endpoint, array $queryParams = []): array private function makeAPICall(string $endpoint, array $queryParams = []): array
{ {
try { try {
$response = $this->client->request('GET', self::ENDPOINT_URL . $endpoint, [ $response = $this->client->request('GET', self::ENDPOINT_URL . $endpoint, [
'auth_bearer' => $this->getToken(), '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(); return $response->toArray();
} catch (\Exception $e) { } 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', 'name' => 'Buerklin',
'description' => 'This provider uses the Buerklin API to search for parts.', 'description' => 'This provider uses the Buerklin API to search for parts.',
'url' => 'https://www.buerklin.com/', '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.' '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 public function isActive(): bool
{ {
//The client ID has to be set and a token has to be available (user clicked connect) //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 private function queryDetail(string $id): PartDetailDTO
{ {
$arr = $this->makeAPICall('/products', ['sku' => $id]); $product = $this->makeAPICall('/products/' . rawurlencode($id) . '/', [
$product = $arr['result'] ?? null; 'curr' => $this->currency,
'language' => $this->language,
]);
if ($product === null) { if ($product === null) {
throw new \RuntimeException('Could not find product code: ' . $id); throw new \RuntimeException('Could not find product code: ' . $id);
} }
@ -160,41 +202,96 @@ class BuerklinProvider implements InfoProviderInterface
*/ */
private function getPartDetail(array $product): PartDetailDTO private function getPartDetail(array $product): PartDetailDTO
{ {
// Get product images in advance // If this is a search-result object, it may not contain prices/features/images -> reload full detail.
$product_images = $this->getProductImages($product['images'] ?? null); if ((!isset($product['price']) && !isset($product['volumePrices'])) && isset($product['code'])) {
$product['productImageUrl'] ??= null; try {
$product = $this->makeAPICall('/products/' . rawurlencode((string) $product['code']) . '/');
// If the product does not have a product image but otherwise has attached images, use the first one which should be thumbnail. } catch (\Throwable $e) {
if (count($product_images) > 0) { // If reload fails, keep the partial product data and continue.
$product['productImageUrl'] ??= self::ENDPOINT_URL . $product_images[0]->url; }
} }
// Find the footprint in classifications->features. en: name='Design'; de: name='Bauform' // Images (already absolute + dedup in getProductImages())
if (isset($product['classifications']['features'])) { $productImages = $this->getProductImages($product['images'] ?? null);
foreach ($product['classifications']['features'] as $feature) {
if (isset($feature['name']) && ($feature['name'] === 'Design' || $feature['name'] === 'Bauform')) { // Preview image: DO NOT prefix ENDPOINT_URL here (images are already absolute)
$footprint = $feature['featureValues']['value'] ?? null; $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; 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( return new PartDetailDTO(
provider_key: $this->getProviderKey(), provider_key: $this->getProviderKey(),
provider_id: $product['code'], provider_id: (string) ($product['code'] ?? $code),
name: $product['name'],
description: $this->sanitizeField($product['description']), name: (string) ($product['manufacturerProductId'] ?? $code),
category: $this->sanitizeField($product['classifications'][0]['name'] ?? null), description: $this->sanitizeField($product['description'] ?? null),
category: $this->sanitizeField($product['classifications'][0]['name'] ?? ($product['categories'][0]['name'] ?? null)),
manufacturer: $this->sanitizeField($product['manufacturer'] ?? null), manufacturer: $this->sanitizeField($product['manufacturer'] ?? null),
mpn: $this->sanitizeField($product['manufacturerProductId'] ?? null), mpn: $this->sanitizeField($product['manufacturerProductId'] ?? null),
preview_image_url: $product['productImageUrl'],
preview_image_url: $preview,
manufacturing_status: null, manufacturing_status: null,
provider_url: $this->getProductShortURL($product['code']),
footprint: $footprint ?? null, provider_url: $this->getProductShortURL((string) ($product['code'] ?? $code)),
datasheets: null, //datasheet urls not found in API responses footprint: $footprint,
images: $product_images,
parameters: $this->attributesToParameters($product['classifications']['features'] ?? []), datasheets: null, // not in /products/{code}/ JSON; you decided to skip for now
vendor_infos: $this->pricesToVendorInfo($product['code'], $this->getProductShortURL($product['code']), $product['productPriceList'] ?? []), images: $productImages,
parameters: $allParams,
vendor_infos: $this->pricesToVendorInfo(
sku: $code,
url: $this->getProductShortURL($code),
prices: $prices
),
mass: $product['weight'] ?? null, mass: $product['weight'] ?? null,
); );
} }
@ -208,12 +305,22 @@ class BuerklinProvider implements InfoProviderInterface
*/ */
private function pricesToVendorInfo(string $sku, string $url, array $prices): array private function pricesToVendorInfo(string $sku, string $url, array $prices): array
{ {
$priceDTOs = array_map(fn($price) => new PriceDTO( $priceDTOs = array_map(function ($price) {
minimum_discount_amount: $price['minQuantity'], $val = $price['value'] ?? null;
price: $price['value'], $valStr = is_numeric($val)
currency_iso_code: $price['currencyIso'], ? number_format((float) $val, 6, '.', '') // 6 Nachkommastellen, trailing zeros ok
includes_tax: false : (string) $val;
), $prices);
// 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 [ return [
new PurchaseInfoDTO( new PurchaseInfoDTO(
@ -233,80 +340,214 @@ class BuerklinProvider implements InfoProviderInterface
*/ */
private function getProductShortURL(string $product_code): string 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 * Returns a deduplicated list of product images as FileDTOs.
* @param array|null $images *
* @return FileDTO[] * 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 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 * @param array|null $attributes
* @return ParameterDTO[] * @return ParameterDTO[]
*/ */
private function attributesToParameters(?array $attributes): array private function attributesToParameters(array $features, ?string $group = null): array
{ {
$result = []; if (!is_array($features)) {
return [];
}
foreach ($attributes as $attribute) { $out = [];
if (!isset($attribute['name'], $attribute['featureValues']['value'])) {
foreach ($features as $f) {
if (!is_array($f))
continue; continue;
$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']);
}
}
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;
} }
$value = trim((string)$attribute['featureValues']['value']); // ParameterDTO kann Zahl/Range/Unit parsing selbst
if ($value === '' || $value === '-') { $out[] = ParameterDTO::parseValueField(
continue; name: $name,
}
$result[] = ParameterDTO::parseValueIncludingUnit(
name: $attribute['name'],
value: $value, 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 public function searchByKeyword(string $keyword): array
{ {
$response = $this->makeAPICall('/products/search', [ $keyword = strtoupper(trim($keyword));
'curr' => $this->currency, if ($keyword === '') {
'language' => $this->language, return [];
'pageSize' => '50', }
'currentPage' => '1',
'keyword' => $keyword,
'sort' => 'relevance']);
return array_map(fn($product) => $this->getPartDetail($product), $response['products'] ?? []); $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 public function getDetails(string $id): PartDetailDTO
{ {
$response = $this->makeAPICall("/products", ['sku' => $id]); // Detail endpoint is /products/{code}/
$response = $this->makeAPICall('/products/' . rawurlencode($id) . '/', [
'curr' => $this->currency,
'language' => $this->language,
]);
if (empty($response['result'])) { return $this->getPartDetail($response);
throw new \RuntimeException("No part found with ID $id");
}
return $this->getPartDetail($response['result']);
} }
public function getCapabilities(): array public function getCapabilities(): array
{ {
return [ return [
ProviderCapabilities::BASIC, ProviderCapabilities::BASIC,
ProviderCapabilities::FOOTPRINT,
ProviderCapabilities::PICTURE, ProviderCapabilities::PICTURE,
ProviderCapabilities::DATASHEET, ProviderCapabilities::DATASHEET,
ProviderCapabilities::PRICE, 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;
}
} }