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

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