<?php declare(strict_types=1);
namespace NewsletterSendinblue\Subscriber;
use Monolog\Logger;
use NewsletterSendinblue\Message\Product\ProductSyncDeleteMessage;
use NewsletterSendinblue\Message\Product\ProductSyncMessage;
use NewsletterSendinblue\NewsletterSendinblue;
use NewsletterSendinblue\Service\BaseSyncService;
use NewsletterSendinblue\Service\ConfigService;
use NewsletterSendinblue\Service\Constant;
use NewsletterSendinblue\Traits\HelperTrait;
use Shopware\Core\Content\Product\ProductEntity;
use Shopware\Core\Content\Product\ProductEvents;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepositoryInterface;
use Shopware\Core\Framework\DataAbstractionLayer\EntityWriteResult;
use Shopware\Core\Framework\DataAbstractionLayer\Event\EntityDeletedEvent;
use Shopware\Core\Framework\DataAbstractionLayer\Event\EntityWrittenEvent;
use Shopware\Core\Framework\Struct\ArrayStruct;
use Shopware\Storefront\Page\Product\ProductPageLoadedEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Messenger\MessageBusInterface;
use Throwable;
class ProductSubscriber implements EventSubscriberInterface
{
use HelperTrait;
/** @var BaseSyncService */
private $productSyncService;
/** @var EntityRepositoryInterface */
private $systemConfigRepository;
/** @var RequestStack */
private $requestStack;
/** @var ConfigService */
private $configService;
/** @var Logger */
private $logger;
/** @var MessageBusInterface */
private $messageBus;
/** @var array */
private $stockUpdatedProducts = [];
/** @var array */
private $syncedProductIds = [];
/**
* @param BaseSyncService $productSyncService
* @param EntityRepositoryInterface $systemConfigRepository
* @param RequestStack $requestStack
* @param ConfigService $configService
* @param Logger $logger
* @param MessageBusInterface $messageBus
*/
public function __construct(
BaseSyncService $productSyncService,
EntityRepositoryInterface $systemConfigRepository,
RequestStack $requestStack,
ConfigService $configService,
Logger $logger,
MessageBusInterface $messageBus
)
{
$this->productSyncService = $productSyncService;
$this->systemConfigRepository = $systemConfigRepository;
$this->requestStack = $requestStack;
$this->configService = $configService;
$this->logger = $logger;
$this->messageBus = $messageBus;
}
/**
* @return string[]
*/
public static function getSubscribedEvents(): array
{
return [
ProductEvents::PRODUCT_TRANSLATION_WRITTEN_EVENT => 'onProductTranslationWrittenEvent',
ProductEvents::PRODUCT_WRITTEN_EVENT => 'onProductWrittenEvent',
ProductEvents::PRODUCT_DELETED_EVENT => 'onProductDeletedEvent',
ProductEvents::PRODUCT_CATEGORY_WRITTEN_EVENT => 'onProductCategoryChangedEvent',
ProductEvents::PRODUCT_CATEGORY_DELETED_EVENT => 'onProductCategoryChangedEvent',
ProductPageLoadedEvent::class => 'onProductPageLoadedEvent'
];
}
/**
* @param ProductPageLoadedEvent $event
* @return void
*/
public function onProductPageLoadedEvent(ProductPageLoadedEvent $event): void
{
$isBackInStockSyncEnabled = false;
$this->configService->setSalesChannelId($event->getSalesChannelContext()->getSalesChannelId());
if ($this->configService->isBackInStockSyncEnabled()) {
$isBackInStockSyncEnabled = true;
}
$event->getPage()->addExtension('brevoProductPage', new ArrayStruct([
'isBackInStockSyncEnabled' => $isBackInStockSyncEnabled
]));
}
/**
* @param EntityDeletedEvent $event
* @return void
*/
public function onProductDeletedEvent(EntityDeletedEvent $event): void
{
$connectionId = $this->getAutoSyncConnectionId(
ConfigService::CONFIG_IS_PRODUCTS_AUTO_SYNC_ENABLED,
$event->getContext()
);
if (empty($connectionId)) {
return;
}
foreach ($event->getWriteResults() as $writeResult) {
$productId = $writeResult->getPrimaryKey();
if (empty($productId)) {
continue;
}
$deleteProducts = $event->getContext()->hasExtension('brevoPreWriteEvent')
? $event->getContext()->getExtension('brevoPreWriteEvent')->get('deleteProducts')
: null;
if ($writeResult->getOperation() === EntityWriteResult::OPERATION_DELETE
&& !empty($deleteProducts)
&& isset($deleteProducts[$productId])
&& $deleteProducts[$productId] instanceof ProductEntity
) {
$this->productSyncService->syncDelete($deleteProducts[$productId], $connectionId, $event->getContext());
}
}
if (!empty($deleteProducts)) {
$event->getContext()->getExtension('brevoPreWriteEvent')->set('deleteProducts', []);
$this->productSyncService->setDeleteEntities([]);
}
}
/**
* @param EntityWrittenEvent $event
* @return void
*/
public function onProductWrittenEvent(EntityWrittenEvent $event): void
{
if ($event->getContext()->hasExtension(NewsletterSendinblue::IGNORE_EVENT)) {
return;
}
$connectionId = $this->getAutoSyncConnectionId(
ConfigService::CONFIG_IS_PRODUCTS_AUTO_SYNC_ENABLED,
$event->getContext()
);
if (empty($connectionId)) {
return;
}
$syncProductIds = [];
$stockUpdatedProductIds = [];
$onlyVisibilityRemoved = $event->getContext()->hasExtension('brevoPreWriteEvent')
? $event->getContext()->getExtension('brevoPreWriteEvent')->get('onlyVisibilityRemoved')
: null;
$visibilityAlsoRemoved = $event->getContext()->hasExtension('brevoPreWriteEvent')
? $event->getContext()->getExtension('brevoPreWriteEvent')->get('visibilityAlsoRemoved')
: null;
// this variable is not for "product deleted",
// it is in case when all sales channels are removed from the product
$deletableIds = [];
try {
foreach ($event->getWriteResults() as $writeResult) {
$productId = $writeResult->getPrimaryKey();
if (empty($productId)) {
continue;
}
if ($writeResult->getOperation() === EntityWriteResult::OPERATION_INSERT) {
$syncProductIds[] = $productId;
// Track specific product IDs that have been synced
// It is to avoid calling "update" when the product is created.
// It will be checked onProductTranslationWrittenEvent
$this->syncedProductIds[] = $productId;
}
if ($writeResult->getOperation() === EntityWriteResult::OPERATION_UPDATE
) {
$changeSet = $writeResult->getChangeSet();
if ($changeSet
&& $changeSet->hasChanged('stock')
) {
$stockUpdatedProductIds[] = $productId;
}
if ($changeSet
&& ((int)$changeSet->getBefore('active') === 1
|| $changeSet->getBefore('active') === null) // for variant
&& $writeResult->getChangeSet()->hasChanged('active')
&& (int)$changeSet->getAfter('active') === 0
) {
$deletableIds[] = $productId;
$childrenIds = $this->productSyncService->getChildrenIds($productId, $event->getContext());
$deletableIds = array_merge($deletableIds, $childrenIds);
} elseif ($onlyVisibilityRemoved || $visibilityAlsoRemoved) {
/** @var ProductEntity $product */
$product = $this->productSyncService->getEntity($productId, $event->getContext());
if ($product->getVisibilities()->count() > 0 && $onlyVisibilityRemoved) {
$syncProductIds[] = $productId;
$this->syncedProductIds[] = $productId;
}
if ($product->getVisibilities()->count() === 0) {
// if this is variant - need to get the parent product and check visibility for parent
$parentProductForVisibility = null;
if ($product->getParentId()) {
$parentProductForVisibility = $this->productSyncService->getEntity($product->getParentId(), $event->getContext());
}
if (!$product->getParentId()
|| ($parentProductForVisibility && $parentProductForVisibility->getVisibilities()->count() === 0)
) {
$deletableIds[] = $productId;
$childrenIds = $this->productSyncService->getChildrenIds($productId, $event->getContext());
$deletableIds = array_merge($deletableIds, $childrenIds);
}
}
}
}
}
if (!empty($stockUpdatedProductIds)) {
/*
* it will look like this
* [
* 'availableStock' => [
* [
* 'product_id' => '<productId>',
* 'open_quantity' => '<quantityFromNonCompletedOrders>',
* 'sales_quantity' => '<quantityFromCompletedOrders>'
* ]
* ]
* ]
*/
$calculatedAvailableStocks = $this->productSyncService->getAdditionalData([
'availableStock' => $stockUpdatedProductIds
], $event->getContext());
$this->stockUpdatedProducts = !empty($calculatedAvailableStocks['availableStock'])
? array_combine(array_column($calculatedAvailableStocks['availableStock'], 'product_id'), $calculatedAvailableStocks['availableStock'])
: ['availableStock' => []];
}
// Handle sync operations
$this->handleSyncProducts($syncProductIds, $connectionId, $event->getContext());
// Handle delete operations
$deletableIds = array_unique($deletableIds);
$this->handleSyncDeleteProductIds($deletableIds, $connectionId, $event->getContext());
} catch (Throwable $e) {
}
}
/**
* @param EntityWrittenEvent $event
* @return void
*/
public function onProductCategoryChangedEvent(EntityWrittenEvent $event): void
{
$connectionId = $this->getAutoSyncConnectionId(
ConfigService::CONFIG_IS_PRODUCTS_AUTO_SYNC_ENABLED,
$event->getContext()
);
if (empty($connectionId)) {
return;
}
$syncProductIds = [];
try {
foreach ($event->getWriteResults() as $writeResult) {
$productId = $writeResult->getPrimaryKey()['productId'];
if (empty($productId)) {
continue;
}
$product = $this->productSyncService->getEntity($productId, $event->getContext());
if ($product instanceof ProductEntity
&& !$this->productSyncService->isCustomFieldEmpty($product)
&& !in_array($productId, $this->syncedProductIds, true)
) {
$syncProductIds[] = $productId;
$this->syncedProductIds[] = $productId;
}
}
$this->handleSyncProducts($syncProductIds, $connectionId, $event->getContext());
} catch (Throwable $e) {
}
}
/**
* @param EntityWrittenEvent $event
* @return void
*/
public function onProductTranslationWrittenEvent(EntityWrittenEvent $event): void
{
if ($event->getContext()->hasExtension(NewsletterSendinblue::IGNORE_EVENT)) {
return;
}
$connectionId = $this->getAutoSyncConnectionId(
ConfigService::CONFIG_IS_PRODUCTS_AUTO_SYNC_ENABLED,
$event->getContext()
);
if (empty($connectionId)) {
return;
}
$syncProductIds = [];
foreach ($event->getWriteResults() as $writeResult) {
$productId = $writeResult->getPrimaryKey()['productId'];
if (empty($productId)) {
continue;
}
// it is for avoiding call "sync" action when custom field is saved
if (!$this->checkChangeSet($writeResult)) {
continue;
}
if (($writeResult->getOperation() === EntityWriteResult::OPERATION_UPDATE
|| $writeResult->getOperation() === EntityWriteResult::OPERATION_INSERT)
&& !in_array($productId, $this->syncedProductIds, true)
) {
$syncProductIds[] = $productId;
// Also sync children
$childrenIds = $this->productSyncService->getChildrenIds($productId, $event->getContext());
$syncProductIds = array_merge($syncProductIds, $childrenIds);
$this->syncedProductIds[] = $productId;
// Also mark children as synced to avoid duplicate processing
$this->syncedProductIds = array_merge($this->syncedProductIds, $childrenIds);
}
}
// Handle sync with custom context (including stock data)
$customizedContext = $event->getContext();
if (!empty($this->stockUpdatedProducts)) {
$customizedContext->addExtension('availableStock', new ArrayStruct([
'value' => $this->stockUpdatedProducts
]));
}
$this->handleSyncProducts($syncProductIds, $connectionId, $customizedContext);
$this->syncedProductIds = [];
}
/**
* @param array $productIds
* @param string $connectionId
* @param $context
* @return void
*/
private function handleSyncProducts(array $productIds, string $connectionId, $context): void
{
if (empty($productIds)) {
return;
}
if (count($productIds) >= Constant::ASYNC_THRESHOLD) {
// Use message queue for batch operations
foreach ($productIds as $productId) {
$this->messageBus->dispatch(new ProductSyncMessage(
$productId,
$connectionId,
$context
));
}
} else {
// Process synchronously
foreach ($productIds as $productId) {
$this->productSyncService->sync($productId, $connectionId, $context);
}
}
}
/**
* @param array $productEntities
* @param string $connectionId
* @param $context
* @return void
*/
private function handleSyncDeleteProducts(array $productEntities, string $connectionId, $context): void
{
if (empty($productEntities)) {
return;
}
if (count($productEntities) >= Constant::ASYNC_THRESHOLD) {
// Use message queue for batch operations
foreach ($productEntities as $product) {
$this->messageBus->dispatch(new ProductSyncDeleteMessage(
$product->getId(),
$connectionId,
$context,
false // isDeactivation flag
));
}
} else {
// Process synchronously
foreach ($productEntities as $product) {
$this->productSyncService->syncDelete($product, $connectionId, $context);
}
}
}
/**
* @param array $productIds
* @param string $connectionId
* @param $context
* @return void
*/
private function handleSyncDeleteProductIds(array $productIds, string $connectionId, $context): void
{
if (empty($productIds)) {
return;
}
if (count($productIds) >= Constant::ASYNC_THRESHOLD) {
// Use message queue for batch operations
foreach ($productIds as $productId) {
$this->messageBus->dispatch(new ProductSyncDeleteMessage(
$productId,
$connectionId,
$context,
true // isDeactivation flag
));
}
} else {
// Process synchronously
foreach ($productIds as $productId) {
$item = $this->productSyncService->getEntity($productId, $context);
if ($item instanceof ProductEntity
&& ($item->getVisibilities()->count() === 0 || !$item->getActive())
) {
$this->productSyncService->syncDelete($item, $connectionId, $context, true);
}
}
}
}
}