Cache for normalized supplier names */ private array $supplierCache = []; public function __construct( private readonly PartInfoRetriever $infoRetriever, private readonly ExistingPartFinder $existingPartFinder, private readonly ProviderRegistry $providerRegistry, private readonly EntityManagerInterface $entityManager, private readonly LoggerInterface $logger ) {} /** * Perform bulk search across multiple parts and providers. * * @param Part[] $parts Array of parts to search for * @param BulkSearchFieldMappingDTO[] $fieldMappings Array of field mappings defining search strategy * @param bool $prefetchDetails Whether to prefetch detailed information for results * @return BulkSearchResponseDTO Structured response containing all search results * @throws \InvalidArgumentException If no valid parts provided * @throws \RuntimeException If no search results found for any parts */ public function performBulkSearch(array $parts, array $fieldMappings, bool $prefetchDetails = false): BulkSearchResponseDTO { if (empty($parts)) { throw new \InvalidArgumentException('No valid parts found for bulk import'); } $partResults = []; $hasAnyResults = false; // Group providers by batch capability $batchProviders = []; $regularProviders = []; foreach ($fieldMappings as $mapping) { foreach ($mapping->providers as $providerKey) { if (!is_string($providerKey)) { $this->logger->error('Invalid provider key type', [ 'providerKey' => $providerKey, 'type' => gettype($providerKey) ]); continue; } $provider = $this->providerRegistry->getProviderByKey($providerKey); if ($provider instanceof BatchInfoProviderInterface) { $batchProviders[$providerKey] = $provider; } else { $regularProviders[$providerKey] = $provider; } } } // Process batch providers first (more efficient) $batchResults = $this->processBatchProviders($parts, $fieldMappings, $batchProviders); // Process regular providers $regularResults = $this->processRegularProviders($parts, $fieldMappings, $regularProviders, $batchResults); // Combine and format results for each part foreach ($parts as $part) { $searchResults = []; // Get results from batch and regular processing $allResults = array_merge( $batchResults[$part->getId()] ?? [], $regularResults[$part->getId()] ?? [] ); if (!empty($allResults)) { $hasAnyResults = true; $searchResults = $this->formatSearchResults($allResults); } $partResults[] = new BulkSearchPartResultsDTO( part: $part, searchResults: $searchResults, errors: [] ); } if (!$hasAnyResults) { throw new \RuntimeException('No search results found for any of the selected parts'); } $response = new BulkSearchResponseDTO($partResults); // Prefetch details if requested if ($prefetchDetails) { $this->prefetchDetailsForResults($response); } return $response; } /** * Process parts using batch-capable info providers. * * @param Part[] $parts Array of parts to search for * @param BulkSearchFieldMappingDTO[] $fieldMappings Array of field mapping configurations * @param array $batchProviders Batch providers indexed by key * @return array Results indexed by part ID */ private function processBatchProviders(array $parts, array $fieldMappings, array $batchProviders): array { $batchResults = []; foreach ($batchProviders as $providerKey => $provider) { $keywords = $this->collectKeywordsForProvider($parts, $fieldMappings, $providerKey); if (empty($keywords)) { continue; } try { $providerResults = $provider->searchByKeywordsBatch($keywords); // Map results back to parts foreach ($parts as $part) { foreach ($fieldMappings as $mapping) { if (!in_array($providerKey, $mapping->providers, true)) { continue; } $keyword = $this->getKeywordFromField($part, $mapping->field); if ($keyword && isset($providerResults[$keyword])) { foreach ($providerResults[$keyword] as $dto) { $batchResults[$part->getId()][] = new BulkSearchPartResultDTO( searchResult: $dto, sourceField: $mapping->field, sourceKeyword: $keyword, localPart: $this->existingPartFinder->findFirstExisting($dto), priority: $mapping->priority ); } } } } } catch (\Exception $e) { $this->logger->error('Batch search failed for provider ' . $providerKey, [ 'error' => $e->getMessage(), 'provider' => $providerKey ]); } } return $batchResults; } /** * Process parts using regular (non-batch) info providers. * * @param Part[] $parts Array of parts to search for * @param BulkSearchFieldMappingDTO[] $fieldMappings Array of field mapping configurations * @param array $regularProviders Regular providers indexed by key * @param array $excludeResults Results to exclude (from batch processing) * @return array Results indexed by part ID */ private function processRegularProviders(array $parts, array $fieldMappings, array $regularProviders, array $excludeResults): array { $regularResults = []; foreach ($parts as $part) { $regularResults[$part->getId()] = []; // Skip if we already have batch results for this part if (!empty($excludeResults[$part->getId()] ?? [])) { continue; } foreach ($fieldMappings as $mapping) { $providers = array_intersect($mapping->providers, array_keys($regularProviders)); if (empty($providers)) { continue; } $keyword = $this->getKeywordFromField($part, $mapping->field); if (!$keyword) { continue; } try { $dtos = $this->infoRetriever->searchByKeyword($keyword, $providers); foreach ($dtos as $dto) { $regularResults[$part->getId()][] = new BulkSearchPartResultDTO( searchResult: $dto, sourceField: $mapping->field, sourceKeyword: $keyword, localPart: $this->existingPartFinder->findFirstExisting($dto), priority: $mapping->priority ); } } catch (ClientException $e) { $this->logger->error('Regular search failed', [ 'part_id' => $part->getId(), 'field' => $mapping->field, 'error' => $e->getMessage() ]); } } } return $regularResults; } /** * Collect unique keywords for a specific provider from all parts and field mappings. * * @param Part[] $parts Array of parts to collect keywords from * @param BulkSearchFieldMappingDTO[] $fieldMappings Array of field mapping configurations * @param string $providerKey The provider key to collect keywords for * @return string[] Array of unique keywords */ private function collectKeywordsForProvider(array $parts, array $fieldMappings, string $providerKey): array { $keywords = []; foreach ($parts as $part) { foreach ($fieldMappings as $mapping) { if (!in_array($providerKey, $mapping->providers, true)) { continue; } $keyword = $this->getKeywordFromField($part, $mapping->field); if ($keyword && !in_array($keyword, $keywords, true)) { $keywords[] = $keyword; } } } return $keywords; } 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 { if (!str_ends_with($field, '_spn')) { return null; } $supplierKey = substr($field, 0, -4); $supplier = $this->getSupplierByNormalizedName($supplierKey); if (!$supplier) { return null; } $orderDetail = $part->getOrderdetails()->filter( fn($od) => $od->getSupplier()?->getId() === $supplier->getId() )->first(); return $orderDetail !== false ? $orderDetail->getSupplierpartnr() : null; } /** * Get supplier by normalized name with caching to prevent N+1 queries. * * @param string $normalizedKey The normalized supplier key to search for * @return Supplier|null The matching supplier or null if not found */ private function getSupplierByNormalizedName(string $normalizedKey): ?Supplier { // Check cache first if (isset($this->supplierCache[$normalizedKey])) { return $this->supplierCache[$normalizedKey]; } // Use efficient database query with PHP normalization // Since DQL doesn't support REPLACE, we'll load all suppliers once and cache the normalization if (empty($this->supplierCache)) { $this->loadSuppliersIntoCache(); } $supplier = $this->supplierCache[$normalizedKey] ?? null; // Cache the result (including null results to prevent repeated queries) $this->supplierCache[$normalizedKey] = $supplier; return $supplier; } /** * Load all suppliers into cache with normalized names to avoid N+1 queries. */ private function loadSuppliersIntoCache(): void { /** @var Supplier[] $suppliers */ $suppliers = $this->entityManager->getRepository(Supplier::class)->findAll(); foreach ($suppliers as $supplier) { $normalizedName = strtolower(str_replace([' ', '-', '_'], '_', $supplier->getName())); $this->supplierCache[$normalizedName] = $supplier; } } /** * Format and deduplicate search results. * * @param BulkSearchPartResultDTO[] $bulkResults Array of bulk search results * @return BulkSearchPartResultDTO[] Array of formatted search results with metadata */ private function formatSearchResults(array $bulkResults): array { // Sort by priority and remove duplicates usort($bulkResults, fn($a, $b) => $a->priority <=> $b->priority); $uniqueResults = []; $seenKeys = []; foreach ($bulkResults as $result) { $key = "{$result->searchResult->provider_key}|{$result->searchResult->provider_id}"; if (!in_array($key, $seenKeys, true)) { $seenKeys[] = $key; $uniqueResults[] = $result; } } return $uniqueResults; } /** * Prefetch detailed information for search results. * * @param BulkSearchResponseDTO $searchResults Search results (supports both new DTO and legacy array format) */ public function prefetchDetailsForResults(BulkSearchResponseDTO $searchResults): void { $prefetchCount = 0; // Handle both new DTO format and legacy array format for backwards compatibility foreach ($searchResults->partResults as $partResult) { foreach ($partResult->searchResults as $result) { $dto = $result->searchResult; try { $this->infoRetriever->getDetails($dto->provider_key, $dto->provider_id); $prefetchCount++; } catch (\Exception $e) { $this->logger->warning('Failed to prefetch details for provider part', [ 'provider_key' => $dto->provider_key, 'provider_id' => $dto->provider_id, 'error' => $e->getMessage() ]); } } } $this->logger->info("Prefetched details for {$prefetchCount} search results"); } }