custom/plugins/MaxiaListingVariants6/src/Service/ListingVariantsLoader.php line 459

Open in your IDE?
  1. <?php
  2. namespace Maxia\MaxiaListingVariants6\Service;
  3. use Maxia\MaxiaListingVariants6\Core\Content\Product\Cms\ProductListingCmsElementResolver;
  4. use Maxia\MaxiaListingVariants6\Events\ListingVariantsBeforeLoadEvent;
  5. use Monolog\Logger;
  6. use Doctrine\DBAL\Connection;
  7. use Maxia\MaxiaListingVariants6\Config\BaseConfig;
  8. use Maxia\MaxiaListingVariants6\Config\ProductConfig;
  9. use Maxia\MaxiaListingVariants6\Config\PropertyGroupConfig;
  10. use Maxia\MaxiaListingVariants6\Events\ListingVariantsLoadedEvent;
  11. use Shopware\Core\Content\Product\Events\ProductListingCriteriaEvent;
  12. use Shopware\Core\Content\Product\Exception\ProductNotFoundException;
  13. use Shopware\Core\Content\Product\ProductEntity;
  14. use Shopware\Core\Content\Product\SalesChannel\SalesChannelProductEntity;
  15. use Shopware\Core\Content\Property\Aggregate\PropertyGroupOption\PropertyGroupOptionCollection;
  16. use Shopware\Core\Content\Property\Aggregate\PropertyGroupOption\PropertyGroupOptionEntity;
  17. use Shopware\Core\Content\Property\PropertyGroupCollection;
  18. use Shopware\Core\Content\Property\PropertyGroupEntity;
  19. use Shopware\Core\Framework\DataAbstractionLayer\EntityCollection;
  20. use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
  21. use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
  22. use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsAnyFilter;
  23. use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
  24. use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\NotFilter;
  25. use Shopware\Core\Framework\DataAbstractionLayer\Search\Sorting\FieldSorting;
  26. use Shopware\Core\Framework\Uuid\Uuid;
  27. use Shopware\Core\PlatformRequest;
  28. use Shopware\Core\System\SalesChannel\SalesChannelContext;
  29. use Shopware\Storefront\Page\Product\Configurator\ProductPageConfiguratorLoader;
  30. use Symfony\Component\DependencyInjection\ContainerInterface;
  31. use Symfony\Component\HttpFoundation\Request;
  32. use Symfony\Component\HttpFoundation\RequestStack;
  33. use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
  34. use Shopware\Core\System\SalesChannel\Entity\SalesChannelRepositoryInterface;
  35. class ListingVariantsLoader
  36. {
  37.     /** @var ContainerInterface */
  38.     protected $container;
  39.     /** @var Logger */
  40.     protected $logger;
  41.     /** @var RequestStack */
  42.     protected $requestStack;
  43.     /** @var EventDispatcherInterface */
  44.     protected $eventDispatcher;
  45.     /** @var Connection */
  46.     protected $dbConnection;
  47.     /** @var ProductPageConfiguratorLoader */
  48.     protected $configuratorLoader;
  49.     /** @var ProductCombinationFinder */
  50.     protected $combinationFinder;
  51.     /** @var SalesChannelRepositoryInterface */
  52.     protected $productRepository;
  53.     /** @var EntityRepository */
  54.     protected $mediaRepository;
  55.     /** @var EntityRepository */
  56.     protected $productMediaRepository;
  57.     /** @var VariantDisplayConfigLoader */
  58.     protected $variantDisplayConfigLoader;
  59.     /** @var ConfigService */
  60.     protected $configService;
  61.     public function __construct(
  62.         ContainerInterface $container,
  63.         Logger $logger,
  64.         RequestStack $requestStack,
  65.         EventDispatcherInterface $eventDispatcher,
  66.         Connection $dbConnection,
  67.         ProductPageConfiguratorLoader $configuratorLoader,
  68.         ProductCombinationFinder $combinationFinder,
  69.         SalesChannelRepositoryInterface $productRepository,
  70.         /*EntityRepository*/ $mediaRepository,
  71.         /*EntityRepository*/ $productMediaRepository,
  72.         VariantDisplayConfigLoader $variantDisplayConfigLoader,
  73.         ConfigService $configService
  74.     ) {
  75.         $this->container $container;
  76.         $this->logger $logger;
  77.         $this->requestStack $requestStack;
  78.         $this->eventDispatcher $eventDispatcher;
  79.         $this->dbConnection $dbConnection;
  80.         $this->configuratorLoader $configuratorLoader;
  81.         $this->combinationFinder $combinationFinder;
  82.         $this->productRepository $productRepository;
  83.         $this->productMediaRepository $productMediaRepository;
  84.         $this->mediaRepository $mediaRepository;
  85.         $this->variantDisplayConfigLoader $variantDisplayConfigLoader;
  86.         $this->configService $configService;
  87.     }
  88.     /**
  89.      * @param SalesChannelProductEntity[] $products
  90.      */
  91.     public function load(array $productsSalesChannelContext $context$expandOptions null)
  92.     {
  93.         $request $this->getMainRequest();
  94.         $pluginConfig $this->configService->getBaseConfig($context);
  95.         if ($expandOptions === null) {
  96.             $expandOptions $request $request->query->get('expandOptions'false) : false;
  97.         }
  98.         // add variants config extension to all products
  99.         foreach ($products as $product) {
  100.             $isVariantProduct = !empty($product->getParentId()) || $product->getChildCount();
  101.             if (!$product->hasExtension('maxiaListingVariants')) {
  102.                 $config $this->configService->getProductConfig($productnull$context);
  103.                 $product->addExtension('maxiaListingVariants'$config);
  104.             }
  105.             if (!$isVariantProduct) {
  106.                 // disable for main products
  107.                 $config $product->getExtension('maxiaListingVariants');
  108.                 $config->setDisplayMode('none');
  109.                 $config->setQuickBuyActive(
  110.                     $pluginConfig->isQuickBuyActive() && $pluginConfig->isActivateForMainProducts()
  111.                 );
  112.             }
  113.         }
  114.         // filter products where no additional data needs to be loaded
  115.         $products array_filter($products, function($product) {
  116.             return $product->hasExtension('maxiaListingVariants')
  117.                 && $product->getExtension('maxiaListingVariants')->getDisplayMode() !== 'none';
  118.         });
  119.         if (!$products) {
  120.             return;
  121.         }
  122.         // get display configs / preselection info
  123.         $displayConfigs $this->variantDisplayConfigLoader->load($products$context);
  124.         if ($request && $request->query->get('prependOptions')) {
  125.             $prependedOptions json_decode($request->query->get('prependOptions'), true);
  126.         }
  127.         // load configurator for each product
  128.         foreach ($products as $i => $product) {
  129.             /** @var ProductConfig $config */
  130.             $config $product->getExtension('maxiaListingVariants');
  131.             $config->setIsExpanded($expandOptions);
  132.             if ($config->isExpanded()) {
  133.                 $this->setExpandedConfig($pluginConfig$config);
  134.             }
  135.             if (isset($prependedOptions) && $prependedOptions) {
  136.                 $config->setPrependedOptions($prependedOptions);
  137.             }
  138.             $this->eventDispatcher->dispatch(new ListingVariantsBeforeLoadEvent($product$config$context));
  139.             // load options
  140.             if (!$product->getOptions()) {
  141.                 $product->setOptions(new PropertyGroupOptionCollection());
  142.             }
  143.             if (!$product->getOptionIds()) {
  144.                 $product->setOptionIds([]);
  145.             }
  146.             if (!$product->getParentId()) {
  147.                 $firstVariant $this->getFirstVariantProduct($product$displayConfigs$context);
  148.                 if (!$firstVariant) {
  149.                     continue;
  150.                 }
  151.                 $settings $this->configuratorLoader->load($firstVariant$context);
  152.             } else {
  153.                 $settings $this->configuratorLoader->load($product$context);
  154.             }
  155.             if ($settings && $settings->count()) {
  156.                 $config->setTotalGroupCount($settings->count());
  157.                 $config->setOptions($settings);
  158.                 $config->setOptions($this->filterGroups($product$context));
  159.             } else {
  160.                 unset($products[$i]);
  161.             }
  162.         }
  163.         // handle preselection
  164.         if ($this->shouldHandlePreselection()) {
  165.             $this->handlePreselection($products$displayConfigs$context);
  166.         }
  167.         // load additional data, limit displayed options
  168.         foreach ($products as $product) {
  169.             /** @var ProductConfig $config */
  170.             $config $product->getExtension('maxiaListingVariants');
  171.             $settings $config->getOptions();
  172.             if (!$settings || !$settings->count()) {
  173.                 continue;
  174.             }
  175.             $settings $this->filterOptions($product$context);
  176.             $config->setOptions($settings);
  177.             $mappings $this->loadProductMappings($product$context);
  178.             $config->setOptionProductMappings($mappings);
  179.             $mappings $this->loadProductEntities($config->getOptionProductMappings(), $product$context);
  180.             $mappings $this->loadProductCovers($mappings$product$context);
  181.             $config->setOptionProductMappings($mappings);
  182.             // get preselection and save to config
  183.             $selection = [];
  184.             if ($product->getOptions()) {
  185.                 foreach ($product->getOptions()->getElements() as $optionEntity) {
  186.                     $selection[$optionEntity->getGroupId()] = $optionEntity->getId();
  187.                 }
  188.                 $config->setSelection($selection);
  189.             }
  190.             $this->eventDispatcher->dispatch(new ListingVariantsLoadedEvent($product$config$context));
  191.         }
  192.     }
  193.     /**
  194.      * Returns the first variant product for the parent product.
  195.      */
  196.     protected function getFirstVariantProduct(SalesChannelProductEntity $parentProduct,
  197.         array $displayConfigs,
  198.         SalesChannelContext $context): ?SalesChannelProductEntity
  199.     {
  200.         $criteria = new Criteria();
  201.         if (isset($displayConfigs[$parentProduct->getId()])) {
  202.             $displayConfig $displayConfigs[$parentProduct->getId()];
  203.             if ($displayConfig['mainVariantId']) {
  204.                 $productId $displayConfig['mainVariantId'];
  205.             } else {
  206.                 $productId $displayConfig['firstAvailableVariantId'] ?: $displayConfig['firstVariantId'];
  207.             }
  208.         }
  209.         if (isset($productId)) {
  210.             $criteria->addFilter(new EqualsFilter('id'$productId));
  211.         } else {
  212.             $criteria->addFilter(new EqualsFilter('parentId'$parentProduct->getId()));
  213.         }
  214.         $criteria->setLimit(1);
  215.         $criteria->addAssociation('options')
  216.             ->addAssociation('options.group');
  217.         $criteria->addSorting(new FieldSorting('available'FieldSorting::DESCENDING));
  218.         $criteria->addSorting(new FieldSorting('availableStock'FieldSorting::DESCENDING));
  219.         $criteria->addSorting(new FieldSorting('autoIncrement'FieldSorting::ASCENDING));
  220.         $criteria->setLimit(1);
  221.         $results $this->productRepository->search($criteria$context);
  222.         return $results->first();
  223.     }
  224.     /**
  225.      * Check if preselection should be handled for the current request.
  226.      */
  227.     protected function shouldHandlePreselection(): bool
  228.     {
  229.         $request $this->getMainRequest();
  230.         $hasPropertyFilter $request->query->get('properties') !== null;
  231.         $handlePreselectionAttr $request->attributes->get('maxia-handle-variant-preselection'true);
  232.         /** @var SalesChannelContext $context */
  233.         $context $request->attributes->get(PlatformRequest::ATTRIBUTE_SALES_CHANNEL_CONTEXT_OBJECT);
  234.         return $handlePreselectionAttr && !$hasPropertyFilter
  235.             && !$this->configService->getBaseConfig($context)->isDisablePreselection();
  236.     }
  237.     /**
  238.      * Overwrite config when popup mode is active
  239.      */
  240.     protected function setExpandedConfig(BaseConfig $baseConfigProductConfig $config): void
  241.     {
  242.         $config->setDisplayMode('all');
  243.         $config->setQuickBuyActive(true);
  244.         $baseConfig->setShowDeliveryInfo(true);
  245.     }
  246.     /**
  247.      * Loads the property group configs and removes groups that should be hidden in the listing.
  248.      */
  249.     protected function filterGroups(ProductEntity $productSalesChannelContext $context): PropertyGroupCollection
  250.     {
  251.         /** @var ProductConfig $config */
  252.         $config $product->getExtension('maxiaListingVariants');
  253.         $settings $config->getOptions();
  254.         $groupIndex = -1;
  255.         $removed 0;
  256.         // filter and extend options
  257.         foreach ($settings->getElements() as $index => $groupEntity) {
  258.             $groupIndex++;
  259.             // remove group if not allowed by config
  260.             if (!$this->configService->checkDisplayMode($config$groupEntity$groupIndex)) {
  261.                 $settings->remove($index);
  262.                 $removed++;
  263.                 continue;
  264.             }
  265.             // add property group config extension
  266.             $groupConfig $this->configService->getPropertyGroupConfig($groupEntity$context);
  267.             $groupEntity->addExtension('maxiaListingVariants'$groupConfig);
  268.             if ($groupConfig->getDisplayType() && $groupConfig->getDisplayType() !== 'inherited') {
  269.                 $groupEntity->setDisplayType($groupConfig->getDisplayType());
  270.             }
  271.         }
  272.         if ($removed) {
  273.             $config->setIsPartialConfiguration(true);
  274.         }
  275.         return $settings;
  276.     }
  277.     protected function filterOptions(ProductEntity $productSalesChannelContext $context): PropertyGroupCollection
  278.     {
  279.         /** @var ProductConfig $config */
  280.         $config $product->getExtension('maxiaListingVariants');
  281.         /** @var PropertyGroupCollection $settings */
  282.         $settings $config->getOptions();
  283.         $prependedOptions = new EntityCollection();
  284.         $hideUnavailable $this->configService->getBaseConfig($context)->isHideSoldOutCloseoutProducts()
  285.             && $config->getTotalGroupCount() === 1;
  286.         $requiredOptionIds array_unique(array_merge(
  287.             $product->getOptionIds() ?? [], $config->getPrependedOptions() ?? []
  288.         ));
  289.         // filter and extend options
  290.         foreach ($settings->getElements() as $groupEntity) {
  291.             /** @var PropertyGroupEntity $groupEntity */
  292.             /** @var PropertyGroupConfig $groupConfig */
  293.             $groupConfig $groupEntity->getExtension('maxiaListingVariants');
  294.             $newOptions = new PropertyGroupOptionCollection();
  295.             // remove out of stock options
  296.             if ($hideUnavailable) {
  297.                 $newOptions $groupEntity->getOptions()->filter(function($option) use ($requiredOptionIds) {
  298.                     return $option->getCombinable() || in_array($option->getId(), $requiredOptionIds);
  299.                 });
  300.             } else {
  301.                 $newOptions->merge($groupEntity->getOptions());
  302.             }
  303.             $groupConfig->setTotalEntries($newOptions->count());
  304.             if (!$config->isExpanded()) {
  305.                 // limit max options and remove unavailable options
  306.                 $optionIndex 0;
  307.                 foreach ($newOptions as $option) {
  308.                     $optionIndex++;
  309.                     if ($groupConfig->getMaxEntries() && $optionIndex $groupConfig->getMaxEntries()) {
  310.                         $newOptions->remove($option->getId());
  311.                     }
  312.                 }
  313.                 // prepend / append preselected option if it was cut off
  314.                 foreach ($requiredOptionIds as $requiredOptionId) {
  315.                     $options $newOptions->filter(function($option) use ($requiredOptionId) {
  316.                         return $requiredOptionId === $option->getId();
  317.                     });
  318.                     if ($options->count()) {
  319.                         // option is already in list
  320.                         continue;
  321.                     }
  322.                     $requiredOptions $groupEntity->getOptions()->filter(function($option) use ($requiredOptionId) {
  323.                         return $requiredOptionId === $option->getId();
  324.                     });
  325.                     if ($requiredOptions->count()) {
  326.                         $prependedOptions->merge($requiredOptions);
  327.                         $requiredOption $requiredOptions->first();
  328.                         if ($requiredOption && $newOptions->count() > &&
  329.                             $requiredOption->getPosition() < $newOptions->first()->getPosition())
  330.                         {
  331.                             // prepend option
  332.                             $requiredOptions->merge($newOptions);
  333.                             $newOptions $requiredOptions;
  334.                             if ($newOptions->count() === $groupConfig->getMaxEntries()) {
  335.                                 $newOptions->remove($newOptions->last()->getId());
  336.                             }
  337.                         } else if ($requiredOption) {
  338.                             // append option
  339.                             if ($newOptions->count() === $groupConfig->getMaxEntries()) {
  340.                                 $newOptions->remove($newOptions->last()->getId());
  341.                             }
  342.                             $newOptions->add($requiredOption);
  343.                         }
  344.                     }
  345.                 }
  346.             }
  347.             $groupEntity->setOptions($newOptions);
  348.         }
  349.         $config->setPrependedOptions($prependedOptions->getKeys());
  350.         return $settings;
  351.     }
  352.     /**
  353.      * Search product IDs for each option
  354.      */
  355.     protected function loadProductMappings(ProductEntity $productSalesChannelContext $context): array
  356.     {
  357.         /** @var ProductConfig $config */
  358.         $config $product->getExtension('maxiaListingVariants');
  359.         $hideUnavailable $this->configService->getBaseConfig($context)->isHideSoldOutCloseoutProducts()
  360.             && $config->getTotalGroupCount() === 1;
  361.         $settings $config->getOptions();
  362.         $mappings $config->getOptionProductMappings() ?: [];
  363.         foreach ($settings->getElements() as $group) {
  364.             /** @var PropertyGroupConfig $groupConfig */
  365.             $groupConfig $group->getExtension('maxiaListingVariants');
  366.             $optionIndex 0;
  367.             foreach ($group->getOptions() as $key => $option) {
  368.                 if (isset($mappings[$option->getId()])) {
  369.                     continue;
  370.                 }
  371.                 $mappings[$option->getId()] = $this->loadProductMapping($product$group$option$context);
  372.                 if ($hideUnavailable && !$option->getCombinable()
  373.                     && !in_array($option->getId(), $product->getOptionIds()))
  374.                 {
  375.                     $group->getOptions()->remove($key);
  376.                     continue;
  377.                 }
  378.                 $optionIndex++;
  379.                 if (!$config->isExpanded() && $optionIndex $groupConfig->getMaxEntries()) {
  380.                     break;
  381.                 }
  382.             }
  383.         }
  384.         return $mappings;
  385.     }
  386.     /**
  387.      * Loads variant mapping for the given option.
  388.      */
  389.     protected function loadProductMapping(
  390.         ProductEntity $product,
  391.         PropertyGroupEntity $group,
  392.         PropertyGroupOptionEntity $option,
  393.         SalesChannelContext $context
  394.     ): array {
  395.         /** @var ProductConfig $productConfig */
  396.         $productConfig $product->getExtension('maxiaListingVariants');
  397.         /** @var PropertyGroupConfig $groupConfig */
  398.         $groupConfig $group->getExtension('maxiaListingVariants');
  399.         /** @var PropertyGroupOptionEntity $option */
  400.         $combinationOptionIds $this->getCombinationOptionIds($product$option);
  401.         $parentId $product->getParentId() ?: $product->getId();
  402.         // use less restrictive search, only if quick buy is off and expand by property values is inactive
  403.         $preferExactOptions $productConfig->isQuickBuyActive();
  404.         if ($product->getConfiguratorGroupConfig()) {
  405.             foreach ($product->getConfiguratorGroupConfig() as $item) {
  406.                 if ($item['expressionForListings']) {
  407.                     $preferExactOptions true;
  408.                 }
  409.             }
  410.         }
  411.         try {
  412.             // try to find available variants first
  413.             $foundCombination $this->combinationFinder->find(
  414.                 $parentId,
  415.                 $group->getId(),
  416.                 $combinationOptionIds,
  417.                 false,
  418.                 $context
  419.             );
  420.             $option->setCombinable(true);
  421.             $mapping = [
  422.                 'productId' => $foundCombination->getVariantId(),
  423.                 'isCombinable' => $option->getCombinable()
  424.             ];
  425.         } catch (ProductNotFoundException $e) {
  426.             try {
  427.                 if ($preferExactOptions) {
  428.                     $foundCombination $this->combinationFinder->find(
  429.                         $parentId,
  430.                         $group->getId(),
  431.                         $combinationOptionIds,
  432.                         true,
  433.                         $context
  434.                     );
  435.                     $option->setCombinable(false);
  436.                 } else {
  437.                     try {
  438.                         $foundCombination $this->combinationFinder->find(
  439.                             $parentId,
  440.                             $group->getId(),
  441.                             [$option->getId()],
  442.                             false,
  443.                             $context
  444.                         );
  445.                         $option->setCombinable(true);
  446.                     } catch (ProductNotFoundException $e) {
  447.                         $foundCombination $this->combinationFinder->find(
  448.                             $parentId,
  449.                             $group->getId(),
  450.                             [$option->getId()],
  451.                             true,
  452.                             $context
  453.                         );
  454.                         $option->setCombinable(false);
  455.                     }
  456.                 }
  457.                 $mapping = [
  458.                     'productId' => $foundCombination->getVariantId(),
  459.                     'isCombinable' => $option->getCombinable()
  460.                 ];
  461.             } catch (ProductNotFoundException $e) {
  462.                 $this->logger->debug('No products found for combination', [
  463.                     'productId' => $product->getId(),
  464.                     'combinationOptionIds' => $combinationOptionIds
  465.                 ]);
  466.                 $mapping = [
  467.                     'productId' => $product->getId(),
  468.                     'isCombinable' => $product->getAvailable(),
  469.                     'loadEntity' => false
  470.                 ];
  471.             }
  472.         }
  473.         $pluginConfig $this->configService->getBaseConfig($context);
  474.         if ($pluginConfig->isLoadAllEntities() ||
  475.             ($groupConfig->isShowPrices() && in_array($groupConfig->getDisplayType(), ['dropdown''list']) ||
  476.             ($groupConfig->isShowPrices() && in_array($groupConfig->getDisplayType(), ['inherited'null]) && $group->getDisplayType() === 'select'))
  477.         ) {
  478.             $mapping['loadEntity'] = true;
  479.         } else {
  480.             $mapping['loadEntity'] = false;
  481.         }
  482.         return $mapping;
  483.     }
  484.     /**
  485.      * Loads product entities for each option.
  486.      */
  487.     protected function loadProductEntities(array $mappings,
  488.         ProductEntity $product,
  489.         SalesChannelContext $context): array
  490.     {
  491.         $variantIds = [];
  492.         foreach ($mappings as $optionId => $mapping) {
  493.             if ($mapping['loadEntity']) {
  494.                 $variantIds[] = $mapping['productId'];
  495.             }
  496.         }
  497.         if ($variantIds) {
  498.             $criteria = new Criteria();
  499.             $criteria->setIds(array_unique($variantIds));
  500.             $criteria->addAssociation('prices');
  501.             /** @var EntityCollection|null $product */
  502.             $products $this->productRepository->search($criteria$context);
  503.             // assign entities to mappings array
  504.             foreach ($mappings as $optionId => $mapping) {
  505.                 if ($products->get($mapping['productId'])) {
  506.                     /** @var SalesChannelProductEntity $variant */
  507.                     $variant $products->get($mapping['productId']);
  508.                     $mappings[$optionId]['entity'] = $variant;
  509.                 }
  510.             }
  511.         }
  512.         return $mappings;
  513.     }
  514.     /**
  515.      * Returns the option IDs that are used for resolving the product for each option.
  516.      */
  517.     protected function getCombinationOptionIds(ProductEntity $productEntityPropertyGroupOptionEntity $option): array
  518.     {
  519.         /** @var PropertyGroupCollection $settings */
  520.         $settings $productEntity->getExtension('maxiaListingVariants')->getOptions();
  521.         $optionIds = [];
  522.         if (!$productEntity->getOptionIds()) {
  523.             return [$option->getId()];
  524.         }
  525.         foreach ($productEntity->getOptionIds() as $optionId) {
  526.             $group $settings->filter(function (PropertyGroupEntity $group) use ($optionId) {
  527.                 $optionIds $group->getOptions()->map(function(PropertyGroupOptionEntity $option) {
  528.                     return $option->getId();
  529.                 });
  530.                 return in_array($optionId$optionIds);
  531.             })->first();
  532.             if ($group && $group->getId() === $option->getGroupId()) {
  533.                 continue;
  534.             } else {
  535.                 $optionIds[] = $optionId;
  536.             }
  537.         }
  538.         $optionIds[] = $option->getId();
  539.         return $optionIds;
  540.     }
  541.     /**
  542.      * Overrides the default variant preselection in some cases.
  543.      *
  544.      * @param SalesChannelProductEntity[] $products
  545.      */
  546.     protected function handlePreselection(array $products, array $displayConfigsSalesChannelContext $context): void
  547.     {
  548.         $newProductIds $this->resolvePreselectionIds($products$displayConfigs$context);
  549.         if (!$newProductIds) {
  550.             return;
  551.         }
  552.         // load product entity for the new preselection
  553.         $criteria = clone $this->getProductListingCriteria($context);
  554.         $criteria->addAssociation('cover')
  555.             ->addAssociation('options.group')
  556.             ->addAssociation('manufacturer.media')
  557.             ->addAssociation('properties.group');
  558.         $criteria->addFilter(new EqualsAnyFilter('id'array_unique(array_values($newProductIds))));
  559.         $criteria->setLimit(null);
  560.         $criteria->setOffset(null);
  561.         $newProducts $this->productRepository->search($criteria$context);
  562.         // override products with the new ones
  563.         foreach ($products as $productId => $product) {
  564.             if (!isset($newProductIds[$productId])) {
  565.                 continue;
  566.             }
  567.             $newProductId $newProductIds[$productId];
  568.             if (!$newProducts->has($newProductId)) {
  569.                 continue;
  570.             }
  571.             $newProduct = clone $newProducts->get($newProductId);
  572.             foreach ($product->getExtensions() as $name => $extension) {
  573.                 if (!$newProduct->hasExtension($name)) {
  574.                     $newProduct->addExtension($name$extension);
  575.                 }
  576.             }
  577.             foreach ($newProduct->getVars() as $key => $value) {
  578.                 $product->assign([$key => $value]);
  579.             }
  580.             $settings $this->configuratorLoader->load($product$context);
  581.             if ($settings && $settings->count()) {
  582.                 $config $product->getExtension('maxiaListingVariants');
  583.                 $config->setTotalGroupCount($settings->count());
  584.                 $config->setOptions($settings);
  585.                 $config->setOptions($this->filterGroups($product$context));
  586.             }
  587.         }
  588.     }
  589.     /**
  590.      * Returns new product IDs as array (old product ID => new product ID)
  591.      *
  592.      * @param SalesChannelProductEntity[] $products
  593.      */
  594.     protected function resolvePreselectionIds(array $products,
  595.         array $displayConfigs,
  596.         SalesChannelContext $context): array
  597.     {
  598.         $pluginConfig $this->configService->getBaseConfig($context);
  599.         $newProductIds = [];
  600.         foreach ($products as $product) {
  601.             /** @var ProductConfig $productConfig */
  602.             $productConfig $product->getExtension('maxiaListingVariants');
  603.             $settings $productConfig->getOptions();
  604.             $mainVariantId null;
  605.             if ($product->getChildCount() && $pluginConfig->isDisplayParentSupported()) {
  606.                 // parent product without preselection
  607.                 continue;
  608.             }
  609.             $productId $product->getParentId() ?: $product->getId();
  610.             if (!isset($displayConfigs[$productId]) || !$settings || !$settings->count()) {
  611.                 continue;
  612.             }
  613.             $displayConfig $displayConfigs[$productId];
  614.             $mainVariantId $displayConfig['mainVariantId'];
  615.             $configuratorGroupConfig $displayConfig['configuratorGroupConfig'];
  616.             if ($configuratorGroupConfig) {
  617.                 // check if expand by property is active (auffaechern), if yes, do not update this product
  618.                 $expandActive false;
  619.                 foreach ($configuratorGroupConfig as $item) {
  620.                     if (isset($item['expressionForListings']) && $item['expressionForListings']) {
  621.                         $expandActive true;
  622.                         break;
  623.                     }
  624.                 }
  625.                 if ($expandActive && !($product->getChildCount() && !$pluginConfig->isDisplayParentSupported())) {
  626.                     continue;
  627.                 }
  628.             }
  629.             if (!$mainVariantId && $pluginConfig->isPreselectFirstVariantByDefault()) {
  630.                 $firstGroup $settings->first();
  631.                 $firstOption $firstGroup && $firstGroup->getOptions()
  632.                     ? $firstGroup->getOptions()->first()
  633.                     : null;
  634.                 if ($firstOption) {
  635.                     try {
  636.                         $foundCombination $this->combinationFinder->find(
  637.                             $displayConfig['parentId'],
  638.                             $firstGroup->getId(),
  639.                             [$firstOption->getId()],
  640.                             !$pluginConfig->isAvoidOutOfStockPreselection(),
  641.                             $context
  642.                         );
  643.                         $mainVariantId $foundCombination->getVariantId();
  644.                         if ($pluginConfig->isAvoidOutOfStockPreselection()) {
  645.                             $displayConfig['available'] = true;
  646.                         }
  647.                     } catch (ProductNotFoundException $e) {}
  648.                 }
  649.             }
  650.             if ($pluginConfig->isAvoidOutOfStockPreselection() && !$displayConfig['available']
  651.                 && $displayConfig['firstAvailableVariantId'])
  652.             {
  653.                 $mainVariantId $displayConfig['firstAvailableVariantId'];
  654.             }
  655.             if (!$mainVariantId && $product->getChildCount() && !$pluginConfig->isDisplayParentSupported()) {
  656.                 $mainVariantId $displayConfig['firstVariantId'];
  657.             }
  658.             if ($mainVariantId && $mainVariantId !== $product->getId()) {
  659.                 $newProductIds[$product->getId()] = $mainVariantId;
  660.             }
  661.         }
  662.         return $newProductIds;
  663.     }
  664.     /**
  665.      * Loads cover media for all options.
  666.      */
  667.     protected function loadProductCovers(array $mappings,
  668.         ProductEntity $product,
  669.         SalesChannelContext $context): array
  670.     {
  671.         // build product IDs array
  672.         $variantIds = [];
  673.         foreach ($mappings as $mapping) {
  674.             if (!isset($mapping['media'])) {
  675.                 $variantIds[] = $mapping['productId'];
  676.             }
  677.         }
  678.         if (empty($variantIds)) {
  679.             return $mappings;
  680.         }
  681.         $parentId $product->getParentId() ?: $product->getId();
  682.         $variantIds[] = $parentId;
  683.         $productMedia $this->getProductCovers($variantIds$context);
  684.         if (!$productMedia) {
  685.             return $mappings;
  686.         }
  687.         // assign media to options
  688.         foreach ($mappings as $optionId => $mapping) {
  689.             if (isset($productMedia[$mapping['productId']])) {
  690.                 $media $productMedia[$mapping['productId']];
  691.             } else if (isset($productMedia[$parentId])) {
  692.                 $media $productMedia[$parentId];
  693.             } else {
  694.                 continue;
  695.             }
  696.             if ($media) {
  697.                 $mappings[$optionId]['media'] = $media['entity'];
  698.             }
  699.         }
  700.         // assign media to main product entity
  701.         if (!$product->getCover()) {
  702.             $criteria = new Criteria();
  703.             $criteria->addFilter(new EqualsFilter('productId'$parentId));
  704.             $media $this->productMediaRepository->search($criteria$context->getContext());
  705.             if ($media->count()) {
  706.                 $product->setCover($media->first());
  707.             }
  708.         }
  709.         return $mappings;
  710.     }
  711.     /**
  712.      * Load cover media for multiple products, grouped by product IDs.
  713.      */
  714.     protected function getProductCovers(array $productIdsSalesChannelContext $context): array
  715.     {
  716.         // get media IDs for all products
  717.         $mediaIds $this->dbConnection->fetchAllAssociative("
  718.             SELECT product.id, product_media.media_id FROM product 
  719.             LEFT JOIN product_media ON (product_media.id = product.cover) 
  720.             WHERE product.id IN (:ids) AND product_media.media_id IS NOT NULL
  721.         ",
  722.             ['ids' => Uuid::fromHexToBytesList(array_unique($productIds))],
  723.             ['ids' => Connection::PARAM_STR_ARRAY]
  724.         );
  725.         if (empty($mediaIds)) {
  726.             return [];
  727.         }
  728.         $criteria = new Criteria();
  729.         $criteria->setIds(Uuid::fromBytesToHexList(array_column($mediaIds'media_id')));
  730.         $mediaEntities $this->mediaRepository->search($criteria$context->getContext());
  731.         $productMedia = [];
  732.         foreach ($mediaIds as $item) {
  733.             $productMedia[UUid::fromBytesToHex($item['id'])] =
  734.             $productMedia[UUid::fromBytesToHex($item['id'])] = [
  735.                 'media_id' => UUid::fromBytesToHex($item['media_id']),
  736.                 'entity' => $mediaEntities->get(UUid::fromBytesToHex($item['media_id']))
  737.             ];
  738.         }
  739.         return $productMedia;
  740.     }
  741.     /**
  742.      * Returns cached product listing criteria for the current request.
  743.      */
  744.     public function getProductListingCriteria(SalesChannelContext $context): ?Criteria
  745.     {
  746.         static $criteria;
  747.         if ($criteria === null) {
  748.             $criteria = new Criteria();
  749.             if (class_exists('\Shopware\Core\Content\Product\SalesChannel\Sorting\ProductSortingCollection')) {
  750.                 $criteria->addExtension('sortings'ProductListingCmsElementResolver::createSortings());
  751.             }
  752.             $this->eventDispatcher->dispatch(
  753.                 new ProductListingCriteriaEvent($this->getMainRequest(), $criteria$context)
  754.             );
  755.         }
  756.         return $criteria;
  757.     }
  758.     /**
  759.      * Returns listing criteria for the current configurator selection.
  760.      */
  761.     public function buildCriteria(Request $requestSalesChannelContext $salesChannelContext): Criteria
  762.     {
  763.         $productId $request->query->get('productId');
  764.         $pluginConfig $this->configService->getBaseConfig($salesChannelContext);
  765.         if ($pluginConfig->isDisplayParentSupported()) {
  766.             $displayConfig =  $this->dbConnection->fetchAssociative(
  767.                 'SELECT COALESCE(parent_id, id) as parent_id, display_parent FROM product WHERE id = :id',
  768.                 ['id' => Uuid::fromHexToBytes($productId)]
  769.             );
  770.         } else {
  771.             $displayConfig =  $this->dbConnection->fetchAssociative(
  772.                 'SELECT COALESCE(parent_id, id) as parent_id, 0 as display_parent FROM product WHERE id = :id',
  773.                 ['id' => Uuid::fromHexToBytes($productId)]
  774.             );
  775.         }
  776.         $parentId Uuid::fromBytesToHex($displayConfig['parent_id']);
  777.         if ($request->query->get('options') && $request->query->get('switched')) {
  778.             // find new product id by selected options
  779.             $newOptions json_decode($request->query->get('options'), true);
  780.             $switchedOption $request->query->get('switched');
  781.             try {
  782.                 $foundCombination $this->combinationFinder->find(
  783.                     $parentId$switchedOption$newOptions,
  784.                     true$salesChannelContext
  785.                 );
  786.                 $productId $foundCombination->getVariantId();
  787.             } catch (ProductNotFoundException $e) {}
  788.         }
  789.         $criteria = (new Criteria())
  790.             ->addAssociation('cover')
  791.             ->addAssociation('options.group')
  792.             ->addAssociation('manufacturer.media')
  793.             ->addAssociation('properties.group')
  794.             ->setLimit(1);
  795.         if ($parentId === $productId && $displayConfig['display_parent']) {
  796.             // show parent product (handled by ProductListingLoader::resolvePreviews)
  797.             $criteria->addFilter(new EqualsFilter('product.parentId'$productId));
  798.         } else {
  799.             // show specific variant
  800.             $criteria->addFilter(new EqualsFilter('product.id'$productId));
  801.             // add option filter to prevent ProductListingLoader::resolvePreviews
  802.             $criteria->addFilter(new NotFilter(
  803.                     NotFilter::CONNECTION_AND,
  804.                     [ new EqualsFilter('optionIds''1')]
  805.                 ))
  806.                 ->addPostFilter(new NotFilter(
  807.                     NotFilter::CONNECTION_AND,
  808.                     [ new EqualsFilter('optionIds''1')]
  809.                 ));
  810.         }
  811.         return $criteria;
  812.     }
  813.     protected function getMainRequest(): ?Request
  814.     {
  815.         return method_exists($this->requestStack'getMainRequest')
  816.             ? $this->requestStack->getMainRequest()
  817.             : $this->requestStack->getMasterRequest();
  818.     }
  819. }