diff --git a/docs/usage/information_provider_system.md b/docs/usage/information_provider_system.md index 6cdb5183..1600d76f 100644 --- a/docs/usage/information_provider_system.md +++ b/docs/usage/information_provider_system.md @@ -303,7 +303,17 @@ That method is not officially supported nor encouraged by Part-DB, and might bre The following env configuration options are available: * `PROVIDER_CONRAD_API_KEY`: The API key you got from Conrad (mandatory) -### Custom provider +### Canopy / Amazon +The Canopy provider uses the [Canopy API](https://www.canopyapi.co/) to search for parts and get shopping information from Amazon. +Canopy is a third-party service that provides access to Amazon product data through their API. Their trial plan offers 100 requests per month for free, +and they also offer paid plans with higher limits. To use the Canopy provider, you need to create an account on the Canopy website and obtain an API key. +Once you have the API key, you can configure the Canopy provider in Part-DB using the web UI or environment variables: + +* `PROVIDER_CANOPY_API_KEY`: The API key you got from Canopy (mandatory) + + + +### Custom providers To create a custom provider, you have to create a new class implementing the `InfoProviderInterface` interface. As long as it is a valid Symfony service, it will be automatically loaded and can be used. diff --git a/src/Form/LabelSystem/ScanDialogType.php b/src/Form/LabelSystem/ScanDialogType.php index d9c1de0e..cd1440c6 100644 --- a/src/Form/LabelSystem/ScanDialogType.php +++ b/src/Form/LabelSystem/ScanDialogType.php @@ -72,12 +72,7 @@ class ScanDialogType extends AbstractType 'placeholder' => 'scan_dialog.mode.auto', 'choice_label' => fn (?BarcodeSourceType $enum) => match($enum) { null => 'scan_dialog.mode.auto', - BarcodeSourceType::INTERNAL => 'scan_dialog.mode.internal', - BarcodeSourceType::IPN => 'scan_dialog.mode.ipn', - BarcodeSourceType::USER_DEFINED => 'scan_dialog.mode.user', - BarcodeSourceType::EIGP114 => 'scan_dialog.mode.eigp', - BarcodeSourceType::GTIN => 'scan_dialog.mode.gtin', - BarcodeSourceType::LCSC => 'scan_dialog.mode.lcsc', + default => 'scan_dialog.mode.' . $enum->value, }, ]); diff --git a/src/Repository/PartRepository.php b/src/Repository/PartRepository.php index 49342301..d5ccd3a6 100644 --- a/src/Repository/PartRepository.php +++ b/src/Repository/PartRepository.php @@ -454,4 +454,28 @@ class PartRepository extends NamedDBElementRepository return $qb->getQuery()->getOneOrNullResult(); } + /** + * Finds a part based on the provided SPN (Supplier Part Number), with an option for case sensitivity. + * If no part is found with the given SPN, null is returned. + * @param string $spn + * @param bool $caseInsensitive + * @return Part|null + */ + public function getPartBySPN(string $spn, bool $caseInsensitive = true): ?Part + { + $qb = $this->createQueryBuilder('part'); + $qb->select('part'); + + $qb->leftJoin('part.orderdetails', 'o'); + + if ($caseInsensitive) { + $qb->where("LOWER(o.supplierpartnr) = LOWER(:spn)"); + } else { + $qb->where("o.supplierpartnr = :spn"); + } + + $qb->setParameter('spn', $spn); + + return $qb->getQuery()->getOneOrNullResult(); + } } diff --git a/src/Services/InfoProviderSystem/PartInfoRetriever.php b/src/Services/InfoProviderSystem/PartInfoRetriever.php index 5cc23f05..db1895e7 100644 --- a/src/Services/InfoProviderSystem/PartInfoRetriever.php +++ b/src/Services/InfoProviderSystem/PartInfoRetriever.php @@ -95,7 +95,7 @@ final class PartInfoRetriever $escaped_keyword = hash('xxh3', $keyword); return $this->partInfoCache->get("search_{$provider->getProviderKey()}_{$escaped_keyword}", function (ItemInterface $item) use ($provider, $keyword) { //Set the expiration time - $item->expiresAfter(!$this->debugMode ? self::CACHE_RESULT_EXPIRATION : 1); + $item->expiresAfter(!$this->debugMode ? self::CACHE_RESULT_EXPIRATION : 10); return $provider->searchByKeyword($keyword); }); @@ -122,7 +122,7 @@ final class PartInfoRetriever $escaped_part_id = hash('xxh3', $part_id); return $this->partInfoCache->get("details_{$provider_key}_{$escaped_part_id}", function (ItemInterface $item) use ($provider, $part_id) { //Set the expiration time - $item->expiresAfter(!$this->debugMode ? self::CACHE_DETAIL_EXPIRATION : 1); + $item->expiresAfter(!$this->debugMode ? self::CACHE_DETAIL_EXPIRATION : 10); return $provider->getDetails($part_id); }); diff --git a/src/Services/InfoProviderSystem/Providers/CanopyProvider.php b/src/Services/InfoProviderSystem/Providers/CanopyProvider.php new file mode 100644 index 00000000..131db15f --- /dev/null +++ b/src/Services/InfoProviderSystem/Providers/CanopyProvider.php @@ -0,0 +1,227 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\InfoProviderSystem\Providers; + +use App\Services\InfoProviderSystem\DTOs\FileDTO; +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\BuerklinSettings; +use App\Settings\InfoProviderSystem\CanopySettings; +use Psr\Cache\CacheItemPoolInterface; +use Symfony\Component\DependencyInjection\Attribute\When; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * Use canopy API to retrieve infos from amazon + */ +class CanopyProvider implements InfoProviderInterface +{ + + public const BASE_URL = "https://rest.canopyapi.co/api"; + public const SEARCH_API_URL = self::BASE_URL . "/amazon/search"; + public const DETAIL_API_URL = self::BASE_URL . "/amazon/product"; + + public const DISTRIBUTOR_NAME = 'Amazon'; + + public function __construct(private readonly CanopySettings $settings, + private readonly HttpClientInterface $httpClient, private readonly CacheItemPoolInterface $partInfoCache) + { + + } + + public function getProviderInfo(): array + { + return [ + 'name' => 'Amazon (Canopy)', + 'description' => 'Retrieves part infos from Amazon using the Canopy API', + 'url' => 'https://canopyapi.co', + 'disabled_help' => 'Set Canopy API key in the provider configuration to enable this provider', + 'settings_class' => CanopySettings::class + ]; + } + + public function getProviderKey(): string + { + return 'canopy'; + } + + public function isActive(): bool + { + return $this->settings->apiKey !== null; + } + + private function productPageFromASIN(string $asin): string + { + return "https://www.{$this->settings->getRealDomain()}/dp/{$asin}"; + } + + /** + * Saves the given part to the cache. + * Everytime this function is called, the cache is overwritten. + * @param PartDetailDTO $part + * @return void + */ + private function saveToCache(PartDetailDTO $part): void + { + $key = 'canopy_part_'.$part->provider_id; + + $item = $this->partInfoCache->getItem($key); + $item->set($part); + $item->expiresAfter(3600 * 24); //Cache for 1 day + $this->partInfoCache->save($item); + } + + /** + * Retrieves a from the cache, or null if it was not cached yet. + * @param string $id + * @return PartDetailDTO|null + */ + private function getFromCache(string $id): ?PartDetailDTO + { + $key = 'canopy_part_'.$id; + + $item = $this->partInfoCache->getItem($key); + if ($item->isHit()) { + return $item->get(); + } + + return null; + } + + public function searchByKeyword(string $keyword): array + { + $response = $this->httpClient->request('GET', self::SEARCH_API_URL, [ + 'query' => [ + 'domain' => $this->settings->domain, + 'searchTerm' => $keyword, + ], + 'headers' => [ + 'API-KEY' => $this->settings->apiKey, + ] + ]); + + $data = $response->toArray(); + $results = $data['data']['amazonProductSearchResults']['productResults']['results'] ?? []; + + $out = []; + foreach ($results as $result) { + + + $dto = new PartDetailDTO( + provider_key: $this->getProviderKey(), + provider_id: $result['asin'], + name: $result["title"], + description: "", + preview_image_url: $result["mainImageUrl"] ?? null, + provider_url: $this->productPageFromASIN($result['asin']), + vendor_infos: [$this->priceToPurchaseInfo($result['price'], $result['asin'])] + ); + + $out[] = $dto; + $this->saveToCache($dto); + } + + return $out; + } + + private function categoriesToCategory(array $categories): ?string + { + if (count($categories) === 0) { + return null; + } + + return implode(" -> ", array_map(static fn($cat) => $cat['name'], $categories)); + } + + private function feauturesBulletsToNotes(array $featureBullets): string + { + $notes = ""; + return $notes; + } + + private function priceToPurchaseInfo(?array $price, string $asin): PurchaseInfoDTO + { + $priceDtos = []; + if ($price !== null) { + $priceDtos[] = new PriceDTO(minimum_discount_amount: 1, price: (string) $price['value'], currency_iso_code: $price['currency'], includes_tax: true); + } + + + return new PurchaseInfoDTO(self::DISTRIBUTOR_NAME, order_number: $asin, prices: $priceDtos, product_url: $this->productPageFromASIN($asin)); + } + + public function getDetails(string $id): PartDetailDTO + { + //Check that the id is a valid ASIN (10 characters, letters and numbers) + if (!preg_match('/^[A-Z0-9]{10}$/', $id)) { + throw new \InvalidArgumentException("The id must be a valid ASIN (10 characters, letters and numbers)"); + } + + //Use cached details if available and the settings allow it, to avoid unnecessary API requests, since the search results already contain most of the details + if(!$this->settings->alwaysGetDetails && ($cached = $this->getFromCache($id)) !== null) { + return $cached; + } + + $response = $this->httpClient->request('GET', self::DETAIL_API_URL, [ + 'query' => [ + 'asin' => $id, + 'domain' => $this->settings->domain, + ], + 'headers' => [ + 'API-KEY' => $this->settings->apiKey, + ], + ]); + + $product = $response->toArray()['data']['amazonProduct']; + + + return new PartDetailDTO( + provider_key: $this->getProviderKey(), + provider_id: $product['asin'], + name: $product['title'], + description: '', + category: $this->categoriesToCategory($product['categories']), + manufacturer: $product['brand'] ?? null, + preview_image_url: $product['mainImageUrl'] ?? $product['imageUrls'][0] ?? null, + provider_url: $this->productPageFromASIN($product['asin']), + notes: $this->feauturesBulletsToNotes($product['featureBullets'] ?? []), + vendor_infos: [$this->priceToPurchaseInfo($product['price'], $product['asin'])] + ); + } + + public function getCapabilities(): array + { + return [ + ProviderCapabilities::BASIC, + ProviderCapabilities::PICTURE, + ProviderCapabilities::PRICE, + ]; + } +} diff --git a/src/Services/InfoProviderSystem/Providers/ConradProvider.php b/src/Services/InfoProviderSystem/Providers/ConradProvider.php index 3086b7d8..39de1e23 100644 --- a/src/Services/InfoProviderSystem/Providers/ConradProvider.php +++ b/src/Services/InfoProviderSystem/Providers/ConradProvider.php @@ -201,7 +201,7 @@ readonly class ConradProvider implements InfoProviderInterface, URLHandlerInfoPr public function productMediaToDatasheets(array $productMedia): array { $files = []; - foreach ($productMedia['manuals'] as $manual) { + foreach ($productMedia['manuals'] ?? [] as $manual) { //Filter out unwanted languages if (!empty($this->settings->attachmentLanguageFilter) && !in_array($manual['language'], $this->settings->attachmentLanguageFilter, true)) { continue; diff --git a/src/Services/LabelSystem/BarcodeScanner/AmazonBarcodeScanResult.php b/src/Services/LabelSystem/BarcodeScanner/AmazonBarcodeScanResult.php new file mode 100644 index 00000000..fb756043 --- /dev/null +++ b/src/Services/LabelSystem/BarcodeScanner/AmazonBarcodeScanResult.php @@ -0,0 +1,46 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\LabelSystem\BarcodeScanner; + +final readonly class AmazonBarcodeScanResult implements BarcodeScanResultInterface +{ + public function __construct(public string $asin) { + if (!self::isAmazonBarcode($asin)) { + throw new \InvalidArgumentException("The provided input '$asin' is not a valid Amazon barcode (ASIN)"); + } + } + + public static function isAmazonBarcode(string $input): bool + { + //Amazon barcodes are 10 alphanumeric characters + return preg_match('/^[A-Z0-9]{10}$/i', $input) === 1; + } + + public function getDecodedForInfoMode(): array + { + return [ + 'ASIN' => $this->asin, + ]; + } +} diff --git a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanHelper.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanHelper.php index b2363ec8..0bee33a1 100644 --- a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanHelper.php +++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanHelper.php @@ -101,6 +101,10 @@ final class BarcodeScanHelper return $this->parseLCSCBarcode($input); } + if ($type === BarcodeSourceType::AMAZON) { + return new AmazonBarcodeScanResult($input); + } + //Null means auto and we try the different formats $result = $this->parseInternalBarcode($input); @@ -135,6 +139,11 @@ final class BarcodeScanHelper return $this->parseLCSCBarcode($input); } + //Try amazon barcode + if (AmazonBarcodeScanResult::isAmazonBarcode($input)) { + return new AmazonBarcodeScanResult($input); + } + throw new InvalidArgumentException('Unknown barcode'); } diff --git a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandler.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandler.php index 372e976e..e24c7077 100644 --- a/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandler.php +++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeScanResultHandler.php @@ -144,7 +144,12 @@ final readonly class BarcodeScanResultHandler return $this->resolvePartFromLCSC($barcodeScan); } - throw new \InvalidArgumentException("Barcode does not support resolving to a local entity: ".get_class($barcodeScan)); + if ($barcodeScan instanceof AmazonBarcodeScanResult) { + return $this->em->getRepository(Part::class)->getPartByProviderInfo($barcodeScan->asin) + ?? $this->em->getRepository(Part::class)->getPartBySPN($barcodeScan->asin); + } + + return null; } /** @@ -258,6 +263,13 @@ final readonly class BarcodeScanResultHandler return $this->getCreationInfoForEIGP114($scanResult); } + if ($scanResult instanceof AmazonBarcodeScanResult) { + return [ + 'providerKey' => 'canopy', + 'providerId' => $scanResult->asin, + ]; + } + return null; } diff --git a/src/Services/LabelSystem/BarcodeScanner/BarcodeSourceType.php b/src/Services/LabelSystem/BarcodeScanner/BarcodeSourceType.php index 13ab4bf3..fb6eaa77 100644 --- a/src/Services/LabelSystem/BarcodeScanner/BarcodeSourceType.php +++ b/src/Services/LabelSystem/BarcodeScanner/BarcodeSourceType.php @@ -36,12 +36,12 @@ enum BarcodeSourceType: string /** * This barcode is a user defined barcode defined on a part lot */ - case USER_DEFINED = 'user_defined'; + case USER_DEFINED = 'user'; /** * EIGP114 formatted barcodes like used by digikey, mouser, etc. */ - case EIGP114 = 'eigp114'; + case EIGP114 = 'eigp'; /** * GTIN /EAN barcodes, which are used on most products in the world. These are checked with the GTIN field of a part. @@ -50,4 +50,6 @@ enum BarcodeSourceType: string /** For LCSC.com formatted QR codes */ case LCSC = 'lcsc'; + + case AMAZON = 'amazon'; } diff --git a/src/Settings/InfoProviderSystem/CanopySettings.php b/src/Settings/InfoProviderSystem/CanopySettings.php new file mode 100644 index 00000000..88e1fcb7 --- /dev/null +++ b/src/Settings/InfoProviderSystem/CanopySettings.php @@ -0,0 +1,96 @@ +. + */ + +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\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\Translation\TranslatableMessage as TM; +use Symfony\Component\Validator\Constraints as Assert; + +#[Settings(label: new TM("settings.ips.canopy"))] +#[SettingsIcon("fa-plug")] +class CanopySettings +{ + public const ALLOWED_DOMAINS = [ + "amazon.de" => "DE", + "amazon.com" => "US", + "amazon.co.uk" => "UK", + "amazon.fr" => "FR", + "amazon.it" => "IT", + "amazon.es" => "ES", + "amazon.ca" => "CA", + "amazon.com.au" => "AU", + "amazon.com.br" => "BR", + "amazon.com.mx" => "MX", + "amazon.in" => "IN", + "amazon.co.jp" => "JP", + "amazon.nl" => "NL", + "amazon.pl" => "PL", + "amazon.sa" => "SA", + "amazon.sg" => "SG", + "amazon.se" => "SE", + "amazon.com.tr" => "TR", + "amazon.ae" => "AE", + "amazon.com.be" => "BE", + "amazon.com.cn" => "CN", + ]; + + use SettingsTrait; + + #[SettingsParameter(label: new TM("settings.ips.mouser.apiKey"), + formType: APIKeyType::class, + formOptions: ["help_html" => true], envVar: "PROVIDER_CANOPY_API_KEY", envVarMode: EnvVarMode::OVERWRITE)] + public ?string $apiKey = null; + + /** + * @var string The domain used internally for the API requests. This is not necessarily the same as the domain shown to the user, which is determined by the keys of the ALLOWED_DOMAINS constant + */ + #[SettingsParameter(label: new TM("settings.ips.tme.country"), formType: ChoiceType::class, formOptions: ["choices" => self::ALLOWED_DOMAINS])] + public string $domain = "DE"; + + /** + * @var bool If true, the provider will always retrieve details for a part, resulting in an additional API request + */ + #[SettingsParameter(label: new TM("settings.ips.canopy.alwaysGetDetails"), description: new TM("settings.ips.canopy.alwaysGetDetails.help"))] + public bool $alwaysGetDetails = false; + + /** + * Returns the real domain (e.g. amazon.de) based on the selected domain (e.g. DE) + * @return string + */ + public function getRealDomain(): string + { + $domain = array_search($this->domain, self::ALLOWED_DOMAINS); + if ($domain === false) { + throw new \InvalidArgumentException("Invalid domain selected"); + } + return $domain; + } +} diff --git a/src/Settings/InfoProviderSystem/InfoProviderSettings.php b/src/Settings/InfoProviderSystem/InfoProviderSettings.php index 3e78233f..248fcedc 100644 --- a/src/Settings/InfoProviderSystem/InfoProviderSettings.php +++ b/src/Settings/InfoProviderSystem/InfoProviderSettings.php @@ -72,4 +72,7 @@ class InfoProviderSettings #[EmbeddedSettings] public ?ConradSettings $conrad = null; + + #[EmbeddedSettings] + public ?CanopySettings $canopy = null; } diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index b786957a..356aa89a 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -12569,5 +12569,29 @@ Buerklin-API Authentication server: Create [part] from label scan + + + scan_dialog.mode.amazon + Amazon barcode + + + + + settings.ips.canopy + Canopy + + + + + settings.ips.canopy.alwaysGetDetails + Always fetch details + + + + + settings.ips.canopy.alwaysGetDetails.help + When selected, more details will be fetched from canopy when creating a part. This causes an additional API request, but gives product bullet points and category info. + +