custom/plugins/ApplifactionGuidedShopping/src/Controller/AccessoryAssistantController.php line 137

Open in your IDE?
  1. <?php
  2. namespace ApplifactionGuidedShopping\Controller;
  3. use ApplifactionGuidedShopping\Entity\AccessoryRule\AccessoryRuleEntity;
  4. use ApplifactionGuidedShopping\Entity\DynamicFilterRule\DynamicFilterRuleEntity;
  5. use ApplifactionGuidedShopping\Entity\PropertyGroupOptionValue\PropertyGroupOptionValueCollection;
  6. use ApplifactionGuidedShopping\Entity\PropertyGroupOptionValue\PropertyGroupOptionValueEntity;
  7. use ApplifactionGuidedShopping\Service\CacheService;
  8. use ApplifactionGuidedShopping\Service\NormalizationService;
  9. use ApplifactionGuidedShopping\Event\AccessoryProductsFetchedEvent;
  10. use Doctrine\DBAL\Connection;
  11. use Psr\Log\LoggerInterface;
  12. use Shopware\Core\Content\Product\Events\ProductListingCriteriaEvent;
  13. use Shopware\Core\Content\Product\ProductCollection;
  14. use Shopware\Core\Content\Product\ProductEntity;
  15. use Shopware\Core\Content\ProductStream\ProductStreamEntity;
  16. use Shopware\Core\Content\ProductStream\Service\ProductStreamBuilder;
  17. use Shopware\Core\Content\Property\Aggregate\PropertyGroupOption\PropertyGroupOptionEntity;
  18. use Shopware\Core\Content\Seo\SeoUrlPlaceholderHandler;
  19. use Shopware\Core\Framework\Context;
  20. use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
  21. use Shopware\Core\Framework\DataAbstractionLayer\Search\Aggregation\Metric\CountAggregation;
  22. use Shopware\Core\Framework\DataAbstractionLayer\Search\AggregationResult\AggregationResultCollection;
  23. use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
  24. use Shopware\Core\Framework\DataAbstractionLayer\Search\EntitySearchResult;
  25. use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\AndFilter;
  26. use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsAnyFilter;
  27. use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
  28. use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\NotFilter;
  29. use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\OrFilter;
  30. use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\RangeFilter;
  31. use Shopware\Core\Framework\DataAbstractionLayer\Search\Sorting\FieldSorting;
  32. use Shopware\Core\Profiling\Profiler;
  33. use Shopware\Core\System\SalesChannel\Entity\SalesChannelRepository;
  34. use Shopware\Core\System\SalesChannel\SalesChannelContext;
  35. use Shopware\Storefront\Controller\StorefrontController;
  36. use Shopware\Storefront\Framework\Routing\RequestTransformer;
  37. use Shopware\Storefront\Page\Product\QuickView\MinimalQuickViewPageLoader;
  38. use Shopware\Storefront\Page\Product\QuickView\ProductQuickViewWidgetLoadedHook;
  39. use Symfony\Component\EventDispatcher\EventDispatcherInterface;
  40. use Symfony\Component\HttpFoundation\JsonResponse;
  41. use Symfony\Component\HttpFoundation\Request;
  42. use Symfony\Component\HttpFoundation\Response;
  43. use Symfony\Component\Routing\Annotation\Route;
  44. use Twig\Environment;
  45. #[Route(defaults: ['_routeScope' => ['storefront']])]
  46. class AccessoryAssistantController extends StorefrontController
  47. {
  48. /**
  49. * @var ProductStreamBuilder
  50. */
  51. private $productStreamBuilder;
  52. /**
  53. * @var SalesChannelRepository
  54. */
  55. private $productRepository;
  56. /**
  57. * @var EntityRepository
  58. */
  59. private $accessoryRuleRepository;
  60. /**
  61. * @var Environment
  62. */
  63. private $twig;
  64. /**
  65. * @var EventDispatcherInterface
  66. */
  67. private $eventDispatcher;
  68. /**
  69. * @var CacheService
  70. */
  71. private $cacheService;
  72. /**
  73. * @var SeoUrlPlaceholderHandler
  74. */
  75. protected $seoUrlPlaceholderHandler;
  76. /**
  77. * @var MinimalQuickViewPageLoader
  78. */
  79. private $minimalQuickViewPageLoader;
  80. /**
  81. * @var NormalizationService
  82. */
  83. private $normalizationService;
  84. /**
  85. * @var LoggerInterface
  86. */
  87. private $logger;
  88. /**
  89. * @var Connection
  90. */
  91. private $connection;
  92. public function __construct(
  93. ProductStreamBuilder $productStreamBuilder,
  94. SalesChannelRepository $productRepository,
  95. EntityRepository $accessoryRuleRepository,
  96. Environment $twig,
  97. EventDispatcherInterface $eventDispatcher,
  98. CacheService $cacheService,
  99. SeoUrlPlaceholderHandler $seoUrlPlaceholderHandler,
  100. MinimalQuickViewPageLoader $minimalQuickViewPageLoader,
  101. NormalizationService $normalizationService,
  102. LoggerInterface $logger,
  103. Connection $connection
  104. )
  105. {
  106. $this->productStreamBuilder = $productStreamBuilder;
  107. $this->productRepository = $productRepository;
  108. $this->accessoryRuleRepository = $accessoryRuleRepository;
  109. $this->twig = $twig;
  110. $this->eventDispatcher = $eventDispatcher;
  111. $this->cacheService = $cacheService;
  112. $this->seoUrlPlaceholderHandler = $seoUrlPlaceholderHandler;
  113. $this->minimalQuickViewPageLoader = $minimalQuickViewPageLoader;
  114. $this->normalizationService = $normalizationService;
  115. $this->logger = $logger;
  116. $this->connection = $connection;
  117. }
  118. /**
  119. * @Route("/accessory-assistant/matching-accessory-rule-ids/{productId}", name="frontend.applifaction.accessory-assistant.matching-accessory-rule-ids", options={"seo"="false"}, methods={"POST"}, defaults={"XmlHttpRequest": true})
  120. */
  121. public function accessoryRuleMatch(string $productId, SalesChannelContext $salesChannelContext): Response
  122. {
  123. return Profiler::trace('frontend.applifaction.accessory-assistant.matching-accessory-rule-ids', function () use ($productId, $salesChannelContext) {
  124. return $this->cacheService->getCacheValueOrRecalculate(
  125. 'frontend.applifaction.accessory-assistant.matching-accessory-rule-ids',
  126. ['productId' => $productId, 'productUpdatedAt' => $this->cacheService->fetchUpdatedAtByProductIds([$productId])],
  127. \DateInterval::createFromDateString('24 hours'),
  128. function () use ($productId, $salesChannelContext) {
  129. return new JsonResponse(['matchingRuleIds' => $this->fetchMatchingAccessoryRuleIds($productId, $salesChannelContext)]);
  130. },
  131. $salesChannelContext);
  132. });
  133. }
  134. /**
  135. * @Route("/accessory-assistant/modal-content", name="frontend.applifaction.accessory-assistant.modal-content", options={"seo"="false"}, methods={"POST"}, defaults={"XmlHttpRequest": true})
  136. */
  137. public function modalContent(Request $request, SalesChannelContext $salesChannelContext): Response
  138. {
  139. return Profiler::trace('frontend.applifaction.accessory-assistant.modal-content', function () use ($request, $salesChannelContext) {
  140. $productId = $request->get('productId');
  141. $matchingRuleIds = $request->get('matchingRuleIds');
  142. return $this->cacheService->getCacheValueOrRecalculate(
  143. 'frontend.applifaction.accessory-assistant.modal-content',
  144. [
  145. 'productId' => $productId,
  146. 'matchingRuleIds' => $matchingRuleIds,
  147. 'productUpdatedAt' => $this->cacheService->fetchUpdatedAtByProductIds([$productId]),
  148. 'ruleUpdatedAt' => $this->cacheService->fetchUpdatedAtByRuleIds($matchingRuleIds)
  149. ],
  150. \DateInterval::createFromDateString('24 hours'),
  151. function () use ($productId, $matchingRuleIds, $request, $salesChannelContext) {
  152. return new Response($this->fetchAccessoryGroupedByMatchingRule($productId, $matchingRuleIds, $request, $salesChannelContext));
  153. },
  154. $salesChannelContext);
  155. });
  156. }
  157. /**
  158. * @Route("/accessory-assistant/quickview/{productId}", name="frontend.applifaction.accessory-assistant.quickview", methods={"GET"}, defaults={"XmlHttpRequest": true})
  159. */
  160. public function accessoryAssistantQuickView(Request $request, SalesChannelContext $context): Response
  161. {
  162. $page = $this->minimalQuickViewPageLoader->load($request, $context);
  163. $this->hook(new ProductQuickViewWidgetLoadedHook($page, $context));
  164. return $this->renderStorefront('@ApplifactionGuidedShopping/storefront/modal/modal-accessory-assistant-quickview-inner.html.twig', ['page' => $page]);
  165. }
  166. private function fetchAccessoryGroupedByMatchingRule(
  167. string $productId,
  168. array $matchingRuleIds,
  169. Request $request,
  170. SalesChannelContext $salesChannelContext): string
  171. {
  172. $productsGroupedByMatchingRule = [];
  173. $productCriteria = new Criteria();
  174. $productCriteria->setTitle('applifaction::accessory-rule::products');
  175. $productCriteria->addSorting(new FieldSorting('sales', FieldSorting::DESCENDING));
  176. $accessoryRuleCriteria = new Criteria();
  177. $accessoryRuleCriteria->setTitle('applifaction::accessory-rule::results');
  178. $accessoryRuleCriteria->setLimit(200);
  179. $accessoryRuleCriteria->addFilter(new EqualsAnyFilter('id', $matchingRuleIds));
  180. $accessoryRuleCriteria->addAssociation('accessoryStreams');
  181. $accessoryRuleCriteria->addAssociation('accessoryIncludedOptions');
  182. $accessoryRuleCriteria->addAssociation('accessoryExcludedOptions');
  183. $accessoryRuleCriteria->addAssociation('dynamicFilterRules.mainProductCustomField');
  184. $accessoryRuleCriteria->addAssociation('dynamicFilterRules.accessoryGroup');
  185. $accessoryRuleCriteria->addSorting(new FieldSorting('position', FieldSorting::ASCENDING));
  186. // Check for cache hit or aggregate result
  187. /** @var EntitySearchResult $accessoryRules */
  188. $accessoryRules = $this->cacheService->getCacheValueOrRecalculate(
  189. $accessoryRuleCriteria->getTitle(),
  190. [
  191. 'productId' => $productId,
  192. 'productUpdatedAt' => $this->cacheService->fetchUpdatedAtByProductIds([$productId])
  193. ],
  194. \DateInterval::createFromDateString('24 hours'),
  195. function () use ($accessoryRuleCriteria, $salesChannelContext) {
  196. return $this->accessoryRuleRepository->search($accessoryRuleCriteria, $salesChannelContext->getContext());
  197. },
  198. $salesChannelContext,
  199. $accessoryRuleCriteria);
  200. /** @var AccessoryRuleEntity $accessoryRule */
  201. foreach ($accessoryRules as $accessoryRule) {
  202. if ($accessoryRule->getAccessoryStreams()->count() === 0
  203. && $accessoryRule->getAccessoryIncludedOptions()->count() === 0
  204. && $accessoryRule->getDynamicFilterRules()->count() === 0) {
  205. continue;
  206. }
  207. $streamFilters = [];
  208. $optionIncludedFilters = [];
  209. $optionExcludedFilters = [];
  210. $ruleProductCriteria = clone $productCriteria;
  211. $ruleProductCriteria->setTitle('applifaction::accessory-rule::products::rule-id-' . $accessoryRule->getId());
  212. $accessoryRuleId = $accessoryRule->getId();
  213. // Add product stream apiFilters to the criteria
  214. /** @var ProductStreamEntity $accessoryStream */
  215. foreach ($accessoryRule->getAccessoryStreams() as $accessoryStream) {
  216. $filters = $this->productStreamBuilder->buildFilters(
  217. $accessoryStream->getId(),
  218. $salesChannelContext->getContext()
  219. );
  220. $streamFilters[] = $filters[0];
  221. }
  222. if (!empty($streamFilters)) {
  223. $ruleProductCriteria->addFilter(new OrFilter($streamFilters));
  224. }
  225. // Add included property group options to the criteria
  226. /** @var PropertyGroupOptionEntity $accessoryIncludedOptions */
  227. foreach ($accessoryRule->getAccessoryIncludedOptions() as $accessoryIncludedOptions) {
  228. $optionIncludedFilters[] = $accessoryIncludedOptions->getId();
  229. }
  230. if (!empty($optionIncludedFilters)) {
  231. $ruleProductCriteria->addFilter(new EqualsAnyFilter('propertyIds', $optionIncludedFilters));
  232. }
  233. // Add excluded property group options to the criteria
  234. /** @var PropertyGroupOptionEntity $accessoryExcludedOptions */
  235. foreach ($accessoryRule->getAccessoryExcludedOptions() as $accessoryExcludedOptions) {
  236. $optionExcludedFilters[] = $accessoryExcludedOptions->getId();
  237. }
  238. if (!empty($optionExcludedFilters)) {
  239. $ruleProductCriteria->addFilter(
  240. new NotFilter(
  241. NotFilter::CONNECTION_AND,
  242. [new EqualsAnyFilter('propertyIds', $optionExcludedFilters)]
  243. )
  244. );
  245. }
  246. $this->applyDynamicAccessoryFilters($productId, $ruleProductCriteria, $accessoryRule, $salesChannelContext);
  247. $this->eventDispatcher->dispatch(
  248. new ProductListingCriteriaEvent($request, $ruleProductCriteria, $salesChannelContext)
  249. );
  250. $ruleProductCriteria->setLimit(24);
  251. /** @var EntitySearchResult $searchResult */
  252. $searchResult = $this->cacheService->getCacheValueOrRecalculate(
  253. $ruleProductCriteria->getTitle(),
  254. [
  255. 'productId' => $productId,
  256. 'accessoryRuleId' => $accessoryRuleId,
  257. 'productUpdatedAt' => $this->cacheService->fetchUpdatedAtByProductIds([$productId]),
  258. 'ruleUpdatedAt' => $this->cacheService->fetchUpdatedAtByRuleIds([$accessoryRuleId])
  259. ],
  260. \DateInterval::createFromDateString('24 hours'),
  261. function () use ($ruleProductCriteria, $accessoryRule, $salesChannelContext, $productId) {
  262. return Profiler::trace('applifaction.accessory-rule-search-products::rule-' . $accessoryRule->getId(), function () use ($ruleProductCriteria, $salesChannelContext, $productId) {
  263. $products = $this->productRepository->search($ruleProductCriteria, $salesChannelContext);
  264. $event = new AccessoryProductsFetchedEvent($products);
  265. $this->eventDispatcher->dispatch($event);
  266. // If the accessory is only compatible with a few main products, remove it in case the main product is not one of these
  267. $tempProducts = clone $products;
  268. /** @var ProductEntity $product */
  269. foreach ($tempProducts as $product) {
  270. $customFields = $product->getCustomFields();
  271. if (!isset($customFields['ags_compatible_products']) || !is_string($customFields['ags_compatible_products'])) continue;
  272. $productNumbers = preg_split('/, */', $customFields['ags_compatible_products']);
  273. if (empty($productNumbers)) continue;
  274. $productIds = $this->fetchProductIdsByProductNumbers($productNumbers);
  275. if (in_array($productId, $productIds)) continue;
  276. $products->remove($product->getId());
  277. }
  278. // Remove sold out products - they shouldn't be displayed
  279. $tempProducts = clone $products;
  280. $productCount = 0;
  281. $maxProductCount = 12;
  282. /** @var ProductEntity $product */
  283. foreach ($tempProducts as $product) {
  284. $isPurchasable = $product->getAvailable() &&
  285. $product->getChildCount() <= 0 &&
  286. $product->getCalculatedMaxPurchase() > 0 &&
  287. $productCount <= $maxProductCount;
  288. if (!$isPurchasable) {
  289. $products->remove($product->getId());
  290. } else {
  291. $productCount++;
  292. }
  293. }
  294. return $products;
  295. });
  296. },
  297. $salesChannelContext,
  298. $accessoryRuleCriteria);
  299. $productsGroupedByMatchingRule[$accessoryRule->getId()] = [
  300. 'rule' => $accessoryRule,
  301. 'products' => $searchResult
  302. ];
  303. }
  304. $html = $this->twig->render('@ApplifactionGuidedShopping/storefront/modal/modal-accessory-assistant-inner.html.twig', [
  305. 'productsGroupedByMatchingRule' => $productsGroupedByMatchingRule,
  306. ]);
  307. $host = $request->attributes->get(RequestTransformer::SALES_CHANNEL_ABSOLUTE_BASE_URL)
  308. . $request->attributes->get(RequestTransformer::SALES_CHANNEL_BASE_URL);
  309. $html = $this->seoUrlPlaceholderHandler->replace($html, $host, $salesChannelContext);
  310. return $html;
  311. }
  312. private function fetchMatchingAccessoryRuleIds(
  313. string $productId,
  314. SalesChannelContext $salesChannelContext): array
  315. {
  316. $matchingRuleIds = [];
  317. $productCriteria = new Criteria();
  318. $productCriteria->setTitle('applifaction::accessory-rule::product-matches');
  319. $productCriteria->setLimit(1);
  320. $productCriteria->addFilter(new EqualsFilter('id', $productId));
  321. $productCriteria->addAggregation(new CountAggregation('count', 'id'));
  322. $accessoryRuleCriteria = new Criteria();
  323. $accessoryRuleCriteria->setTitle('applifaction::accessory-rule::all-with-main-product-properties');
  324. $accessoryRuleCriteria->setLimit(200);
  325. $accessoryRuleCriteria->addAssociation('mainProductStreams');
  326. $accessoryRuleCriteria->addAssociation('mainProductIncludedOptions');
  327. $accessoryRuleCriteria->addAssociation('mainProductExcludedOptions');
  328. $accessoryRuleCriteria->addAssociation('dynamicFilterRules.mainProductCustomField');
  329. $accessoryRuleCriteria->addSorting(new FieldSorting('position', FieldSorting::ASCENDING));
  330. // Check for cache hit or aggregate result
  331. /** @var EntitySearchResult $accessoryRules */
  332. $accessoryRules = $this->cacheService->getCacheValueOrRecalculate(
  333. $accessoryRuleCriteria->getTitle(),
  334. [
  335. 'productId' => $productId,
  336. 'productUpdatedAt' => $this->cacheService->fetchUpdatedAtByProductIds([$productId])
  337. ],
  338. \DateInterval::createFromDateString('24 hours'),
  339. function () use ($accessoryRuleCriteria, $salesChannelContext) {
  340. return $this->accessoryRuleRepository->search($accessoryRuleCriteria, $salesChannelContext->getContext());
  341. },
  342. $salesChannelContext,
  343. $accessoryRuleCriteria);
  344. /** @var AccessoryRuleEntity $accessoryRule */
  345. foreach ($accessoryRules as $accessoryRule) {
  346. if ($accessoryRule->getMainProductStreams()->count() === 0
  347. && $accessoryRule->getMainProductIncludedOptions()->count() === 0) {
  348. continue;
  349. }
  350. // In case the accessoryRule has dynamic filter rules, the main product must contain at least one of its customFields or properties
  351. if ($accessoryRule->getDynamicFilterRules()->count() > 0) {
  352. $mainProductGroupIds = $accessoryRule->getDynamicFilterRules()->map(function (DynamicFilterRuleEntity $dynamicFilterRule) {
  353. if ($dynamicFilterRule->getMainProductValueSource() === DynamicFilterRuleEntity::VALUE_SOURCE_PROPERTY && !!$dynamicFilterRule->getMainProductGroupId()) {
  354. return $dynamicFilterRule->getMainProductGroupId();
  355. } else {
  356. return null;
  357. }
  358. });
  359. $mainProductGroupIds = array_values(array_filter($mainProductGroupIds, fn($mainProductGroupId) => $mainProductGroupId !== null));
  360. $mainProductCustomFields = $accessoryRule->getDynamicFilterRules()->map(function (DynamicFilterRuleEntity $dynamicFilterRule) {
  361. if ($dynamicFilterRule->getMainProductValueSource() === DynamicFilterRuleEntity::VALUE_SOURCE_CUSTOM_FIELD && !!$dynamicFilterRule->getMainProductCustomField() && !!$dynamicFilterRule->getMainProductCustomFieldUnit()) {
  362. return $dynamicFilterRule->getMainProductCustomField()->getName();
  363. } else {
  364. return null;
  365. }
  366. });
  367. $mainProductCustomFields = array_values(array_filter($mainProductCustomFields, fn($mainProductGroupId) => $mainProductGroupId !== null));
  368. if (!empty($mainProductGroupIds) || !empty($mainProductCustomFields)) {
  369. $criteria = new Criteria();
  370. $criteria->setTitle('applifaction::accessory-rule::dynamic-filter-product-property-check');
  371. $criteria->addAssociation('properties');
  372. $criteria->addFilter(new EqualsFilter('id', $productId));
  373. $products = $this->productRepository->search($criteria, $salesChannelContext);
  374. if ($products->count() === 0) continue;
  375. $hasAtLeastOneCustomField = false;
  376. $hasAtLeastOneProperty = false;
  377. /** @var ProductEntity $product */
  378. foreach ($products as $product) {
  379. foreach ($product->getProperties() as $property) {
  380. if (in_array($property->getGroupId(), $mainProductGroupIds)) {
  381. $hasAtLeastOneProperty = true;
  382. break;
  383. }
  384. }
  385. if ($hasAtLeastOneProperty) break;
  386. foreach ($mainProductCustomFields as $mainProductCustomField) {
  387. if (in_array($mainProductCustomField, array_keys($product->getCustomFields()))) {
  388. $hasAtLeastOneCustomField = true;
  389. break;
  390. }
  391. }
  392. if ($hasAtLeastOneCustomField) break;
  393. }
  394. if (!$hasAtLeastOneCustomField && !$hasAtLeastOneProperty) continue;
  395. }
  396. }
  397. $streamFilters = [];
  398. $optionFilters = [];
  399. $ruleProductCriteria = clone $productCriteria;
  400. $accessoryRuleId = $accessoryRule->getId();
  401. // Add product stream apiFilters to the criteria
  402. /** @var ProductStreamEntity $mainProductStream */
  403. foreach ($accessoryRule->getMainProductStreams() as $mainProductStream) {
  404. $queries = $this->productStreamBuilder->buildFilters(
  405. $mainProductStream->getId(),
  406. $salesChannelContext->getContext()
  407. );
  408. $streamFilters[] = $queries[0];
  409. }
  410. if (!empty($streamFilters)) {
  411. $ruleProductCriteria->addFilter(new OrFilter($streamFilters));
  412. }
  413. // Add propertyOptions to the criteria
  414. /** @var PropertyGroupOptionEntity $mainProductIncludedOptions */
  415. foreach ($accessoryRule->getMainProductIncludedOptions() as $mainProductIncludedOptions) {
  416. $optionFilters[] = $mainProductIncludedOptions->getId();
  417. }
  418. if (!empty($optionFilters)) {
  419. $ruleProductCriteria->addFilter(new EqualsAnyFilter('propertyIds', $optionFilters));
  420. }
  421. // Check for cache hit or aggregate result
  422. /** @var AggregationResultCollection $aggregationResult */
  423. $aggregationResult = $this->cacheService->getCacheValueOrRecalculate(
  424. $ruleProductCriteria->getTitle(),
  425. [
  426. 'productId' => $productId,
  427. 'accessoryRuleId' => $accessoryRuleId,
  428. 'productUpdatedAt' => $this->cacheService->fetchUpdatedAtByProductIds([$productId]),
  429. 'ruleUpdatedAt' => $this->cacheService->fetchUpdatedAtByRuleIds([$accessoryRuleId])
  430. ],
  431. \DateInterval::createFromDateString('24 hours'),
  432. function () use ($ruleProductCriteria, $salesChannelContext) {
  433. return $this->productRepository->aggregate($ruleProductCriteria, $salesChannelContext);
  434. },
  435. $salesChannelContext,
  436. $ruleProductCriteria);
  437. if ($aggregationResult->first()->getCount() > 0) {
  438. $matchingRuleIds[] = $accessoryRule->getId();
  439. }
  440. }
  441. return $matchingRuleIds;
  442. }
  443. private function applyDynamicAccessoryFilters(string $productId, Criteria $ruleProductCriteria, AccessoryRuleEntity $accessoryRule, SalesChannelContext $salesChannelContext)
  444. {
  445. $dynamicFilterRules = $accessoryRule->getDynamicFilterRules();
  446. $allQueries = [];
  447. // Keep only rules, which have both IDs set
  448. $dynamicFilterRules = $dynamicFilterRules->filter(
  449. function (DynamicFilterRuleEntity $rule) {
  450. if ($rule->getMainProductValueSource() === DynamicFilterRuleEntity::VALUE_SOURCE_PROPERTY) {
  451. return !!$rule->getMainProductGroupId() && !!$rule->getAccessoryGroupId();
  452. } else {
  453. return !!$rule->getMainProductCustomFieldId() && !!$rule->getMainProductCustomFieldUnit() && !!$rule->getAccessoryGroupId();
  454. }
  455. }
  456. );
  457. $mainProductGroupIds = $dynamicFilterRules->map(fn(DynamicFilterRuleEntity $dynamicFilterRule) => $dynamicFilterRule->getMainProductGroupId());
  458. $mainProductGroupIds = array_values(array_filter($mainProductGroupIds, fn($mainProductGroupId) => $mainProductGroupId !== null));
  459. if (empty($mainProductGroupIds)) return;
  460. // Fetch main product and cache it
  461. $mainProductCriteria = new Criteria();
  462. $mainProductCriteria->addFilter(new EqualsFilter('id', $productId));
  463. $mainProductCriteria->addAssociation('properties.group');
  464. $mainProductCriteria->addAssociation('properties.agsPropertyGroupOptionValues');
  465. $propertiesAssociation = $mainProductCriteria->getAssociation('properties');
  466. $propertiesAssociation->addFilter(new EqualsAnyFilter('groupId', $mainProductGroupIds));
  467. /** @var ProductCollection $mainProducts */
  468. $mainProducts = $this->productRepository->search($mainProductCriteria, $salesChannelContext);
  469. /** @var DynamicFilterRuleEntity $dynamicFilterRule */
  470. foreach ($dynamicFilterRules as $dynamicFilterRule) {
  471. $mainProductGroupId = $dynamicFilterRule->getMainProductGroupId();
  472. $accessoryGroupId = $dynamicFilterRule->getAccessoryGroupId();
  473. $accessoryGroupUnit = isset($dynamicFilterRule->getAccessoryGroup()->getCustomFields()['ags_property_group_unit']) ? strtolower($dynamicFilterRule->getAccessoryGroup()->getCustomFields()['ags_property_group_unit']) : null;
  474. $mainProductCustomField = $dynamicFilterRule->getMainProductCustomField();
  475. $mainProductValueSource = $dynamicFilterRule->getMainProductValueSource();
  476. $queryPropertyValues = new PropertyGroupOptionValueCollection();
  477. if (!!$mainProductCustomField && $mainProductValueSource === DynamicFilterRuleEntity::VALUE_SOURCE_CUSTOM_FIELD) {
  478. /** @var ProductEntity $mainProduct */
  479. foreach ($mainProducts as $mainProduct) {
  480. $customFields = $mainProduct->getCustomFields();
  481. if (isset($customFields[$mainProductCustomField->getName()])) {
  482. $customFieldValue = $customFields[$mainProductCustomField->getName()];
  483. if (!!$customFieldValue) {
  484. $collection = new PropertyGroupOptionValueCollection();
  485. $mainProductCustomFieldUnit = $dynamicFilterRule->getMainProductCustomFieldUnit() ?? 'kg';
  486. try {
  487. $collection = $this->normalizationService->parseString($customFieldValue, $mainProductCustomFieldUnit);
  488. $collection->map(fn(PropertyGroupOptionValueEntity $value) => $value->convertUnit($accessoryGroupUnit));
  489. $queryPropertyValues->merge($collection);
  490. } catch (\Exception $e) {
  491. $this->logger->error(join("\t", [$e->getMessage(), 'Product: ' . $mainProduct->getProductNumber(), 'Custom Field ' . $mainProductCustomField->getName() . ': ' . $customFieldValue]));
  492. }
  493. }
  494. }
  495. }
  496. } else if (!!$mainProductGroupId) {
  497. /** @var ProductEntity $mainProduct */
  498. foreach ($mainProducts as $mainProduct) {
  499. /** @var PropertyGroupOptionEntity $mainProductPropertyOption */
  500. foreach ($mainProduct->getProperties() as $mainProductPropertyOption) {
  501. if ($mainProductGroupId === $mainProductPropertyOption->getGroupId()) {
  502. /** @var PropertyGroupOptionValueCollection|null $mainProductNormalizedPropertyValues */
  503. $mainProductNormalizedPropertyValues = $mainProductPropertyOption->getExtension('agsPropertyGroupOptionValues');
  504. if (!!$mainProductNormalizedPropertyValues && $mainProductNormalizedPropertyValues->count() > 0) {
  505. try {
  506. $mainProductNormalizedPropertyValues->map(fn(PropertyGroupOptionValueEntity $value) => $value->convertUnit($accessoryGroupUnit));
  507. $queryPropertyValues->merge($mainProductNormalizedPropertyValues);
  508. } catch (\Exception $e) {
  509. $this->logger->error(join("\t", [$e->getMessage(), 'Product: ' . $mainProduct->getProductNumber()]));
  510. }
  511. }
  512. }
  513. }
  514. }
  515. }
  516. $queries = [];
  517. /** @var PropertyGroupOptionValueEntity $propertyValue */
  518. foreach ($queryPropertyValues as $queryPropertyValue) {
  519. // E.g. Accessory: Belastbarkeit -> Main Product: Weight
  520. // E.g. Accessory: VESA Norm (300x300-800x600) -> Main Product: VESA Norm (600x600)
  521. $queries[] = new EqualsFilter('properties.agsPropertyGroupOptionValues.propertyGroupOption.groupId', $accessoryGroupId);
  522. if (!is_null($queryPropertyValue->getMinD1()) && !is_null($queryPropertyValue->getMaxD1())) {
  523. $queries[] = new RangeFilter('properties.agsPropertyGroupOptionValues.minD1', ['lte' => $queryPropertyValue->getMaxD1()]);
  524. $queries[] = new RangeFilter('properties.agsPropertyGroupOptionValues.maxD1', ['gte' => $queryPropertyValue->getMinD1()]);
  525. }
  526. if (!is_null($queryPropertyValue->getMinD2()) && !is_null($queryPropertyValue->getMaxD2())) {
  527. $queries[] = new RangeFilter('properties.agsPropertyGroupOptionValues.minD2', ['lte' => $queryPropertyValue->getMaxD2()]);
  528. $queries[] = new RangeFilter('properties.agsPropertyGroupOptionValues.maxD2', ['gte' => $queryPropertyValue->getMinD2()]);
  529. }
  530. if (!is_null($queryPropertyValue->getMinD3()) && !is_null($queryPropertyValue->getMaxD3())) {
  531. $queries[] = new RangeFilter('properties.agsPropertyGroupOptionValues.minD3', ['lte' => $queryPropertyValue->getMaxD3()]);
  532. $queries[] = new RangeFilter('properties.agsPropertyGroupOptionValues.maxD3', ['gte' => $queryPropertyValue->getMinD3()]);
  533. }
  534. }
  535. if (!empty($queries)) {
  536. $allQueries[] = new AndFilter($queries);
  537. }
  538. }
  539. $ruleProductCriteria->addFilter(new AndFilter($allQueries));
  540. }
  541. /**
  542. * @param array $productNumbers
  543. * @return array
  544. */
  545. public function fetchProductIdsByProductNumbers(array $productNumbers): array
  546. {
  547. $productIdSql = <<<SQL
  548. SELECT LOWER(HEX(id)) as 'id'
  549. FROM product
  550. WHERE product_number IN (:productNumbers)
  551. SQL;
  552. try {
  553. $productIdResults = $this->connection->fetchAllAssociative(
  554. $productIdSql,
  555. ['productNumbers' => $productNumbers],
  556. ['productNumbers' => Connection::PARAM_STR_ARRAY]
  557. );
  558. } catch (\Exception $e) {
  559. return [];
  560. }
  561. return array_map(fn($productIdResult) => $productIdResult['id'], $productIdResults);
  562. }
  563. }