Merge branch 'conrad_provider'

Makes PR #1211 obsolete
This commit is contained in:
Jan Böhmer 2026-01-31 22:12:02 +01:00
commit ed39710f7f
6 changed files with 610 additions and 3 deletions

View file

@ -278,6 +278,16 @@ The following env configuration options are available:
* `PROVIDER_BUERKLIN_CURRENCY`: The currency you want to get prices in if available (optional, 3 letter ISO-code, default: `EUR`).
* `PROVIDER_BUERKLIN_LANGUAGE`: The language you want to get the descriptions in. Possible values: `de` = German, `en` = English. (optional, default: `en`)
### Conrad
The conrad provider the [Conrad API](https://developer.conrad.com/) to search for parts and retried their information.
To use it you have to request access to the API, however it seems currently your mail address needs to be allowlisted before you can register for an account.
The conrad webpages uses the API key in the requests, so you might be able to extract a working API key by listening to browser requests.
That method is not officially supported nor encouraged by Part-DB, and might break at any moment.
The following env configuration options are available:
* `PROVIDER_CONRAD_API_KEY`: The API key you got from Conrad (mandatory)
### Custom provider
To create a custom provider, you have to create a new class implementing the `InfoProviderInterface` interface. As long

View file

@ -0,0 +1,320 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Services\InfoProviderSystem\Providers;
use App\Services\InfoProviderSystem\DTOs\FileDTO;
use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
use App\Settings\InfoProviderSystem\ConradSettings;
use Symfony\Contracts\HttpClient\HttpClientInterface;
readonly class ConradProvider implements InfoProviderInterface
{
private const SEARCH_ENDPOINT = '/search/1/v3/facetSearch';
public const DISTRIBUTOR_NAME = 'Conrad';
private HttpClientInterface $httpClient;
public function __construct( HttpClientInterface $httpClient, private ConradSettings $settings)
{
//We want everything in JSON
$this->httpClient = $httpClient->withOptions([
'headers' => [
'Accept' => 'application/json',
],
]);
}
public function getProviderInfo(): array
{
return [
'name' => 'Conrad',
'description' => 'Retrieves part information from conrad.de',
'url' => 'https://www.conrad.de/',
'disabled_help' => 'Set API key in settings',
'settings_class' => ConradSettings::class,
];
}
public function getProviderKey(): string
{
return 'conrad';
}
public function isActive(): bool
{
return !empty($this->settings->apiKey);
}
private function getProductUrl(string $productId): string
{
return 'https://' . $this->settings->shopID->getDomain() . '/' . $this->settings->shopID->getLanguage() . '/p/' . $productId;
}
private function getFootprintFromTechnicalDetails(array $technicalDetails): ?string
{
foreach ($technicalDetails as $detail) {
if ($detail['name'] === 'ATT_LOV_HOUSING_SEMICONDUCTORS') {
return $detail['values'][0] ?? null;
}
}
return null;
}
public function searchByKeyword(string $keyword): array
{
$url = $this->settings->shopID->getAPIRoot() . self::SEARCH_ENDPOINT . '/'
. $this->settings->shopID->getDomainEnd() . '/' . $this->settings->shopID->getLanguage()
. '/' . $this->settings->shopID->getCustomerType();
$response = $this->httpClient->request('POST', $url, [
'query' => [
'apikey' => $this->settings->apiKey,
],
'json' => [
'query' => $keyword,
'size' => 50,
'sort' => [["field"=>"_score","order"=>"desc"]],
],
]);
$out = [];
$results = $response->toArray();
foreach($results['hits'] as $result) {
$out[] = new SearchResultDTO(
provider_key: $this->getProviderKey(),
provider_id: $result['productId'],
name: $result['manufacturerId'] ?? $result['productId'],
description: $result['title'] ?? '',
manufacturer: $result['brand']['name'] ?? null,
mpn: $result['manufacturerId'] ?? null,
preview_image_url: $result['image'] ?? null,
provider_url: $this->getProductUrl($result['productId']),
footprint: $this->getFootprintFromTechnicalDetails($result['technicalDetails'] ?? []),
);
}
return $out;
}
private function getFootprintFromTechnicalAttributes(array $technicalDetails): ?string
{
foreach ($technicalDetails as $detail) {
if ($detail['attributeID'] === 'ATT.LOV.HOUSING_SEMICONDUCTORS') {
return $detail['values'][0]['value'] ?? null;
}
}
return null;
}
/**
* @param array $technicalAttributes
* @return array<ParameterDTO>
*/
private function technicalAttributesToParameters(array $technicalAttributes): array
{
return array_map(static function (array $p) {
if (count($p['values']) === 1) { //Single value attribute
if (array_key_exists('unit', $p['values'][0])) {
return ParameterDTO::parseValueField( //With unit
name: $p['attributeName'],
value: $p['values'][0]['value'],
unit: $p['values'][0]['unit']['name'],
);
}
return ParameterDTO::parseValueIncludingUnit(
name: $p['attributeName'],
value: $p['values'][0]['value'],
);
}
if (count($p['values']) === 2) { //Multi value attribute (e.g. min/max)
$value = $p['values'][0]['value'] ?? null;
$value2 = $p['values'][1]['value'] ?? null;
$unit = $p['values'][0]['unit']['name'] ?? '';
$unit2 = $p['values'][1]['unit']['name'] ?? '';
if ($unit === $unit2 && is_numeric($value) && is_numeric($value2)) {
if (array_key_exists('unit', $p['values'][0])) { //With unit
return new ParameterDTO(
name: $p['attributeName'],
value_min: (float)$value,
value_max: (float)$value2,
unit: $unit,
);
}
return new ParameterDTO(
name: $p['attributeName'],
value_min: (float)$value,
value_max: (float)$value2,
);
}
}
// fallback implementation
$values = implode(", ", array_map(fn($q) =>
array_key_exists('unit', $q) ? $q['value']." ". ($q['unit']['name'] ?? $q['unit']) : $q['value']
, $p['values']));
return ParameterDTO::parseValueIncludingUnit(
name: $p['attributeName'],
value: $values,
);
}, $technicalAttributes);
}
/**
* @param array $productMedia
* @return array<FileDTO>
*/
public function productMediaToDatasheets(array $productMedia): array
{
$files = [];
foreach ($productMedia['manuals'] as $manual) {
//Filter out unwanted languages
if (!empty($this->settings->attachmentLanguageFilter) && !in_array($manual['language'], $this->settings->attachmentLanguageFilter, true)) {
continue;
}
$files[] = new FileDTO($manual['fullUrl'], $manual['title'] . ' (' . $manual['language'] . ')');
}
return $files;
}
/**
* Queries prices for a given product ID. It makes a POST request to the Conrad API
* @param string $productId
* @return PurchaseInfoDTO
*/
private function queryPrices(string $productId): PurchaseInfoDTO
{
$priceQueryURL = $this->settings->shopID->getAPIRoot() . '/price-availability/4/'
. $this->settings->shopID->getShopID() . '/facade';
$response = $this->httpClient->request('POST', $priceQueryURL, [
'query' => [
'apikey' => $this->settings->apiKey,
'overrideCalculationSchema' => $this->settings->includeVAT ? 'GROSS' : 'NET'
],
'json' => [
'ns:inputArticleItemList' => [
"#namespaces" => [
"ns" => "http://www.conrad.de/ccp/basit/service/article/priceandavailabilityservice/api"
],
'articles' => [
[
"articleID" => $productId,
"calculatePrice" => true,
"checkAvailability" => true,
],
]
]
]
]);
$result = $response->toArray();
$priceInfo = $result['priceAndAvailabilityFacadeResponse']['priceAndAvailability']['price'] ?? [];
$price = $priceInfo['price'] ?? "0.0";
$currency = $priceInfo['currency'] ?? "EUR";
$includesVat = $priceInfo['isGrossAmount'] === "true" ?? true;
$minOrderAmount = $result['priceAndAvailabilityFacadeResponse']['priceAndAvailability']['availabilityStatus']['minimumOrderQuantity'] ?? 1;
$prices = [];
foreach ($priceInfo['priceScale'] ?? [] as $priceScale) {
$prices[] = new PriceDTO(
minimum_discount_amount: max($priceScale['scaleFrom'], $minOrderAmount),
price: (string)$priceScale['pricePerUnit'],
currency_iso_code: $currency,
includes_tax: $includesVat
);
}
if (empty($prices)) { //Fallback if no price scales are defined
$prices[] = new PriceDTO(
minimum_discount_amount: $minOrderAmount,
price: (string)$price,
currency_iso_code: $currency,
includes_tax: $includesVat
);
}
return new PurchaseInfoDTO(
distributor_name: self::DISTRIBUTOR_NAME,
order_number: $productId,
prices: $prices,
product_url: $this->getProductUrl($productId)
);
}
public function getDetails(string $id): PartDetailDTO
{
$productInfoURL = $this->settings->shopID->getAPIRoot() . '/product/1/service/' . $this->settings->shopID->getShopID()
. '/product/' . $id;
$response = $this->httpClient->request('GET', $productInfoURL, [
'query' => [
'apikey' => $this->settings->apiKey,
]
]);
$data = $response->toArray();
return new PartDetailDTO(
provider_key: $this->getProviderKey(),
provider_id: $data['shortProductNumber'],
name: $data['productFullInformation']['manufacturer']['name'] ?? $data['productFullInformation']['manufacturer']['id'] ?? $data['shortProductNumber'],
description: $data['productShortInformation']['title'] ?? '',
category: $data['productShortInformation']['articleGroupName'] ?? null,
manufacturer: $data['brand']['displayName'] !== null ? preg_replace("/[\u{2122}\u{00ae}]/", "", $data['brand']['displayName']) : null, //Replace ™ and ® symbols
mpn: $data['productFullInformation']['manufacturer']['id'] ?? null,
preview_image_url: $data['productShortInformation']['mainImage']['imageUrl'] ?? null,
provider_url: $this->getProductUrl($data['shortProductNumber']),
footprint: $this->getFootprintFromTechnicalAttributes($data['productFullInformation']['technicalAttributes'] ?? []),
notes: $data['productFullInformation']['description'] ?? null,
datasheets: $this->productMediaToDatasheets($data['productMedia'] ?? []),
parameters: $this->technicalAttributesToParameters($data['productFullInformation']['technicalAttributes'] ?? []),
vendor_infos: [$this->queryPrices($data['shortProductNumber'])]
);
}
public function getCapabilities(): array
{
return [
ProviderCapabilities::BASIC,
ProviderCapabilities::PICTURE,
ProviderCapabilities::DATASHEET,
ProviderCapabilities::PRICE,
];
}
}

View file

@ -0,0 +1,77 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Settings\InfoProviderSystem;
use App\Form\Type\APIKeyType;
use App\Settings\SettingsIcon;
use Jbtronics\SettingsBundle\Metadata\EnvVarMode;
use Jbtronics\SettingsBundle\ParameterTypes\ArrayType;
use Jbtronics\SettingsBundle\ParameterTypes\StringType;
use Jbtronics\SettingsBundle\Settings\Settings;
use Jbtronics\SettingsBundle\Settings\SettingsParameter;
use Jbtronics\SettingsBundle\Settings\SettingsTrait;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\CountryType;
use Symfony\Component\Form\Extension\Core\Type\EnumType;
use Symfony\Component\Form\Extension\Core\Type\LanguageType;
use Symfony\Component\Translation\TranslatableMessage as TM;
use Symfony\Component\Validator\Constraints as Assert;
#[Settings(label: new TM("settings.ips.conrad"))]
#[SettingsIcon("fa-plug")]
class ConradSettings
{
use SettingsTrait;
#[SettingsParameter(label: new TM("settings.ips.element14.apiKey"),
formType: APIKeyType::class,
formOptions: ["help_html" => true], envVar: "PROVIDER_CONRAD_API_KEY", envVarMode: EnvVarMode::OVERWRITE)]
public ?string $apiKey = null;
#[SettingsParameter(label: new TM("settings.ips.conrad.shopID"),
description: new TM("settings.ips.conrad.shopID.description"),
formType: EnumType::class,
formOptions: ['class' => ConradShopIDs::class],
)]
public ConradShopIDs $shopID = ConradShopIDs::COM_B2B;
#[SettingsParameter(label: new TM("settings.ips.reichelt.include_vat"))]
public bool $includeVAT = true;
/**
* @var array|string[] Only attachments in these languages will be downloaded (ISO 639-1 codes)
*/
#[Assert\Unique()]
#[Assert\All([new Assert\Language()])]
#[SettingsParameter(type: ArrayType::class,
label: new TM("settings.ips.conrad.attachment_language_filter"), description: new TM("settings.ips.conrad.attachment_language_filter.description"),
options: ['type' => StringType::class],
formType: LanguageType::class,
formOptions: [
'multiple' => true,
'preferred_choices' => ['en', 'de', 'fr', 'it', 'cs', 'da', 'nl', 'hu', 'hr', 'sk', 'pl']
],
)]
public array $attachmentLanguageFilter = ['en'];
}

