diff --git a/docs/usage/information_provider_system.md b/docs/usage/information_provider_system.md index 13df7f10..da8ea32b 100644 --- a/docs/usage/information_provider_system.md +++ b/docs/usage/information_provider_system.md @@ -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 diff --git a/src/Services/InfoProviderSystem/Providers/ConradProvider.php b/src/Services/InfoProviderSystem/Providers/ConradProvider.php new file mode 100644 index 00000000..044eb7a7 --- /dev/null +++ b/src/Services/InfoProviderSystem/Providers/ConradProvider.php @@ -0,0 +1,320 @@ +. + */ + +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 + */ + 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 + */ + 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, + ]; + } +} diff --git a/src/Settings/InfoProviderSystem/ConradSettings.php b/src/Settings/InfoProviderSystem/ConradSettings.php new file mode 100644 index 00000000..dda884c8 --- /dev/null +++ b/src/Settings/InfoProviderSystem/ConradSettings.php @@ -0,0 +1,77 @@ +. + */ + +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']; +} diff --git a/src/Settings/InfoProviderSystem/ConradShopIDs.php b/src/Settings/InfoProviderSystem/ConradShopIDs.php new file mode 100644 index 00000000..e39ed7b1 --- /dev/null +++ b/src/Settings/InfoProviderSystem/ConradShopIDs.php @@ -0,0 +1,167 @@ +. + */ + +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', + }; + } +} diff --git a/src/Settings/InfoProviderSystem/InfoProviderSettings.php b/src/Settings/InfoProviderSystem/InfoProviderSettings.php index d4679e23..fb31bdb9 100644 --- a/src/Settings/InfoProviderSystem/InfoProviderSettings.php +++ b/src/Settings/InfoProviderSystem/InfoProviderSettings.php @@ -63,7 +63,10 @@ class InfoProviderSettings #[EmbeddedSettings] public ?PollinSettings $pollin = null; - + #[EmbeddedSettings] public ?BuerklinSettings $buerklin = null; + + #[EmbeddedSettings] + public ?ConradSettings $conrad = null; } diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 5c4151d6..b2bd908e 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -9928,13 +9928,13 @@ Element 1 -> Element 1.2]]> project.builds.number_of_builds_possible - You have enough stocked to build <b>%max_builds%</b> builds of this [project]. + %max_builds% builds of this [project].]]> project.builds.check_project_status - The current [project] status is <b>"%project_status%"</b>. You should check if you really want to build the [project] with this status! + "%project_status%". You should check if you really want to build the [project] with this status!]]> @@ -14286,5 +14286,35 @@ Buerklin-API Authentication server: Transport error while retrieving information from the providers. Check that your server has internet accesss. See server logs for more info. + + + settings.ips.conrad + Conrad + + + + + settings.ips.conrad.shopID + Shop ID + + + + + settings.ips.conrad.shopID.description + 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. + + + + + settings.ips.conrad.attachment_language_filter + Language filter for attachments + + + + + settings.ips.conrad.attachment_language_filter.description + Only includes attachments in the selected languages in the results. + +