From 4c8940f9c31e08be7181b9764b85483f3c157371 Mon Sep 17 00:00:00 2001 From: barisgit Date: Sat, 2 Aug 2025 17:56:46 +0200 Subject: [PATCH] Simple batch processing --- .../BulkInfoProviderImportController.php | 210 +++++++++++ .../BulkProviderSearchType.php | 68 ++++ .../FieldToProviderMappingType.php | 58 +++ .../GlobalFieldMappingType.php | 60 ++++ .../PartProviderConfigurationType.php | 55 +++ .../Parts/PartsTableActionHandler.php | 10 + .../components/datatables.macro.html.twig | 5 +- .../bulk_import/step1.html.twig | 339 ++++++++++++++++++ translations/messages.en.xlf | 146 +++++++- 9 files changed, 949 insertions(+), 2 deletions(-) create mode 100644 src/Controller/BulkInfoProviderImportController.php create mode 100644 src/Form/InfoProviderSystem/BulkProviderSearchType.php create mode 100644 src/Form/InfoProviderSystem/FieldToProviderMappingType.php create mode 100644 src/Form/InfoProviderSystem/GlobalFieldMappingType.php create mode 100644 src/Form/InfoProviderSystem/PartProviderConfigurationType.php create mode 100644 templates/info_providers/bulk_import/step1.html.twig diff --git a/src/Controller/BulkInfoProviderImportController.php b/src/Controller/BulkInfoProviderImportController.php new file mode 100644 index 00000000..6893de93 --- /dev/null +++ b/src/Controller/BulkInfoProviderImportController.php @@ -0,0 +1,210 @@ +. + */ + +declare(strict_types=1); + +namespace App\Controller; + +use App\Entity\Parts\Part; +use App\Entity\Parts\Supplier; +use App\Form\InfoProviderSystem\GlobalFieldMappingType; +use App\Services\InfoProviderSystem\PartInfoRetriever; +use App\Services\InfoProviderSystem\ProviderRegistry; +use App\Services\InfoProviderSystem\ExistingPartFinder; +use Doctrine\ORM\EntityManagerInterface; +use Psr\Log\LoggerInterface; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpClient\Exception\ClientException; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Attribute\Route; + +use function Symfony\Component\Translation\t; + +#[Route('/tools/bulk-info-provider-import')] +class BulkInfoProviderImportController extends AbstractController +{ + public function __construct( + private readonly ProviderRegistry $providerRegistry, + private readonly PartInfoRetriever $infoRetriever, + private readonly ExistingPartFinder $existingPartFinder, + private readonly EntityManagerInterface $entityManager + ) { + } + + #[Route('/step1', name: 'bulk_info_provider_step1')] + public function step1(Request $request, LoggerInterface $exceptionLogger): Response + { + $this->denyAccessUnlessGranted('@info_providers.create_parts'); + + $ids = $request->query->get('ids'); + if (!$ids) { + $this->addFlash('error', 'No parts selected for bulk import'); + return $this->redirectToRoute('homepage'); + } + + // Get the selected parts + $partIds = explode(',', $ids); + $partRepository = $this->entityManager->getRepository(Part::class); + $parts = $partRepository->getElementsFromIDArray($partIds); + + if (empty($parts)) { + $this->addFlash('error', 'No valid parts found for bulk import'); + return $this->redirectToRoute('homepage'); + } + + // Generate field choices + $fieldChoices = [ + 'info_providers.bulk_search.field.mpn' => 'mpn', + 'info_providers.bulk_search.field.name' => 'name', + ]; + + // Add dynamic supplier fields + $suppliers = $this->entityManager->getRepository(Supplier::class)->findAll(); + foreach ($suppliers as $supplier) { + $supplierKey = strtolower(str_replace([' ', '-', '_'], '_', $supplier->getName())); + $fieldChoices["Supplier: " . $supplier->getName() . " (SPN)"] = $supplierKey . '_spn'; + } + + // Initialize form with useful default mappings + $initialData = [ + 'field_mappings' => [ + ['field' => 'mpn', 'providers' => []] + ] + ]; + + $form = $this->createForm(GlobalFieldMappingType::class, $initialData, [ + 'field_choices' => $fieldChoices + ]); + $form->handleRequest($request); + + $searchResults = null; + + if ($form->isSubmitted() && $form->isValid()) { + $fieldMappings = $form->getData()['field_mappings']; + $searchResults = []; + + foreach ($parts as $part) { + $partResult = [ + 'part' => $part, + 'search_results' => [], + 'errors' => [] + ]; + + // Collect all DTOs from all applicable field mappings + $allDtos = []; + + foreach ($fieldMappings as $mapping) { + $field = $mapping['field']; + $providers = $mapping['providers'] ?? []; + + if (empty($providers)) { + continue; + } + + $keyword = $this->getKeywordFromField($part, $field); + + if ($keyword) { + try { + $dtos = $this->infoRetriever->searchByKeyword( + keyword: $keyword, + providers: $providers + ); + + // Add field info to each DTO for tracking + foreach ($dtos as $dto) { + $dto->_source_field = $field; + $dto->_source_keyword = $keyword; + } + + $allDtos = array_merge($allDtos, $dtos); + } catch (ClientException $e) { + $partResult['errors'][] = "Error searching with {$field}: " . $e->getMessage(); + $exceptionLogger->error('Error during bulk info provider search for part ' . $part->getId() . " field {$field}: " . $e->getMessage(), ['exception' => $e]); + } + } + } + + // Remove duplicates based on provider_key + provider_id + $uniqueDtos = []; + $seenKeys = []; + foreach ($allDtos as $dto) { + $key = $dto->provider_key . '|' . $dto->provider_id; + if (!in_array($key, $seenKeys)) { + $seenKeys[] = $key; + $uniqueDtos[] = $dto; + } + } + + // Convert DTOs to result format + $partResult['search_results'] = array_map( + fn($dto) => ['dto' => $dto, 'localPart' => $this->existingPartFinder->findFirstExisting($dto)], + $uniqueDtos + ); + + $searchResults[] = $partResult; + } + } + + return $this->render('info_providers/bulk_import/step1.html.twig', [ + 'form' => $form, + 'parts' => $parts, + 'search_results' => $searchResults, + 'fieldChoices' => $fieldChoices + ]); + } + + private function getKeywordFromField(Part $part, string $field): ?string + { + return match ($field) { + 'mpn' => $part->getManufacturerProductNumber(), + 'name' => $part->getName(), + default => $this->getSupplierPartNumber($part, $field) + }; + } + + private function getSupplierPartNumber(Part $part, string $field): ?string + { + // Check if this is a supplier SPN field + if (!str_ends_with($field, '_spn')) { + return null; + } + + // Extract supplier key (remove _spn suffix) + $supplierKey = substr($field, 0, -4); + + // Get all suppliers to find matching one + $suppliers = $this->entityManager->getRepository(Supplier::class)->findAll(); + + foreach ($suppliers as $supplier) { + $normalizedName = strtolower(str_replace([' ', '-', '_'], '_', $supplier->getName())); + if ($normalizedName === $supplierKey) { + // Find order detail for this supplier + $orderDetail = $part->getOrderdetails()->filter( + fn($od) => $od->getSupplier()?->getId() === $supplier->getId() + )->first(); + + return $orderDetail ? $orderDetail->getSupplierpartnr() : null; + } + } + + return null; + } +} \ No newline at end of file diff --git a/src/Form/InfoProviderSystem/BulkProviderSearchType.php b/src/Form/InfoProviderSystem/BulkProviderSearchType.php new file mode 100644 index 00000000..5da8f53f --- /dev/null +++ b/src/Form/InfoProviderSystem/BulkProviderSearchType.php @@ -0,0 +1,68 @@ +. + */ + +declare(strict_types=1); + +namespace App\Form\InfoProviderSystem; + +use App\Entity\Parts\Part; +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\Form\Extension\Core\Type\CollectionType; +use Symfony\Component\Form\Extension\Core\Type\HiddenType; +use Symfony\Component\Form\Extension\Core\Type\SubmitType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class BulkProviderSearchType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $parts = $options['parts']; + + $builder->add('part_configurations', CollectionType::class, [ + 'entry_type' => PartProviderConfigurationType::class, + 'entry_options' => [ + 'label' => false, + ], + 'allow_add' => false, + 'allow_delete' => false, + 'label' => false, + ]); + + $builder->add('submit', SubmitType::class, [ + 'label' => 'info_providers.bulk_search.submit' + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'parts' => [], + ]); + $resolver->setRequired('parts'); + } + + private function getDefaultSearchField(Part $part): string + { + // Default to MPN if available, otherwise name + return $part->getManufacturerProductNumber() ? 'mpn' : 'name'; + } +} \ No newline at end of file diff --git a/src/Form/InfoProviderSystem/FieldToProviderMappingType.php b/src/Form/InfoProviderSystem/FieldToProviderMappingType.php new file mode 100644 index 00000000..20506fc8 --- /dev/null +++ b/src/Form/InfoProviderSystem/FieldToProviderMappingType.php @@ -0,0 +1,58 @@ +. + */ + +declare(strict_types=1); + +namespace App\Form\InfoProviderSystem; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class FieldToProviderMappingType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $fieldChoices = $options['field_choices'] ?? []; + + $builder->add('field', ChoiceType::class, [ + 'label' => 'info_providers.bulk_search.search_field', + 'choices' => $fieldChoices, + 'expanded' => false, + 'multiple' => false, + 'required' => false, + 'placeholder' => 'info_providers.bulk_search.field.select', + ]); + + $builder->add('providers', ProviderSelectType::class, [ + 'label' => 'info_providers.bulk_search.providers', + 'help' => 'info_providers.bulk_search.providers.help', + 'required' => false, + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'field_choices' => [], + ]); + } +} \ No newline at end of file diff --git a/src/Form/InfoProviderSystem/GlobalFieldMappingType.php b/src/Form/InfoProviderSystem/GlobalFieldMappingType.php new file mode 100644 index 00000000..ecc3dbc9 --- /dev/null +++ b/src/Form/InfoProviderSystem/GlobalFieldMappingType.php @@ -0,0 +1,60 @@ +. + */ + +declare(strict_types=1); + +namespace App\Form\InfoProviderSystem; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\CollectionType; +use Symfony\Component\Form\Extension\Core\Type\SubmitType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class GlobalFieldMappingType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $fieldChoices = $options['field_choices'] ?? []; + + $builder->add('field_mappings', CollectionType::class, [ + 'entry_type' => FieldToProviderMappingType::class, + 'entry_options' => [ + 'label' => false, + 'field_choices' => $fieldChoices, + ], + 'allow_add' => true, + 'allow_delete' => true, + 'prototype' => true, + 'label' => false, + ]); + + $builder->add('submit', SubmitType::class, [ + 'label' => 'info_providers.bulk_search.submit' + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'field_choices' => [], + ]); + } +} \ No newline at end of file diff --git a/src/Form/InfoProviderSystem/PartProviderConfigurationType.php b/src/Form/InfoProviderSystem/PartProviderConfigurationType.php new file mode 100644 index 00000000..cecf62a3 --- /dev/null +++ b/src/Form/InfoProviderSystem/PartProviderConfigurationType.php @@ -0,0 +1,55 @@ +. + */ + +declare(strict_types=1); + +namespace App\Form\InfoProviderSystem; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\Form\Extension\Core\Type\HiddenType; +use Symfony\Component\Form\FormBuilderInterface; + +class PartProviderConfigurationType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->add('part_id', HiddenType::class); + + $builder->add('search_field', ChoiceType::class, [ + 'label' => 'info_providers.bulk_search.search_field', + 'choices' => [ + 'info_providers.bulk_search.field.mpn' => 'mpn', + 'info_providers.bulk_search.field.name' => 'name', + 'info_providers.bulk_search.field.digikey_spn' => 'digikey_spn', + 'info_providers.bulk_search.field.mouser_spn' => 'mouser_spn', + 'info_providers.bulk_search.field.lcsc_spn' => 'lcsc_spn', + 'info_providers.bulk_search.field.farnell_spn' => 'farnell_spn', + ], + 'expanded' => false, + 'multiple' => false, + ]); + + $builder->add('providers', ProviderSelectType::class, [ + 'label' => 'info_providers.bulk_search.providers', + 'help' => 'info_providers.bulk_search.providers.help', + ]); + } +} \ No newline at end of file diff --git a/src/Services/Parts/PartsTableActionHandler.php b/src/Services/Parts/PartsTableActionHandler.php index bb8ab45f..945cff7b 100644 --- a/src/Services/Parts/PartsTableActionHandler.php +++ b/src/Services/Parts/PartsTableActionHandler.php @@ -117,6 +117,16 @@ implode(',', array_map(static fn (PartLot $lot) => $lot->getID(), $part->getPart ); } + if ($action === 'bulk_info_provider_import') { + $ids = implode(',', array_map(static fn (Part $part) => $part->getID(), $selected_parts)); + return new RedirectResponse( + $this->urlGenerator->generate('bulk_info_provider_step1', [ + 'ids' => $ids, + '_redirect' => $redirect_url + ]) + ); + } + //Iterate over the parts and apply the action to it: foreach ($selected_parts as $part) { diff --git a/templates/components/datatables.macro.html.twig b/templates/components/datatables.macro.html.twig index 5e1747e3..8d7e10f7 100644 --- a/templates/components/datatables.macro.html.twig +++ b/templates/components/datatables.macro.html.twig @@ -30,7 +30,7 @@
- {# #} + {% trans %}part_list.action.scrollable_hint{% endtrans %}