View file

@ -0,0 +1,167 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Settings\InfoProviderSystem;
use Symfony\Contracts\Translation\TranslatableInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
enum ConradShopIDs: string implements TranslatableInterface
{
case COM_B2B = 'HP_COM_B2B';
case DE_B2B = 'CQ_DE_B2B';
case DE_B2C = 'CQ_DE_B2C';
case AT_B2C = 'CQ_AT_B2C';
case CH_B2C_DE = 'CQ_CH_B2C_DE';
case CH_B2C_FR = 'CQ_CH_B2C_FR';
case SE_B2B = 'HP_SE_B2B';
case HU_B2C = 'CQ_HU_B2C';
case CZ_B2B = 'HP_CZ_B2B';
case SI_B2B = 'HP_SI_B2B';
case SK_B2B = 'HP_SK_B2B';
case BE_B2B = 'HP_BE_B2B';
case PL_B2B = 'HP_PL_B2B';
case NL_B2B = 'CQ_NL_B2B';
case NL_B2C = 'CQ_NL_B2C';
case DK_B2B = 'HP_DK_B2B';
case IT_B2B = 'HP_IT_B2B';
case FR_B2B = 'HP_FR_B2B';
case AT_B2B = 'CQ_AT_B2B';
case HR_B2B = 'HP_HR_B2B';
public function trans(TranslatorInterface $translator, ?string $locale = null): string
{
return match ($this) {
self::DE_B2B => "conrad.de (B2B)",
self::AT_B2C => "conrad.at (B2C)",
self::CH_B2C_DE => "conrad.ch DE (B2C)",
self::CH_B2C_FR => "conrad.ch FR (B2C)",
self::SE_B2B => "conrad.se (B2B)",
self::HU_B2C => "conrad.hu (B2C)",
self::CZ_B2B => "conrad.cz (B2B)",
self::SI_B2B => "conrad.si (B2B)",
self::SK_B2B => "conrad.sk (B2B)",
self::BE_B2B => "conrad.be (B2B)",
self::DE_B2C => "conrad.de (B2C)",
self::PL_B2B => "conrad.pl (B2B)",
self::NL_B2B => "conrad.nl (B2B)",
self::DK_B2B => "conradelektronik.dk (B2B)",
self::IT_B2B => "conrad.it (B2B)",
self::NL_B2C => "conrad.nl (B2C)",
self::FR_B2B => "conrad.fr (B2B)",
self::COM_B2B => "conrad.com (B2B)",
self::AT_B2B => "conrad.at (B2B)",
self::HR_B2B => "conrad.hr (B2B)",
};
}
public function getDomain(): string
{
if ($this === self::DK_B2B) {
return 'conradelektronik.dk';
}
return 'conrad.' . $this->getDomainEnd();
}
/**
* Retrieves the API root URL for this shop ID. e.g. https://api.conrad.de
* @return string
*/
public function getAPIRoot(): string
{
return 'https://api.' . $this->getDomain();
}
/**
* Returns the shop ID value used in the API requests. e.g. 'CQ_DE_B2B'
* @return string
*/
public function getShopID(): string
{
if ($this === self::CH_B2C_FR || $this === self::CH_B2C_DE) {
return 'CQ_CH_B2C';
}
return $this->value;
}
public function getDomainEnd(): string
{
return match ($this) {
self::DE_B2B, self::DE_B2C => 'de',
self::AT_B2B, self::AT_B2C => 'at',
self::CH_B2C_DE => 'ch', self::CH_B2C_FR => 'ch',
self::SE_B2B => 'se',
self::HU_B2C => 'hu',
self::CZ_B2B => 'cz',
self::SI_B2B => 'si',
self::SK_B2B => 'sk',
self::BE_B2B => 'be',
self::PL_B2B => 'pl',
self::NL_B2B, self::NL_B2C => 'nl',
self::DK_B2B => 'dk',
self::IT_B2B => 'it',
self::FR_B2B => 'fr',
self::COM_B2B => 'com',
self::HR_B2B => 'hr',
};
}
public function getLanguage(): string
{
return match ($this) {
self::DE_B2B, self::DE_B2C, self::AT_B2B, self::AT_B2C => 'de',
self::CH_B2C_DE => 'de', self::CH_B2C_FR => 'fr',
self::SE_B2B => 'sv',
self::HU_B2C => 'hu',
self::CZ_B2B => 'cs',
self::SI_B2B => 'sl',
self::SK_B2B => 'sk',
self::BE_B2B => 'nl',
self::PL_B2B => 'pl',
self::NL_B2B, self::NL_B2C => 'nl',
self::DK_B2B => 'da',
self::IT_B2B => 'it',
self::FR_B2B => 'fr',
self::COM_B2B => 'en',
self::HR_B2B => 'hr',
};
}
/**
* Retrieves the customer type for this shop ID. e.g. 'b2b' or 'b2c'
* @return string 'b2b' or 'b2c'
*/
public function getCustomerType(): string
{
return match ($this) {
self::DE_B2B, self::AT_B2B, self::SE_B2B, self::CZ_B2B, self::SI_B2B,
self::SK_B2B, self::BE_B2B, self::PL_B2B, self::NL_B2B, self::DK_B2B,
self::IT_B2B, self::FR_B2B, self::COM_B2B, self::HR_B2B => 'b2b',
self::DE_B2C, self::AT_B2C, self::CH_B2C_DE, self::CH_B2C_FR, self::HU_B2C, self::NL_B2C => 'b2c',
};
}
}

