Added an TME data provider

This commit is contained in:
Jan Böhmer 2023-07-15 01:01:20 +02:00
parent 0cb46039dd
commit f9fdae9de9
7 changed files with 397 additions and 1 deletions

View file

@ -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
####################################################################################################################

View file

@ -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('/^(?<value>[0-9.]+)\s*(?<unit>[a-zA-Z]+)$/', $value, $matches)) {
$value = $matches['value'];
$unit = $matches['unit'];
}
return new self($name, value_text: $value, unit: $unit, symbol: $symbol, group: $group);

View file

@ -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,

View file

@ -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 */

View file

@ -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 ?? '');

View file

@ -0,0 +1,92 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 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 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;
}
}

View file

@ -0,0 +1,276 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 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\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<string, list<FileDTO>> An array with the keys 'datasheet'
* @phpstan-return array{datasheets: list<FileDTO>}
*/
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,
];
}
}