diff --git a/src/Controller/InfoProviderController.php b/src/Controller/InfoProviderController.php index a755d093..dbcd6a2a 100644 --- a/src/Controller/InfoProviderController.php +++ b/src/Controller/InfoProviderController.php @@ -23,29 +23,42 @@ declare(strict_types=1); namespace App\Controller; +use App\Exceptions\AttachmentDownloadException; use App\Form\InfoProviderSystem\PartSearchType; +use App\Form\Part\PartBaseType; +use App\Services\Attachments\AttachmentSubmitHandler; use App\Services\InfoProviderSystem\PartInfoRetriever; use App\Services\InfoProviderSystem\ProviderRegistry; +use App\Services\LogSystem\EventCommentHelper; +use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; +use Symfony\Contracts\Translation\TranslatorInterface; #[Route('/tools/info_providers')] class InfoProviderController extends AbstractController { + public function __construct(private readonly ProviderRegistry $providerRegistry, + private readonly PartInfoRetriever $infoRetriever, private readonly EventCommentHelper $commentHelper) + { + + } + #[Route('/providers', name: 'info_providers_list')] - public function listProviders(ProviderRegistry $providerRegistry): Response + public function listProviders(): Response { return $this->render('info_providers/providers_list/providers_list.html.twig', [ - 'active_providers' => $providerRegistry->getActiveProviders(), - 'disabled_providers' => $providerRegistry->getDisabledProviders(), + 'active_providers' => $this->providerRegistry->getActiveProviders(), + 'disabled_providers' => $this->providerRegistry->getDisabledProviders(), ]); } #[Route('/search', name: 'info_providers_search')] - public function search(Request $request, PartInfoRetriever $infoRetriever): Response + public function search(Request $request): Response { $form = $this->createForm(PartSearchType::class); $form->handleRequest($request); @@ -56,7 +69,7 @@ class InfoProviderController extends AbstractController $keyword = $form->get('keyword')->getData(); $providers = $form->get('providers')->getData(); - $results = $infoRetriever->searchByKeyword(keyword: $keyword, providers: $providers); + $results = $this->infoRetriever->searchByKeyword(keyword: $keyword, providers: $providers); } return $this->render('info_providers/search/part_search.html.twig', [ @@ -64,4 +77,70 @@ class InfoProviderController extends AbstractController 'results' => $results, ]); } + + #[Route('/part/{providerKey}/{providerId}/create', name: 'info_providers_create_part')] + public function createPart(Request $request, EntityManagerInterface $em, TranslatorInterface $translator, + AttachmentSubmitHandler $attachmentSubmitHandler, string $providerKey, string $providerId): Response + { + + $new_part = $this->infoRetriever->createPart($providerKey, $providerId); + + $form = $this->createForm(PartBaseType::class, $new_part); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + //Upload passed files + $attachments = $form['attachments']; + foreach ($attachments as $attachment) { + /** @var FormInterface $attachment */ + $options = [ + 'secure_attachment' => $attachment['secureFile']->getData(), + 'download_url' => $attachment['downloadURL']->getData(), + ]; + + try { + $attachmentSubmitHandler->handleFormSubmit($attachment->getData(), $attachment['file']->getData(), $options); + } catch (AttachmentDownloadException $attachmentDownloadException) { + $this->addFlash( + 'error', + $translator->trans('attachment.download_failed').' '.$attachmentDownloadException->getMessage() + ); + } + } + + $this->commentHelper->setMessage($form['log_comment']->getData()); + + $em->persist($new_part); + $em->flush(); + $this->addFlash('success', 'part.created_flash'); + + //If a redirect URL was given, redirect there + if ($request->query->get('_redirect')) { + return $this->redirect($request->query->get('_redirect')); + } + + //Redirect to clone page if user wished that... + //@phpstan-ignore-next-line + if ('save_and_clone' === $form->getClickedButton()->getName()) { + return $this->redirectToRoute('part_clone', ['id' => $new_part->getID()]); + } + //@phpstan-ignore-next-line + if ('save_and_new' === $form->getClickedButton()->getName()) { + return $this->redirectToRoute('part_new'); + } + + return $this->redirectToRoute('part_edit', ['id' => $new_part->getID()]); + } + + if ($form->isSubmitted() && !$form->isValid()) { + $this->addFlash('error', 'part.created_flash.invalid'); + } + + return $this->render('parts/edit/new_part.html.twig', + [ + 'part' => $new_part, + 'form' => $form, + ]); + } } \ No newline at end of file diff --git a/src/Services/InfoProviderSystem/DTOs/FileDTO.php b/src/Services/InfoProviderSystem/DTOs/FileDTO.php new file mode 100644 index 00000000..7c005b9b --- /dev/null +++ b/src/Services/InfoProviderSystem/DTOs/FileDTO.php @@ -0,0 +1,34 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\InfoProviderSystem\DTOs; + +class FileDTO +{ + public function __construct( + /** The URL where to get this file */ + public readonly string $url, + /** Optionally the name of this file */ + public readonly ?string $name = null, + ) {} +} \ No newline at end of file diff --git a/src/Services/InfoProviderSystem/DTOs/ParameterDTO.php b/src/Services/InfoProviderSystem/DTOs/ParameterDTO.php new file mode 100644 index 00000000..9613a859 --- /dev/null +++ b/src/Services/InfoProviderSystem/DTOs/ParameterDTO.php @@ -0,0 +1,49 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\InfoProviderSystem\DTOs; + +class ParameterDTO +{ + public function __construct( + public readonly string $name, + public readonly ?string $value_text = null, + public readonly ?float $value_typ = null, + public readonly ?float $value_min = null, + public readonly ?float $value_max = null, + public readonly ?string $unit = null, + public readonly ?string $symbol = null, + public readonly ?string $group = null, + ) { + + } + + 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_text: $value, unit: $unit, symbol: $symbol, group: $group); + } +} \ No newline at end of file diff --git a/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php b/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php new file mode 100644 index 00000000..13bb93d8 --- /dev/null +++ b/src/Services/InfoProviderSystem/DTOs/PartDetailDTO.php @@ -0,0 +1,61 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\InfoProviderSystem\DTOs; + +use App\Entity\Parts\ManufacturingStatus; +use Hoa\Zformat\Parameter; + +class PartDetailDTO extends SearchResultDTO +{ + public function __construct( + string $provider_key, + string $provider_id, + string $name, + string $description, + ?string $manufacturer = null, + ?string $mpn = null, + ?string $preview_image_url = null, + ?ManufacturingStatus $manufacturing_status = null, + ?string $provider_url = null, + ?string $footprint = null, + public readonly ?string $notes = null, + /** @var FileDTO[]|null */ + public readonly ?array $datasheets = null, + /** @var ParameterDTO[]|null */ + public readonly ?array $parameters = null, + ) { + parent::__construct( + provider_key: $provider_key, + provider_id: $provider_id, + name: $name, + description: $description, + manufacturer: $manufacturer, + mpn: $mpn, + preview_image_url: $preview_image_url, + manufacturing_status: $manufacturing_status, + provider_url: $provider_url, + footprint: $footprint, + ); + } +} \ No newline at end of file diff --git a/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php b/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php index 5c4cb97f..3687dca9 100644 --- a/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php +++ b/src/Services/InfoProviderSystem/DTOs/SearchResultDTO.php @@ -46,6 +46,8 @@ class SearchResultDTO public readonly ?ManufacturingStatus $manufacturing_status = null, /** @var string|null A link to the part on the providers page */ public readonly ?string $provider_url = null, + /** @var string|null A footprint representation of the providers page */ + public readonly ?string $footprint = null, ) { } diff --git a/src/Services/InfoProviderSystem/DTOtoEntityConverter.php b/src/Services/InfoProviderSystem/DTOtoEntityConverter.php new file mode 100644 index 00000000..749467e5 --- /dev/null +++ b/src/Services/InfoProviderSystem/DTOtoEntityConverter.php @@ -0,0 +1,98 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Services\InfoProviderSystem; + +use App\Entity\Attachments\AttachmentType; +use App\Entity\Attachments\PartAttachment; +use App\Entity\Parameters\AbstractParameter; +use App\Entity\Parameters\PartParameter; +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; + +/** + * This class converts DTOs to entities which can be persisted in the DB + */ +class DTOtoEntityConverter +{ + + public function convertParameter(ParameterDTO $dto, PartParameter $entity = new PartParameter()): PartParameter + { + $entity->setName($dto->name); + $entity->setValueText($dto->value_text ?? ''); + $entity->setValueTypical($dto->value_typ); + $entity->setValueMin($dto->value_min); + $entity->setValueMax($dto->value_max); + $entity->setUnit($dto->unit ?? ''); + $entity->setSymbol($dto->symbol ?? ''); + $entity->setGroup($dto->group ?? ''); + + return $entity; + } + + public function convertFile(FileDTO $dto, PartAttachment $entity = new PartAttachment()): PartAttachment + { + $entity->setURL($dto->url); + + //If no name is given, try to extract the name from the URL + if (empty($dto->name)) { + $entity->setName(basename($dto->url)); + } else { + $entity->setName($dto->name); + } + + return $entity; + } + + /** + * Converts a PartDetailDTO to a Part entity + * @param PartDetailDTO $dto + * @param Part $entity The part entity to fill + * @return Part + */ + public function convertPart(PartDetailDTO $dto, Part $entity = new Part()): Part + { + $entity->setName($dto->name); + $entity->setDescription($dto->description ?? ''); + $entity->setComment($dto->notes ?? ''); + + $entity->setManufacturerProductNumber($dto->mpn ?? ''); + $entity->setManufacturingStatus($dto->manufacturing_status ?? ManufacturingStatus::NOT_SET); + + //Add parameters + foreach ($dto->parameters ?? [] as $parameter) { + $entity->addParameter($this->convertParameter($parameter)); + } + + //Add datasheets + foreach ($dto->datasheets ?? [] as $datasheet) { + $entity->addAttachment($this->convertFile($datasheet)); + } + + return $entity; + } + +} \ No newline at end of file diff --git a/src/Services/InfoProviderSystem/PartInfoRetriever.php b/src/Services/InfoProviderSystem/PartInfoRetriever.php index 5e7ecc31..a010fbcd 100644 --- a/src/Services/InfoProviderSystem/PartInfoRetriever.php +++ b/src/Services/InfoProviderSystem/PartInfoRetriever.php @@ -23,12 +23,14 @@ declare(strict_types=1); namespace App\Services\InfoProviderSystem; +use App\Entity\Parts\Part; +use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; use App\Services\InfoProviderSystem\Providers\InfoProviderInterface; class PartInfoRetriever { - public function __construct(private readonly ProviderRegistry $provider_registry) + public function __construct(private readonly ProviderRegistry $provider_registry, private readonly DTOtoEntityConverter $dto_to_entity_converter) { } @@ -57,4 +59,27 @@ class PartInfoRetriever return $results; } + + /** + * Retrieves the details for a part from the given provider with the given (provider) part id + * @param string $provider_key + * @param string $part_id + * @return + */ + public function getDetails(string $provider_key, string $part_id): PartDetailDTO + { + return $this->provider_registry->getProviderByKey($provider_key)->getDetails($part_id); + } + + public function getDetailsForSearchResult(SearchResultDTO $search_result): PartDetailDTO + { + return $this->getDetails($search_result->provider_key, $search_result->provider_id); + } + + public function createPart(string $provider_key, string $part_id): Part + { + $details = $this->getDetails($provider_key, $part_id); + + return $this->dto_to_entity_converter->convertPart($details); + } } \ No newline at end of file diff --git a/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php b/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php index 6c85e36b..26cea80c 100644 --- a/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php +++ b/src/Services/InfoProviderSystem/Providers/DigikeyProvider.php @@ -24,6 +24,7 @@ declare(strict_types=1); namespace App\Services\InfoProviderSystem\Providers; use App\Entity\Parts\ManufacturingStatus; +use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -118,4 +119,9 @@ class DigikeyProvider implements InfoProviderInterface default => ManufacturingStatus::NOT_SET, }; } + + public function getDetails(string $id): PartDetailDTO + { + // TODO: Implement getDetails() method. + } } \ No newline at end of file diff --git a/src/Services/InfoProviderSystem/Providers/Element14Provider.php b/src/Services/InfoProviderSystem/Providers/Element14Provider.php index 0250d45e..854b4a1b 100644 --- a/src/Services/InfoProviderSystem/Providers/Element14Provider.php +++ b/src/Services/InfoProviderSystem/Providers/Element14Provider.php @@ -25,6 +25,9 @@ namespace App\Services\InfoProviderSystem\Providers; use App\Entity\Parts\ManufacturingStatus; use App\Form\InfoProviderSystem\ProviderSelectType; +use App\Services\InfoProviderSystem\DTOs\FileDTO; +use App\Services\InfoProviderSystem\DTOs\ParameterDTO; +use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -36,6 +39,9 @@ class Element14Provider implements InfoProviderInterface private const API_VERSION_NUMBER = '1.2'; private const NUMBER_OF_RESULTS = 20; + private const COMPLIANCE_ATTRIBUTES = ['euEccn', 'hazardous', 'MSL', 'productTraceability', 'rohsCompliant', + 'rohsPhthalatesCompliant', 'SVHC', 'tariffCode', 'usEccn', 'hazardCode']; + public function __construct(private readonly HttpClientInterface $element14Client, private readonly string $api_key) { @@ -60,7 +66,11 @@ class Element14Provider implements InfoProviderInterface return !empty($this->api_key); } - private function queryByTerm(string $term, string $responseGroup = 'large'): array + /** + * @param string $term + * @return PartDetailDTO[] + */ + private function queryByTerm(string $term): array { $response = $this->element14Client->request('GET', self::ENDPOINT_URL, [ 'query' => [ @@ -68,17 +78,60 @@ class Element14Provider implements InfoProviderInterface 'storeInfo.id' => self::FARNELL_STORE_ID, 'resultsSettings.offset' => 0, 'resultsSettings.numberOfResults' => self::NUMBER_OF_RESULTS, - 'resultsSettings.responseGroup' => $responseGroup, + 'resultsSettings.responseGroup' => 'large', 'callInfo.apiKey' => $this->api_key, 'callInfo.responseDataFormat' => 'json', 'callInfo.version' => self::API_VERSION_NUMBER, ], ]); - return $response->toArray(); + $arr = $response->toArray(); + if (isset($arr['keywordSearchReturn'])) { + $products = $arr['keywordSearchReturn']['products'] ?? []; + } elseif (isset($arr['premierFarnellPartNumberReturn'])) { + $products = $arr['premierFarnellPartNumberReturn']['products'] ?? []; + } else { + throw new \RuntimeException('Unknown response format'); + } + $result = []; + + foreach ($products as $product) { + $result[] = new PartDetailDTO( + provider_key: $this->getProviderKey(), provider_id: $product['sku'], + name: $product['translatedManufacturerPartNumber'], + description: $this->displayNameToDescription($product['displayName'], $product['translatedManufacturerPartNumber']), + manufacturer: $product['vendorName'] ?? $product['brandName'] ?? null, + mpn: $product['translatedManufacturerPartNumber'], + preview_image_url: $this->toImageUrl($product['image'] ?? null), + manufacturing_status: $this->releaseStatusCodeToManufacturingStatus($product['releaseStatusCode'] ?? null), + provider_url: 'https://' . self::FARNELL_STORE_ID . '/' . $product['sku'], + datasheets: $this->parseDataSheets($product['datasheets'] ?? null), + parameters: $this->attributesToParameters($product['attributes'] ?? null), + ); + } + + return $result; } + /** + * @param mixed[]|null $datasheets + * @return FileDTO[]|null Array of FileDTOs + */ + private function parseDataSheets(?array $datasheets): ?array + { + if ($datasheets === null || count($datasheets) === 0) { + return null; + } + + $result = []; + foreach ($datasheets as $datasheet) { + $result[] = new FileDTO(url: $datasheet['url'], name: $datasheet['description']); + } + + return $result; + } + private function toImageUrl(?array $image): ?string { if ($image === null || count($image) === 0) { @@ -94,6 +147,33 @@ class Element14Provider implements InfoProviderInterface return 'https://' . self::FARNELL_STORE_ID . '/productimages/standard/' . $locale . $image['baseName']; } + /** + * @param array|null $attributes + * @return ParameterDTO[]|null + */ + private function attributesToParameters(?array $attributes): ?array + { + $result = []; + + foreach ($attributes as $attribute) { + $group = null; + + //Check if the attribute is a compliance attribute, they get assigned to the compliance group + if (in_array($attribute['attributeLabel'], self::COMPLIANCE_ATTRIBUTES, true)) { + $group = 'Compliance'; + } + + //tariffCode is a special case, we prepend a # to prevent conversion to float + if (in_array($attribute['attributeLabel'], ['tariffCode', 'hazardCode'])) { + $attribute['attributeValue'] = '#' . $attribute['attributeValue']; + } + + $result[] = ParameterDTO::parseValueField(name: $attribute['attributeLabel'], value: $attribute['attributeValue'], unit: $attribute['attributeUnit'] ?? null, group: $group); + } + + return $result; + } + private function displayNameToDescription(string $display_name, string $mpn): string { //Try to find the position of the '-' after the MPN @@ -123,25 +203,21 @@ class Element14Provider implements InfoProviderInterface public function searchByKeyword(string $keyword): array { - $response = $this->queryByTerm('any:' . $keyword); - $products = $response['keywordSearchReturn']['products'] ?? []; + return $this->queryByTerm('any:' . $keyword); + } - $result = []; - - foreach ($products as $product) { - $result[] = new SearchResultDTO( - provider_key: $this->getProviderKey(), provider_id: $product['sku'], - name: $product['translatedManufacturerPartNumber'], - description: $this->displayNameToDescription($product['displayName'], $product['translatedManufacturerPartNumber']), - manufacturer: $product['vendorName'] ?? $product['brandName'] ?? null, - mpn: $product['translatedManufacturerPartNumber'], - preview_image_url: $this->toImageUrl($product['image'] ?? null), - manufacturing_status: $this->releaseStatusCodeToManufacturingStatus($product['releaseStatusCode'] ?? null), - provider_url: 'https://' . self::FARNELL_STORE_ID . '/' . $product['sku'] - ); + public function getDetails(string $id): PartDetailDTO + { + $tmp = $this->queryByTerm('id:' . $id); + if (count($tmp) === 0) { + throw new \RuntimeException('No part found with ID ' . $id); } - return $result; + if (count($tmp) > 1) { + throw new \RuntimeException('Multiple parts found with ID ' . $id); + } + + return $tmp[0]; } public function getCapabilities(): array diff --git a/src/Services/InfoProviderSystem/Providers/InfoProviderInterface.php b/src/Services/InfoProviderSystem/Providers/InfoProviderInterface.php index 97d8087b..61f79274 100644 --- a/src/Services/InfoProviderSystem/Providers/InfoProviderInterface.php +++ b/src/Services/InfoProviderSystem/Providers/InfoProviderInterface.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace App\Services\InfoProviderSystem\Providers; +use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\InfoProviderSystem\DTOs\SearchResultDTO; interface InfoProviderInterface @@ -62,6 +63,13 @@ interface InfoProviderInterface */ public function searchByKeyword(string $keyword): array; + /** + * Returns detailed information about the part with the given id + * @param string $id + * @return PartDetailDTO + */ + public function getDetails(string $id): PartDetailDTO; + /** * A list of capabilities this provider supports (which kind of data it can provide). * Not every part have to contain all of these data, but the provider should be able to provide them in general. diff --git a/src/Services/InfoProviderSystem/Providers/TestProvider.php b/src/Services/InfoProviderSystem/Providers/TestProvider.php index 04018f32..b680513e 100644 --- a/src/Services/InfoProviderSystem/Providers/TestProvider.php +++ b/src/Services/InfoProviderSystem/Providers/TestProvider.php @@ -23,6 +23,8 @@ declare(strict_types=1); namespace App\Services\InfoProviderSystem\Providers; +use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; + class TestProvider implements InfoProviderInterface { @@ -58,4 +60,9 @@ class TestProvider implements InfoProviderInterface ProviderCapabilities::FOOTPRINT, ]; } + + public function getDetails(string $id): PartDetailDTO + { + // TODO: Implement getDetails() method. + } } \ No newline at end of file diff --git a/templates/info_providers/search/part_search.html.twig b/templates/info_providers/search/part_search.html.twig index 79d18c9a..91f5cc70 100644 --- a/templates/info_providers/search/part_search.html.twig +++ b/templates/info_providers/search/part_search.html.twig @@ -24,6 +24,7 @@