View file

@ -63,7 +63,10 @@ class InfoProviderSettings
#[EmbeddedSettings]
public ?PollinSettings $pollin = null;
#[EmbeddedSettings]
public ?BuerklinSettings $buerklin = null;
#[EmbeddedSettings]
public ?ConradSettings $conrad = null;
}

View file

@ -9928,13 +9928,13 @@ Element 1 -> Element 1.2]]></target>
<unit id="NdZ1t7a" name="project.builds.number_of_builds_possible">
<segment state="translated">
<source>project.builds.number_of_builds_possible</source>
<target>You have enough stocked to build &lt;b&gt;%max_builds%&lt;/b&gt; builds of this [project].</target>
<target><![CDATA[You have enough stocked to build <b>%max_builds%</b> builds of this [project].]]></target>
</segment>
</unit>
<unit id="iuSpPbg" name="project.builds.check_project_status">
<segment state="translated">
<source>project.builds.check_project_status</source>
<target>The current [project] status is &lt;b&gt;"%project_status%"&lt;/b&gt;. You should check if you really want to build the [project] with this status!</target>
<target><![CDATA[The current [project] status is <b>"%project_status%"</b>. You should check if you really want to build the [project] with this status!]]></target>
</segment>
</unit>
<unit id="Y7vSSxi" name="project.builds.following_bom_entries_miss_instock_n">
@ -14286,5 +14286,35 @@ Buerklin-API Authentication server:
<target>Transport error while retrieving information from the providers. Check that your server has internet accesss. See server logs for more info.</target>
</segment>
</unit>
<unit id="kHKChQB" name="settings.ips.conrad">
<segment>
<source>settings.ips.conrad</source>
<target>Conrad</target>
</segment>
</unit>
<unit id="gwZXJ0F" name="settings.ips.conrad.shopID">
<segment>
<source>settings.ips.conrad.shopID</source>
<target>Shop ID</target>
</segment>
</unit>
<unit id="honqnBf" name="settings.ips.conrad.shopID.description">
<segment>
<source>settings.ips.conrad.shopID.description</source>
<target>The version of the conrad store you wanna get results from. This determines language, prices and currency of the results. If both a B2B and a B2C version if available, you should choose the B2C version if you want prices including VAT. </target>
</segment>
</unit>
<unit id="EVcQbEK" name="settings.ips.conrad.attachment_language_filter">
<segment>
<source>settings.ips.conrad.attachment_language_filter</source>
<target>Language filter for attachments</target>
</segment>
</unit>
<unit id="MWPmQf2" name="settings.ips.conrad.attachment_language_filter.description">
<segment>
<source>settings.ips.conrad.attachment_language_filter.description</source>
<target>Only includes attachments in the selected languages in the results.</target>
</segment>
</unit>
</file>
</xliff>