Add Caching of requests, use default query params (language and currency) using a function, Fix Footprint assignment, translate German code comments

This commit is contained in:
Marc Kreidler 2025-12-07 20:09:32 +01:00
parent 73ecbb53cf
commit cc3fbce962

View file

@ -42,6 +42,12 @@ class BuerklinProvider implements InfoProviderInterface
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';
private const CACHE_TTL = 600;
/**
* Local in-request cache to avoid hitting the PSR cache repeatedly for the same product.
* @var array<string, array>
*/
private array $productCache = [];
public function __construct( public function __construct(
private readonly HttpClientInterface $client, private readonly HttpClientInterface $client,
@ -117,6 +123,43 @@ class BuerklinProvider implements InfoProviderInterface
return $token; return $token;
} }
private function getDefaultQueryParams(): array
{
return [
'curr' => $this->currency ?: 'EUR',
'language' => $this->language ?: 'en',
];
}
private function getProduct(string $code): array
{
$code = strtoupper(trim($code));
if ($code === '') {
throw new \InvalidArgumentException('Product code must not be empty.');
}
$cacheKey = sprintf(
'buerklin.product.%s',
md5($code . '|' . $this->language . '|' . $this->currency)
);
if (isset($this->productCache[$cacheKey])) {
return $this->productCache[$cacheKey];
}
$item = $this->partInfoCache->getItem($cacheKey);
if ($item->isHit() && is_array($cached = $item->get())) {
return $this->productCache[$cacheKey] = $cached;
}
$product = $this->makeAPICall('/products/' . rawurlencode($code) . '/');
$item->set($product);
$item->expiresAfter(self::CACHE_TTL);
$this->partInfoCache->save($item);
return $this->productCache[$cacheKey] = $product;
}
private function makeAPICall(string $endpoint, array $queryParams = []): array private function makeAPICall(string $endpoint, array $queryParams = []): array
{ {
@ -124,7 +167,7 @@ class BuerklinProvider implements InfoProviderInterface
$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(),
'headers' => ['Accept' => 'application/json'], 'headers' => ['Accept' => 'application/json'],
'query' => array_merge(['curr' => $this->currency ?: 'EUR', 'language' => $this->language ?: 'de'], $queryParams), 'query' => array_merge($this->getDefaultQueryParams(), $queryParams),
]); ]);
return $response->toArray(); return $response->toArray();
@ -170,10 +213,7 @@ class BuerklinProvider implements InfoProviderInterface
*/ */
private function queryDetail(string $id): PartDetailDTO private function queryDetail(string $id): PartDetailDTO
{ {
$product = $this->makeAPICall('/products/' . rawurlencode($id) . '/', [ $product = $this->getProduct($id);
'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);
} }
@ -205,38 +245,36 @@ class BuerklinProvider implements InfoProviderInterface
// If this is a search-result object, it may not contain prices/features/images -> reload full detail. // 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'])) { if ((!isset($product['price']) && !isset($product['volumePrices'])) && isset($product['code'])) {
try { try {
$product = $this->makeAPICall('/products/' . rawurlencode((string) $product['code']) . '/'); $product = $this->getProduct((string) $product['code']);
} catch (\Throwable $e) { } catch (\Throwable $e) {
// If reload fails, keep the partial product data and continue. // If reload fails, keep the partial product data and continue.
} }
} }
// Images (already absolute + dedup in getProductImages()) // Extract Images from API response
$productImages = $this->getProductImages($product['images'] ?? null); $productImages = $this->getProductImages($product['images'] ?? null);
// Preview image: DO NOT prefix ENDPOINT_URL here (images are already absolute) // Set Preview image
$preview = $productImages[0]->url ?? null; $preview = $productImages[0]->url ?? null;
// Features live in classifications[0].features in Bürklin JSON // Extract features (parameters) from classifications[0].features of Bürklin JSON response
$features = $product['classifications'][0]['features'] ?? []; $features = $product['classifications'][0]['features'] ?? [];
$group = $product['classifications'][0]['name'] ?? null;
// Feature Parameters (from classifications->features)
$featureParams = $this->attributesToParameters($features, ''); //leave group empty for normal parameters
// 1) Feature-Parameter (aus classifications->features) // Compliance-Parameter (from Top-Level fields like RoHS/SVHC/…)
$featureParams = $this->attributesToParameters($features, $group);
// 2) Compliance-Parameter (aus Top-Level Feldern wie RoHS/SVHC/…)
$complianceParams = $this->complianceToParameters($product, 'Compliance'); $complianceParams = $this->complianceToParameters($product, 'Compliance');
// 3) Zusammenführen // Merge all parameters
$allParams = array_merge($featureParams, $complianceParams); $allParams = array_merge($featureParams, $complianceParams);
// Footprint: "Design" (en) / "Bauform" (de) // Assign Footprint: "Design" (en) / "Bauform" (de) / "Enclosure" (en) / "Gehäuse" (de)
$footprint = null; $footprint = null;
if (is_array($features)) { if (is_array($features)) {
foreach ($features as $feature) { foreach ($features as $feature) {
$name = $feature['name'] ?? null; $name = $feature['name'] ?? null;
if ($name === 'Design' || $name === 'Bauform') { if ($name === 'Design' || $name === 'Bauform' || $name === 'Enclosure' || $name === 'Gehäuse') {
$footprint = $feature['featureValues'][0]['value'] ?? null; $footprint = $feature['featureValues'][0]['value'] ?? null;
break; break;
} }
@ -281,7 +319,7 @@ class BuerklinProvider implements InfoProviderInterface
provider_url: $this->getProductShortURL((string) ($product['code'] ?? $code)), provider_url: $this->getProductShortURL((string) ($product['code'] ?? $code)),
footprint: $footprint, footprint: $footprint,
datasheets: null, // not in /products/{code}/ JSON; you decided to skip for now datasheets: null, // not found in JSON response, the Buerklin website however has links to datasheets
images: $productImages, images: $productImages,
parameters: $allParams, parameters: $allParams,
@ -346,12 +384,10 @@ class BuerklinProvider implements InfoProviderInterface
/** /**
* Returns a deduplicated list of product images as FileDTOs. * Returns a deduplicated list of product images as FileDTOs.
* *
* Bürklin liefert oft mehrere Einträge mit gleicher URL (und verschiedene "format"s). * - takes only real image arrays (with 'url' field)
* Diese Variante: * - makes relative URLs absolut
* - nimmt nur echte URL-Strings * - deduplicates using URL
* - macht relative URLs absolut * - prefers 'zoom' format, then 'product' format, then all others
* - dedupliziert nach URL
* - bevorzugt zoom/product vor thumbnail
* *
* @param array|null $images * @param array|null $images
* @return \App\Services\InfoProviderSystem\DTOs\FileDTO[] * @return \App\Services\InfoProviderSystem\DTOs\FileDTO[]
@ -361,21 +397,21 @@ class BuerklinProvider implements InfoProviderInterface
if (!is_array($images)) if (!is_array($images))
return []; return [];
// 1) Nur echte Image-Arrays // 1) Only real image entries with URL
$imgs = array_values(array_filter($images, fn($i) => is_array($i) && !empty($i['url']))); $imgs = array_values(array_filter($images, fn($i) => is_array($i) && !empty($i['url'])));
// 2) Bevorzuge zoom; wenn vorhanden, nimm ausschließlich zoom // 2) Prefer zoom images
$zoom = array_values(array_filter($imgs, fn($i) => ($i['format'] ?? null) === 'zoom')); $zoom = array_values(array_filter($imgs, fn($i) => ($i['format'] ?? null) === 'zoom'));
$chosen = count($zoom) > 0 $chosen = count($zoom) > 0
? $zoom ? $zoom
: array_values(array_filter($imgs, fn($i) => ($i['format'] ?? null) === 'product')); : array_values(array_filter($imgs, fn($i) => ($i['format'] ?? null) === 'product'));
// 3) Falls auch keine product-Bilder da sind, nimm alles (letzter Fallback) // 3) If still none, take all
if (count($chosen) === 0) { if (count($chosen) === 0) {
$chosen = $imgs; $chosen = $imgs;
} }
// 4) Dedupliziere nach URL + relativ -> absolut // 4) Deduplicate by URL (after making absolute)
$byUrl = []; $byUrl = [];
foreach ($chosen as $img) { foreach ($chosen as $img) {
$url = (string) $img['url']; $url = (string) $img['url'];
@ -417,7 +453,6 @@ class BuerklinProvider implements InfoProviderInterface
if (!is_string($name) || trim($name) === '') if (!is_string($name) || trim($name) === '')
continue; continue;
// Bürklin: featureValues ist ein Array von { value: "..." }
$vals = []; $vals = [];
foreach (($f['featureValues'] ?? []) as $fv) { foreach (($f['featureValues'] ?? []) as $fv) {
if (is_array($fv) && isset($fv['value']) && is_string($fv['value']) && trim($fv['value']) !== '') { if (is_array($fv) && isset($fv['value']) && is_string($fv['value']) && trim($fv['value']) !== '') {
@ -427,16 +462,16 @@ class BuerklinProvider implements InfoProviderInterface
if (count($vals) === 0) if (count($vals) === 0)
continue; continue;
// Mehrfachwerte zusammenführen // Multiple values: join with comma
$value = implode(', ', array_values(array_unique($vals))); $value = implode(', ', array_values(array_unique($vals)));
// Unit/Symbol aus Bürklin (optional) // Unit/Symbol from Buerklin feature
$unit = $f['featureUnit']['symbol'] ?? null; $unit = $f['featureUnit']['symbol'] ?? null;
if (!is_string($unit) || trim($unit) === '') { if (!is_string($unit) || trim($unit) === '') {
$unit = null; $unit = null;
} }
// ParameterDTO kann Zahl/Range/Unit parsing selbst // ParameterDTO parses value field (handles value+unit)
$out[] = ParameterDTO::parseValueField( $out[] = ParameterDTO::parseValueField(
name: $name, name: $name,
value: $value, value: $value,
@ -446,7 +481,7 @@ class BuerklinProvider implements InfoProviderInterface
); );
} }
// Dedupe nach Name (falls Bürklin doppelt liefert) // deduplicate by name
$byName = []; $byName = [];
foreach ($out as $p) { foreach ($out as $p) {
$byName[$p->name] ??= $p; $byName[$p->name] ??= $p;
@ -472,15 +507,14 @@ class BuerklinProvider implements InfoProviderInterface
$products = $response['products'] ?? []; $products = $response['products'] ?? [];
// Normalfall: Search liefert Treffer // Normal case: products found in search results
if (is_array($products) && count($products) > 0) { if (is_array($products) && count($products) > 0) {
return array_map(fn($p) => $this->getPartDetail($p), $products); return array_map(fn($p) => $this->getPartDetail($p), $products);
} }
// Fallback: Bestellnummer/Code direkt abfragen // Fallback: try direct lookup by code
// (funktioniert bei deinen Postman-Tests für /products/{code}/)
try { try {
$product = $this->makeAPICall('/products/' . rawurlencode($keyword) . '/'); $product = $this->getProduct($keyword);
return [$this->getPartDetail($product)]; return [$this->getPartDetail($product)];
} catch (\Throwable $e) { } catch (\Throwable $e) {
return []; return [];
@ -492,10 +526,7 @@ class BuerklinProvider implements InfoProviderInterface
public function getDetails(string $id): PartDetailDTO public function getDetails(string $id): PartDetailDTO
{ {
// Detail endpoint is /products/{code}/ // Detail endpoint is /products/{code}/
$response = $this->makeAPICall('/products/' . rawurlencode($id) . '/', [ $response = $this->getProduct($id);
'curr' => $this->currency,
'language' => $this->language,
]);
return $this->getPartDetail($response); return $this->getPartDetail($response);
} }
@ -538,13 +569,11 @@ class BuerklinProvider implements InfoProviderInterface
); );
}; };
$add('RoHS', $product['labelRoHS'] ?? null); // "Ja" $add('RoHS', $product['labelRoHS'] ?? null); // "yes"/"no"
$add('RoHS date', $product['dateRoHS'] ?? null); // ISO string $add('RoHS date', $product['dateRoHS'] ?? null); // ISO string
$add('SVHC', $product['SVHC'] ?? null); // bool $add('SVHC', $product['SVHC'] ?? null); // bool
$add('Hazardous good', $product['hazardousGood'] ?? null); // bool $add('Hazardous good', $product['hazardousGood'] ?? null); // bool
$add('Hazardous materials', $product['hazardousMaterials'] ?? null); // bool $add('Hazardous materials', $product['hazardousMaterials'] ?? null); // bool
// Optional, oft nützlich:
$add('Country of origin', $product['countryOfOrigin'] ?? null); $add('Country of origin', $product['countryOfOrigin'] ?? null);
$add('Customs code', $product['articleCustomsCode'] ?? null); $add('Customs code', $product['articleCustomsCode'] ?? null);