custom/plugins/NewsletterSendinblue/src/Subscriber/ProductSubscriber.php line 368

Open in your IDE?
  1. <?php declare(strict_types=1);
  2. namespace NewsletterSendinblue\Subscriber;
  3. use Monolog\Logger;
  4. use NewsletterSendinblue\Message\Product\ProductSyncDeleteMessage;
  5. use NewsletterSendinblue\Message\Product\ProductSyncMessage;
  6. use NewsletterSendinblue\NewsletterSendinblue;
  7. use NewsletterSendinblue\Service\BaseSyncService;
  8. use NewsletterSendinblue\Service\ConfigService;
  9. use NewsletterSendinblue\Service\Constant;
  10. use NewsletterSendinblue\Service\Product\ProductViewedProducer;
  11. use NewsletterSendinblue\Traits\HelperTrait;
  12. use Shopware\Core\Content\Product\ProductEntity;
  13. use Shopware\Core\Content\Product\ProductEvents;
  14. use Shopware\Core\Framework\DataAbstractionLayer\EntityRepositoryInterface;
  15. use Shopware\Core\Framework\DataAbstractionLayer\EntityWriteResult;
  16. use Shopware\Core\Framework\DataAbstractionLayer\Event\EntityDeletedEvent;
  17. use Shopware\Core\Framework\DataAbstractionLayer\Event\EntityWrittenEvent;
  18. use Shopware\Core\Framework\Struct\ArrayStruct;
  19. use Shopware\Storefront\Page\Product\ProductPageLoadedEvent;
  20. use Symfony\Component\EventDispatcher\EventSubscriberInterface;
  21. use Symfony\Component\HttpFoundation\RequestStack;
  22. use Symfony\Component\Messenger\MessageBusInterface;
  23. use Throwable;
  24. class ProductSubscriber implements EventSubscriberInterface
  25. {
  26.     use HelperTrait;
  27.     /** @var BaseSyncService */
  28.     private $productSyncService;
  29.     /** @var EntityRepositoryInterface */
  30.     private $systemConfigRepository;
  31.     /** @var RequestStack */
  32.     private $requestStack;
  33.     /** @var ConfigService */
  34.     private $configService;
  35.     /** @var Logger */
  36.     private $logger;
  37.     /** @var MessageBusInterface */
  38.     private $messageBus;
  39.     /** @var ProductViewedProducer */
  40.     private $productViewedProducer;
  41.     /** @var array */
  42.     private $stockUpdatedProducts = [];
  43.     /** @var array */
  44.     private $syncedProductIds = [];
  45.     /**
  46.      * @param BaseSyncService $productSyncService
  47.      * @param EntityRepositoryInterface $systemConfigRepository
  48.      * @param RequestStack $requestStack
  49.      * @param ConfigService $configService
  50.      * @param Logger $logger
  51.      * @param MessageBusInterface $messageBus
  52.      * @param ProductViewedProducer $productViewedProducer
  53.      */
  54.     public function __construct(
  55.         BaseSyncService           $productSyncService,
  56.         EntityRepositoryInterface $systemConfigRepository,
  57.         RequestStack              $requestStack,
  58.         ConfigService             $configService,
  59.         Logger                    $logger,
  60.         MessageBusInterface       $messageBus,
  61.         ProductViewedProducer     $productViewedProducer
  62.     )
  63.     {
  64.         $this->productSyncService = $productSyncService;
  65.         $this->systemConfigRepository = $systemConfigRepository;
  66.         $this->requestStack = $requestStack;
  67.         $this->configService = $configService;
  68.         $this->logger = $logger;
  69.         $this->messageBus = $messageBus;
  70.         $this->productViewedProducer = $productViewedProducer;
  71.     }
  72.     /**
  73.      * @return string[]
  74.      */
  75.     public static function getSubscribedEvents(): array
  76.     {
  77.         return [
  78.             ProductEvents::PRODUCT_TRANSLATION_WRITTEN_EVENT => 'onProductTranslationWrittenEvent',
  79.             ProductEvents::PRODUCT_WRITTEN_EVENT => 'onProductWrittenEvent',
  80.             ProductEvents::PRODUCT_DELETED_EVENT => 'onProductDeletedEvent',
  81.             ProductEvents::PRODUCT_CATEGORY_WRITTEN_EVENT => 'onProductCategoryChangedEvent',
  82.             ProductEvents::PRODUCT_CATEGORY_DELETED_EVENT => 'onProductCategoryChangedEvent',
  83.             ProductPageLoadedEvent::class => 'onProductPageLoadedEvent'
  84.         ];
  85.     }
  86.     /**
  87.      * @param ProductPageLoadedEvent $event
  88.      * @return void
  89.      */
  90.     public function onProductPageLoadedEvent(ProductPageLoadedEvent $event): void
  91.     {
  92.         $isBackInStockSyncEnabled = false;
  93.         $this->configService->setSalesChannelId($event->getSalesChannelContext()->getSalesChannelId());
  94.         if ($this->configService->isBackInStockSyncEnabled()) {
  95.             $isBackInStockSyncEnabled = true;
  96.         }
  97.         $event->getPage()->addExtension('brevoProductPage', new ArrayStruct([
  98.             'isBackInStockSyncEnabled' => $isBackInStockSyncEnabled
  99.         ]));
  100.         //  Send product viewed event
  101.         if ($this->configService->isAbandonedCartTrackingEnabled()) {
  102.             try {
  103.                 $product = $event->getPage()->getProduct();
  104.                 if ($product instanceof ProductEntity) {
  105.                     $this->productViewedProducer->sendProductViewedEvent($product, $event->getSalesChannelContext());
  106.                 }
  107.             } catch (\Exception $e) {
  108.                 $this->logger->addRecord(Logger::ERROR, 'Product viewed event failed: ' . $e->getMessage());
  109.             }
  110.         }
  111.     }
  112.     /**
  113.      * @param EntityDeletedEvent $event
  114.      * @return void
  115.      */
  116.     public function onProductDeletedEvent(EntityDeletedEvent $event): void
  117.     {
  118.         $connectionId = $this->getAutoSyncConnectionId(
  119.             ConfigService::CONFIG_IS_PRODUCTS_AUTO_SYNC_ENABLED,
  120.             $event->getContext()
  121.         );
  122.         if (empty($connectionId)) {
  123.             return;
  124.         }
  125.         foreach ($event->getWriteResults() as $writeResult) {
  126.             $productId = $writeResult->getPrimaryKey();
  127.             if (empty($productId)) {
  128.                 continue;
  129.             }
  130.             $deleteProducts = $event->getContext()->hasExtension('brevoPreWriteEvent')
  131.                 ? $event->getContext()->getExtension('brevoPreWriteEvent')->get('deleteProducts')
  132.                 : null;
  133.             if ($writeResult->getOperation() === EntityWriteResult::OPERATION_DELETE
  134.                 && !empty($deleteProducts)
  135.                 && isset($deleteProducts[$productId])
  136.                 && $deleteProducts[$productId] instanceof ProductEntity
  137.             ) {
  138.                 $this->productSyncService->syncDelete($deleteProducts[$productId], $connectionId, $event->getContext());
  139.             }
  140.         }
  141.         if (!empty($deleteProducts)) {
  142.             $event->getContext()->getExtension('brevoPreWriteEvent')->set('deleteProducts', []);
  143.             $this->productSyncService->setDeleteEntities([]);
  144.         }
  145.     }
  146.     /**
  147.      * @param EntityWrittenEvent $event
  148.      * @return void
  149.      */
  150.     public function onProductWrittenEvent(EntityWrittenEvent $event): void
  151.     {
  152.         if ($event->getContext()->hasExtension(NewsletterSendinblue::IGNORE_EVENT)) {
  153.             return;
  154.         }
  155.         $connectionId = $this->getAutoSyncConnectionId(
  156.             ConfigService::CONFIG_IS_PRODUCTS_AUTO_SYNC_ENABLED,
  157.             $event->getContext()
  158.         );
  159.         if (empty($connectionId)) {
  160.             return;
  161.         }
  162.         $syncProductIds = [];
  163.         $stockUpdatedProductIds = [];
  164.         $onlyVisibilityRemoved = $event->getContext()->hasExtension('brevoPreWriteEvent')
  165.             ? $event->getContext()->getExtension('brevoPreWriteEvent')->get('onlyVisibilityRemoved')
  166.             : null;
  167.         $visibilityAlsoRemoved = $event->getContext()->hasExtension('brevoPreWriteEvent')
  168.             ? $event->getContext()->getExtension('brevoPreWriteEvent')->get('visibilityAlsoRemoved')
  169.             : null;
  170.         // this variable is not for "product deleted",
  171.         // it is in case when all sales channels are removed from the product
  172.         $deletableIds = [];
  173.         $parentsForChildren = [];
  174.         try {
  175.             foreach ($event->getWriteResults() as $writeResult) {
  176.                 $productId = $writeResult->getPrimaryKey();
  177.                 if (empty($productId)) {
  178.                     continue;
  179.                 }
  180.                 if ($writeResult->getOperation() === EntityWriteResult::OPERATION_INSERT) {
  181.                     $syncProductIds[] = $productId;
  182.                     // Track specific product IDs that have been synced
  183.                     // It is to avoid calling "update" when the product is created.
  184.                     // It will be checked onProductTranslationWrittenEvent
  185.                     $this->syncedProductIds[] = $productId;
  186.                 }
  187.                 if ($writeResult->getOperation() === EntityWriteResult::OPERATION_UPDATE
  188.                 ) {
  189.                     $changeSet = $writeResult->getChangeSet();
  190.                     if ($changeSet
  191.                         && $changeSet->hasChanged('stock')
  192.                     ) {
  193.                         $stockUpdatedProductIds[] = $productId;
  194.                     }
  195.                     if ($changeSet
  196.                         && ((int)$changeSet->getBefore('active') === 1
  197.                             || $changeSet->getBefore('active') === null) // for variant
  198.                         && $writeResult->getChangeSet()->hasChanged('active')
  199.                         && (int)$changeSet->getAfter('active') === 0
  200.                     ) {
  201.                         $deletableIds[] = $productId;
  202.                         $parentsForChildren[] = $productId;
  203.                     } elseif ($onlyVisibilityRemoved || $visibilityAlsoRemoved) {
  204.                         /** @var ProductEntity $product */
  205.                         $product = $this->productSyncService->getEntity($productId, $event->getContext());
  206.                         if ($product->getVisibilities()->count() > 0 && $onlyVisibilityRemoved) {
  207.                             $syncProductIds[] = $productId;
  208.                             $this->syncedProductIds[] = $productId;
  209.                         }
  210.                         if ($product->getVisibilities()->count() === 0) {
  211.                             // if this is variant - need to get the parent product and check visibility for parent
  212.                             $parentProductForVisibility = null;
  213.                             if ($product->getParentId()) {
  214.                                 $parentProductForVisibility = $this->productSyncService->getEntity($product->getParentId(), $event->getContext());
  215.                             }
  216.                             if (!$product->getParentId()
  217.                                 || ($parentProductForVisibility && $parentProductForVisibility->getVisibilities()->count() === 0)
  218.                             ) {
  219.                                 $deletableIds[] = $productId;
  220.                                 $parentsForChildren[] = $productId;
  221.                             }
  222.                         }
  223.                     }
  224.                 }
  225.             }
  226.             // Fetch children ids in batch for delete operations
  227.             if (!empty($parentsForChildren)) {
  228.                 $map = $this->productSyncService->getChildrenIdsForParents(array_unique($parentsForChildren), $event->getContext());
  229.                 $allChildren = [];
  230.                 foreach ($map as $childList) {
  231.                     if (is_array($childList)) {
  232.                         $allChildren = array_merge($allChildren, $childList);
  233.                     }
  234.                 }
  235.                 if (!empty($allChildren)) {
  236.                     $deletableIds = array_merge($deletableIds, $allChildren);
  237.                 }
  238.             }
  239.             if (!empty($stockUpdatedProductIds)) {
  240.                 /*
  241.                  * it will look like this
  242.                  *  [
  243.                  *     'availableStock' => [
  244.                  *         [
  245.                  *             'product_id' => '<productId>',
  246.                  *             'open_quantity' => '<quantityFromNonCompletedOrders>',
  247.                  *             'sales_quantity' => '<quantityFromCompletedOrders>'
  248.                  *         ]
  249.                  *     ]
  250.                  *  ]
  251.                  */
  252.                 $calculatedAvailableStocks = $this->productSyncService->getAdditionalData([
  253.                     'availableStock' => $stockUpdatedProductIds
  254.                 ], $event->getContext());
  255.                 $this->stockUpdatedProducts = !empty($calculatedAvailableStocks['availableStock'])
  256.                     ? array_combine(array_column($calculatedAvailableStocks['availableStock'], 'product_id'), $calculatedAvailableStocks['availableStock'])
  257.                     : ['availableStock' => []];
  258.             }
  259.             // Handle sync operations
  260.             $this->handleSyncProducts($syncProductIds, $connectionId, $event->getContext());
  261.             // Handle delete operations
  262.             $deletableIds = array_unique($deletableIds);
  263.             $this->handleSyncDeleteProductIds($deletableIds, $connectionId, $event->getContext());
  264.         } catch (Throwable $e) {
  265.         }
  266.     }
  267.     /**
  268.      * @param EntityWrittenEvent $event
  269.      * @return void
  270.      */
  271.     public function onProductCategoryChangedEvent(EntityWrittenEvent $event): void
  272.     {
  273.         $connectionId = $this->getAutoSyncConnectionId(
  274.             ConfigService::CONFIG_IS_PRODUCTS_AUTO_SYNC_ENABLED,
  275.             $event->getContext()
  276.         );
  277.         if (empty($connectionId)) {
  278.             return;
  279.         }
  280.         try {
  281.             $productIds = [];
  282.             foreach ($event->getWriteResults() as $writeResult) {
  283.                 $id = $writeResult->getPrimaryKey()['productId'] ?? null;
  284.                 if ($id) {
  285.                     $productIds[] = $id;
  286.                 }
  287.             }
  288.             $productIds = array_values(array_unique($productIds));
  289.             if (empty($productIds)) {
  290.                 return;
  291.             }
  292.             $syncProductIds = [];
  293.             // Chunking: load 200 products per query (safe for large batches)
  294.             foreach (array_chunk($productIds, 200) as $chunk) {
  295.                 $products = $this->productSyncService->getEntities($chunk, $event->getContext());
  296.                 foreach ($products as $product) {
  297.                     $id = $product->getId();
  298.                     if (!$this->productSyncService->isCustomFieldEmpty($product)
  299.                         && !in_array($id, $this->syncedProductIds, true)
  300.                     ) {
  301.                         $syncProductIds[] = $id;
  302.                         $this->syncedProductIds[] = $id;
  303.                     }
  304.                 }
  305.             }
  306.             if (!empty($syncProductIds)) {
  307.                 $this->handleSyncProducts($syncProductIds, $connectionId, $event->getContext());
  308.             }
  309.         } catch (Throwable $e) {
  310.         }
  311.     }
  312.     /**
  313.      * @param EntityWrittenEvent $event
  314.      * @return void
  315.      */
  316.     public function onProductTranslationWrittenEvent(EntityWrittenEvent $event): void
  317.     {
  318.         if ($event->getContext()->hasExtension(NewsletterSendinblue::IGNORE_EVENT)) {
  319.             return;
  320.         }
  321.         $connectionId = $this->getAutoSyncConnectionId(
  322.             ConfigService::CONFIG_IS_PRODUCTS_AUTO_SYNC_ENABLED,
  323.             $event->getContext()
  324.         );
  325.         if (empty($connectionId)) {
  326.             return;
  327.         }
  328.         $syncProductIds = [];
  329.         $parentsForChildren = [];
  330.         foreach ($event->getWriteResults() as $writeResult) {
  331.             $productId = $writeResult->getPrimaryKey()['productId'];
  332.             if (empty($productId)) {
  333.                 continue;
  334.             }
  335.             // it is for avoiding call "sync" action when custom field is saved
  336.             if (!$this->checkChangeSet($writeResult)) {
  337.                 continue;
  338.             }
  339.             if (($writeResult->getOperation() === EntityWriteResult::OPERATION_UPDATE
  340.                     || $writeResult->getOperation() === EntityWriteResult::OPERATION_INSERT)
  341.                 && !in_array($productId, $this->syncedProductIds, true)
  342.             ) {
  343.                 $syncProductIds[] = $productId;
  344.                 $parentsForChildren[] = $productId;
  345.                 $this->syncedProductIds[] = $productId;
  346.             }
  347.         }
  348.         // Fetch children ids in batch to minimize DB queries
  349.         if (!empty($parentsForChildren)) {
  350.             $map = $this->productSyncService->getChildrenIdsForParents(array_unique($parentsForChildren), $event->getContext());
  351.             $allChildren = [];
  352.             foreach ($map as $childList) {
  353.                 if (is_array($childList)) {
  354.                     $allChildren = array_merge($allChildren, $childList);
  355.                 }
  356.             }
  357.             if (!empty($allChildren)) {
  358.                 $syncProductIds = array_merge($syncProductIds, $allChildren);
  359.                 $this->syncedProductIds = array_merge($this->syncedProductIds, $allChildren);
  360.             }
  361.         }
  362.         // Handle sync with custom context (including stock data)
  363.         $customizedContext = $event->getContext();
  364.         if (!empty($this->stockUpdatedProducts)) {
  365.             $customizedContext->addExtension('availableStock', new ArrayStruct([
  366.                 'value' => $this->stockUpdatedProducts
  367.             ]));
  368.         }
  369.         $this->handleSyncProducts($syncProductIds, $connectionId, $customizedContext);
  370.         $this->syncedProductIds = [];
  371.     }
  372.     /**
  373.      * @param array $productIds
  374.      * @param string $connectionId
  375.      * @param $context
  376.      * @return void
  377.      */
  378.     private function handleSyncProducts(array $productIds, string $connectionId, $context): void
  379.     {
  380.         if (empty($productIds)) {
  381.             return;
  382.         }
  383.         if (count($productIds) >= Constant::ASYNC_THRESHOLD) {
  384.             // Use message queue for batch operations
  385.             foreach ($productIds as $productId) {
  386.                 $this->messageBus->dispatch(new ProductSyncMessage(
  387.                     $productId,
  388.                     $connectionId,
  389.                     $context
  390.                 ));
  391.             }
  392.         } else {
  393.             // Process synchronously
  394.             foreach ($productIds as $productId) {
  395.                 $this->productSyncService->sync($productId, $connectionId, $context);
  396.             }
  397.         }
  398.     }
  399.     /**
  400.      * @param array $productEntities
  401.      * @param string $connectionId
  402.      * @param $context
  403.      * @return void
  404.      */
  405.     private function handleSyncDeleteProducts(array $productEntities, string $connectionId, $context): void
  406.     {
  407.         if (empty($productEntities)) {
  408.             return;
  409.         }
  410.         if (count($productEntities) >= Constant::ASYNC_THRESHOLD) {
  411.             // Use message queue for batch operations
  412.             foreach ($productEntities as $product) {
  413.                 $this->messageBus->dispatch(new ProductSyncDeleteMessage(
  414.                     $product->getId(),
  415.                     $connectionId,
  416.                     $context,
  417.                     false // isDeactivation flag
  418.                 ));
  419.             }
  420.         } else {
  421.             // Process synchronously
  422.             foreach ($productEntities as $product) {
  423.                 $this->productSyncService->syncDelete($product, $connectionId, $context);
  424.             }
  425.         }
  426.     }
  427.     /**
  428.      * @param array $productIds
  429.      * @param string $connectionId
  430.      * @param $context
  431.      * @return void
  432.      */
  433.     private function handleSyncDeleteProductIds(array $productIds, string $connectionId, $context): void
  434.     {
  435.         if (empty($productIds)) {
  436.             return;
  437.         }
  438.         if (count($productIds) >= Constant::ASYNC_THRESHOLD) {
  439.             // Use message queue for batch operations
  440.             foreach ($productIds as $productId) {
  441.                 $this->messageBus->dispatch(new ProductSyncDeleteMessage(
  442.                     $productId,
  443.                     $connectionId,
  444.                     $context,
  445.                     true // isDeactivation flag
  446.                 ));
  447.             }
  448.         } else {
  449.             // Process synchronously
  450.             foreach ($productIds as $productId) {
  451.                 $item = $this->productSyncService->getEntity($productId, $context);
  452.                 if ($item instanceof ProductEntity
  453.                     && ($item->getVisibilities()->count() === 0 || !$item->getActive())
  454.                 ) {
  455.                     $this->productSyncService->syncDelete($item, $connectionId, $context, true);
  456.                 }
  457.             }
  458.         }
  459.     }
  460. }