diff --git a/config/services.yaml b/config/services.yaml index cec5019d..54807566 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -248,6 +248,11 @@ services: arguments: $api_key: '%env(PROVIDER_ELEMENT14_KEY)%' + App\Services\InfoProviderSystem\Providers\TMEClient: + arguments: + $secret: '%env(PROVIDER_TME_SECRET)%' + $token: '%env(PROVIDER_TME_KEY)%' + #################################################################################################################### # Symfony overrides #################################################################################################################### diff --git a/src/Services/InfoProviderSystem/DTOs/ParameterDTO.php b/src/Services/InfoProviderSystem/DTOs/ParameterDTO.php index 9613a859..4c82f1db 100644 --- a/src/Services/InfoProviderSystem/DTOs/ParameterDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/ParameterDTO.php @@ -41,7 +41,22 @@ class ParameterDTO public static function parseValueField(string $name, string|float $value, ?string $unit = null, ?string $symbol = null, ?string $group = null): self { if (is_float($value) || is_numeric($value)) { - return new self($name, value_typ: (float) $value, unit: $unit, symbol: $symbol); + return new self($name, value_typ: (float) $value, unit: $unit, symbol: $symbol, group: $group); + } + + return new self($name, value_text: $value, unit: $unit, symbol: $symbol, group: $group); + } + + public static function parseValueIncludingUnit(string $name, string|float $value, ?string $symbol = null, ?string $group = null): self + { + if (is_float($value) || is_numeric($value)) { + return new self($name, value_typ: (float) $value, symbol: $symbol, group: $group); + } + + $unit = null; + if (preg_match('/^(?[0-9.]+)\s*(?[a-zA-Z]+)$/', $value, $matches)) { + $value = $matches['value']; + $unit = $matches['unit']; } return new self($name, value_text: $value, unit: $unit, symbol: $symbol, group: $group); diff --git a/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php b/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php index 8854241d..f24e220f 100644 --- a/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php @@ -33,6 +33,7 @@ class PartDetailDTO extends SearchResultDTO string $provider_id, string $name, string $description, + ?string $category = null, ?string $manufacturer = null, ?string $mpn = null, ?string $preview_image_url = null, @@ -46,12 +47,15 @@ class PartDetailDTO extends SearchResultDTO public readonly ?array $parameters = null, /** @var PurchaseInfoDTO[]|null */ public readonly ?array $vendor_infos = null, + /** The mass of the product in grams */ + public readonly ?float $mass = null, ) { parent::__construct( provider_key: $provider_key, provider_id: $provider_id, name: $name, description: $description, + category: $category, manufacturer: $manufacturer, mpn: $mpn, preview_image_url: $preview_image_url, diff --git a/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php b/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php index 3687dca9..228944ab 100644 --- a/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php @@ -36,6 +36,8 @@ class SearchResultDTO public readonly string $name, /** @var string A short description of the part */ public readonly string $description, + /** @var string|null The category the distributor assumes for the part */ + public readonly ?string $category = null, /** @var string|null The manufacturer of the part */ public readonly ?string $manufacturer = null, /** @var string|null The manufacturer part number */ diff --git a/src/Services/InfoProviderSystem/DTOtoEntityConverter.php b/src/Services/InfoProviderSystem/DTOtoEntityConverter.php index 96323df4..36a449e9 100644 --- a/src/Services/InfoProviderSystem/DTOtoEntityConverter.php +++ b/src/Services/InfoProviderSystem/DTOtoEntityConverter.php @@ -122,6 +122,8 @@ class DTOtoEntityConverter $entity->setDescription($dto->description ?? ''); $entity->setComment($dto->notes ?? ''); + $entity->setMass($dto->mass); + $entity->setManufacturer($this->getOrCreateEntity(Manufacturer::class, $dto->manufacturer)); $entity->setManufacturerProductNumber($dto->mpn ?? ''); diff --git a/src/Services/InfoProviderSystem/Providers/TMEClient.php b/src/Services/InfoProviderSystem/Providers/TMEClient.php new file mode 100644 index 00000000..8c2a4430 --- /dev/null +++ b/src/Services/InfoProviderSystem/Providers/TMEClient.php @@ -0,0 +1,92 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\InfoProviderSystem\Providers; + +use Symfony\Component\HttpClient\DecoratorTrait; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; +use Symfony\Contracts\HttpClient\ResponseStreamInterface; + +class TMEClient +{ + public const BASE_URI = 'https://api.tme.eu'; + + public function __construct(private readonly HttpClientInterface $tmeClient, private readonly string $token, private readonly string $secret) + { + + } + + public function makeRequest(string $action, array $parameters): ResponseInterface + { + $parameters['Token'] = $this->token; + $parameters['ApiSignature'] = $this->getSignature($action, $parameters, $this->secret); + + return $this->tmeClient->request('POST', $this->getUrlForAction($action), [ + 'body' => $parameters, + ]); + } + + public function isUsable(): bool + { + if ($this->token === '' || $this->secret === '') { + return false; + } + + return true; + } + + + /** + * Generates the signature for the given action and parameters. + * Taken from https://github.com/tme-dev/TME-API/blob/master/PHP/basic/using_curl.php + */ + public function getSignature(string $action, array $parameters, string $appSecret): string + { + $parameters = $this->sortSignatureParams($parameters); + + $queryString = http_build_query($parameters, '', '&', PHP_QUERY_RFC3986); + $signatureBase = strtoupper('POST') . + '&' . rawurlencode($this->getUrlForAction($action)) . '&' . rawurlencode($queryString); + + return base64_encode(hash_hmac('sha1', $signatureBase, $appSecret, true)); + } + + private function getUrlForAction(string $action): string + { + return self::BASE_URI . '/' . $action . '.json'; + } + + private function sortSignatureParams(array $params): array + { + ksort($params); + + foreach ($params as &$value) { + if (is_array($value)) { + $value = $this->sortSignatureParams($value); + } + } + + return $params; + } +} \ No newline at end of file diff --git a/src/Services/InfoProviderSystem/Providers/TMEProvider.php b/src/Services/InfoProviderSystem/Providers/TMEProvider.php new file mode 100644 index 00000000..506e87f2 --- /dev/null +++ b/src/Services/InfoProviderSystem/Providers/TMEProvider.php @@ -0,0 +1,276 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\InfoProviderSystem\Providers; + +use App\Entity\Parts\ManufacturingStatus; +use App\Entity\Parts\Part; +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 Symfony\Contracts\HttpClient\HttpClientInterface; + +class TMEProvider implements InfoProviderInterface +{ + + private const VENDOR_NAME = 'TME'; + + private string $country = 'DE'; + private string $language = 'en'; + private string $currency = 'EUR'; + /** + * @var bool If true, the prices are gross prices. If false, the prices are net prices. + */ + private bool $get_gross_prices = true; + + public function __construct(private readonly TMEClient $tmeClient) + { + + } + + public function getProviderInfo(): array + { + return [ + 'name' => 'TME', + 'description' => 'This provider uses the API of TME (Transfer Multipart).', + 'url' => 'https://tme.eu/', + ]; + } + + public function getProviderKey(): string + { + return 'tme'; + } + + public function isActive(): bool + { + return $this->tmeClient->isUsable(); + } + + public function searchByKeyword(string $keyword): array + { + $response = $this->tmeClient->makeRequest('Products/Search', [ + 'Country' => $this->country, + 'Language' => $this->language, + 'SearchPlain' => $keyword, + ]); + + $data = $response->toArray()['Data']; + + $result = []; + + foreach($data['ProductList'] as $product) { + $result[] = new SearchResultDTO( + provider_key: $this->getProviderKey(), + provider_id: $product['Symbol'], + name: $product['OriginalSymbol'] ?? $product['Symbol'], + description: $product['Description'], + category: $product['Category'], + manufacturer: $product['Producer'], + mpn: $product['OriginalSymbol'] ?? null, + preview_image_url: $this->normalizeURL($product['Photo']), + manufacturing_status: $this->productStatusArrayToManufacturingStatus($product['ProductStatusList']), + provider_url: $this->normalizeURL($product['ProductInformationPage']), + ); + } + + return $result; + } + + public function getDetails(string $id): PartDetailDTO + { + $response = $this->tmeClient->makeRequest('Products/GetProducts', [ + 'Country' => $this->country, + 'Language' => $this->language, + 'SymbolList' => [$id], + ]); + + $product = $response->toArray()['Data']['ProductList'][0]; + + //Add a explicit https:// to the url if it is missing + $productInfoPage = $this->normalizeURL($product['ProductInformationPage']); + + $files = $this->getFiles($id); + + return new PartDetailDTO( + provider_key: $this->getProviderKey(), + provider_id: $product['Symbol'], + name: $product['OriginalSymbol'] ?? $product['Symbol'], + description: $product['Description'], + category: $product['Category'], + manufacturer: $product['Producer'], + mpn: $product['OriginalSymbol'] ?? null, + preview_image_url: $this->normalizeURL($product['Photo']), + manufacturing_status: $this->productStatusArrayToManufacturingStatus($product['ProductStatusList']), + provider_url: $productInfoPage, + datasheets: $files['datasheets'], + parameters: $this->getParameters($id), + vendor_infos: [$this->getVendorInfo($id, $productInfoPage)], + mass: $product['WeightUnit'] === 'g' ? $product['Weight'] : null, + ); + } + + /** + * Fetches all files for a given product id + * @param string $id + * @return array> An array with the keys 'datasheet' + * @phpstan-return array{datasheets: list} + */ + public function getFiles(string $id): array + { + $response = $this->tmeClient->makeRequest('Products/GetProductsFiles', [ + 'Country' => $this->country, + 'Language' => $this->language, + 'SymbolList' => [$id], + ]); + + $data = $response->toArray()['Data']; + $files = $data['ProductList'][0]['Files']; + + //Extract datasheets + $documentList = $files['DocumentList']; + $datasheets = []; + foreach($documentList as $document) { + $datasheets[] = new FileDTO( + url: $this->normalizeURL($document['DocumentUrl']), + ); + } + + + return [ + 'datasheets' => $datasheets, + ]; + } + + /** + * Fetches the vendor/purchase information for a given product id. + * @param string $id + * @param string|null $productURL + * @return PurchaseInfoDTO + */ + public function getVendorInfo(string $id, ?string $productURL = null): PurchaseInfoDTO + { + $response = $this->tmeClient->makeRequest('Products/GetPricesAndStocks', [ + 'Country' => $this->country, + 'Language' => $this->language, + 'Currency' => $this->currency, + 'GrossPrices' => $this->get_gross_prices, + 'SymbolList' => [$id], + ]); + + $data = $response->toArray()['Data']; + $currency = $data['Currency']; + $include_tax = $data['PriceType'] === 'GROSS'; + + + $product = $response->toArray()['Data']['ProductList'][0]; + $vendor_order_number = $product['Symbol']; + $priceList = $product['PriceList']; + + $prices = []; + foreach ($priceList as $price) { + $prices[] = new PriceDTO( + minimum_discount_amount: $price['Amount'], + price: (string) $price['PriceValue'], + currency_iso_code: $currency, + includes_tax: $include_tax, + ); + } + + return new PurchaseInfoDTO( + distributor_name: self::VENDOR_NAME, + order_number: $vendor_order_number, + prices: $prices, + product_url: $productURL, + ); + } + + /** + * Fetches the parameters of a product + * @param string $id + * @return ParameterDTO[] + */ + public function getParameters(string $id): array + { + $response = $this->tmeClient->makeRequest('Products/GetParameters', [ + 'Country' => $this->country, + 'Language' => $this->language, + 'SymbolList' => [$id], + ]); + + $data = $response->toArray()['Data']['ProductList'][0]; + + $result = []; + + foreach($data['ParameterList'] as $parameter) { + $result[] = ParameterDTO::parseValueIncludingUnit($parameter['ParameterName'], $parameter['ParameterValue']); + } + + return $result; + } + + /** + * Convert the array of product statuses to a single manufacturing status + * @param array $statusArray + * @return ManufacturingStatus + */ + private function productStatusArrayToManufacturingStatus(array $statusArray): ManufacturingStatus + { + if (in_array('AVAILABLE_WHILE_STOCKS_LAST', $statusArray, true)) { + return ManufacturingStatus::EOL; + } + + if (in_array('INVALID', $statusArray, true)) { + return ManufacturingStatus::DISCONTINUED; + } + + //By default we assume that the part is active + return ManufacturingStatus::ACTIVE; + } + + + + private function normalizeURL(string $url): string + { + //If a URL starts with // we assume that it is a relative URL and we add the protocol + if (str_starts_with($url, '//')) { + return 'https:' . $url; + } + + return $url; + } + + public function getCapabilities(): array + { + return [ + ProviderCapabilities::BASIC, + ProviderCapabilities::FOOTPRINT, + ProviderCapabilities::PICTURE, + ProviderCapabilities::DATASHEET, + ProviderCapabilities::PRICE, + ]; + } +} \ No newline at end of file