<?php
namespace Cogi\PropertyCrossSelling\Subscriber;
use Doctrine\DBAL\Connection;
use Shopware\Core\Content\Cms\Events\CmsPageLoadedEvent;
use Shopware\Core\Content\Product\Aggregate\ProductCrossSelling\ProductCrossSellingEntity;
use Shopware\Core\Content\Product\Events\ProductCrossSellingsLoadedEvent;
use Shopware\Core\Content\Product\SalesChannel\CrossSelling\CrossSellingElement;
use Shopware\Core\Content\Product\SalesChannel\Listing\Filter;
use Shopware\Core\Content\Product\SalesChannel\ProductCloseoutFilter;
use Shopware\Core\Content\Product\SalesChannel\SalesChannelProductEntity;
use Shopware\Core\Framework\DataAbstractionLayer\Doctrine\FetchModeHelper;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Aggregation\Bucket\TermsAggregation;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Aggregation\Metric\EntityAggregation;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\ContainsFilter;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsAnyFilter;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\MultiFilter;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\NotFilter;
use Shopware\Core\Framework\Uuid\Uuid;
use Shopware\Storefront\Page\Product\ProductPageLoadedEvent;
use Shopware\Core\System\SystemConfig\SystemConfigService;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Shopware\Core\System\SalesChannel\Entity\SalesChannelRepositoryInterface;
use Shopware\Core\Framework\Adapter\Translation\Translator;
use Symfony\Component\HttpFoundation\Request;
class ProductPageSubscriber implements EventSubscriberInterface
{
/**
* @var SystemConfigService
*/
private $systemConfigService;
/**
* @var SalesChannelRepositoryInterface
*/
private $salesChannelProductRepository;
/**
* @var Connection
*/
private $connection;
/**
* @var Translator
*/
private $translator;
public function __construct(
Connection $connection,
SalesChannelRepositoryInterface $salesChannelProductRepository,
SystemConfigService $systemConfigService,
Translator $translator
)
{
$this->connection = $connection;
$this->salesChannelProductRepository = $salesChannelProductRepository;
$this->systemConfigService = $systemConfigService;
$this->translator = $translator;
}
public static function getSubscribedEvents(): array
{
return [
ProductPageLoadedEvent::class => 'onPageLoaded',
// ProductCrossSellingsLoadedEvent::class => 'onCmsCrossSellingsLoaded'
];
}
/**
* The function below IS and WONT work in near future because the event does not delivery any product ID if the
* cross sellings are empty
*
* @param ProductCrossSellingsLoadedEvent $event
* @param string $request
* @param $data
* @return void
*/
public function onCmsCrossSellingsLoaded(ProductCrossSellingsLoadedEvent $event, string $request, $data): void
{
$salesChannelId = $event->getSalesChannelContext()->getSalesChannel()->getId();
$autoAdd = false;
$insert = 'after';
if (!count($event->getCrossSellings()) && $this->systemConfigService->get('CogiPropertyCrossSelling.config.useAsDefault', $salesChannelId)) {
$autoAdd = true;
}
if ($this->systemConfigService->get('CogiPropertyCrossSelling.config.useAlways', $salesChannelId)) {
$autoAdd = true;
$insert = $this->systemConfigService->get('CogiPropertyCrossSelling.config.useAlwaysPosition', $salesChannelId) ?: 'after';
}
// Use property cross selling as default if no other cross selling defined
if ($autoAdd) {
$crossSellings = $event->getCrossSellings();
$crossSellingElement = new CrossSellingElement();
$crossSelling = new ProductCrossSellingEntity();
$defaultName = $this->translator->trans('cogiPropertyCrossSelling.defaultCrossSellingName');
$crossSelling->setName($defaultName);
$crossSelling->setTranslated(['name' => $defaultName]);
$crossSelling->setType('cogiPropertyCrossSelling');
$crossSelling->setId(md5('crossSelling' . rand()));
$crossSelling->setActive(true);
$crossSellingElement->setCrossSelling($crossSelling);
$crossSellingElement->setTotal(1);
if ($insert === 'before') {
$elements = $crossSellings->getElements();
$crossSellings->clear();
$crossSellings->add($crossSellingElement);
foreach ($elements as $element) {
$crossSellings->add($element);
}
} else {
$crossSellings->add($crossSellingElement);
}
}
foreach ($event->getCrossSellings() as $crossSelling) {
if ($crossSelling->getCrossSelling()->getType() === 'cogiPropertyCrossSelling') {
$useManufacturer = $this->systemConfigService->get('CogiPropertyCrossSelling.config.useManufacturer', $salesChannelId);
$manufacturerMustMatch = $this->systemConfigService->get('CogiPropertyCrossSelling.config.manufacturerMustMatch', $salesChannelId);
// Get the property filter
$propertyFilter = $this->getPropertyFilter($product, $salesChannelId);
$filter = $propertyFilter->getFilter();
// If manufacturer should be used and is set for product, get filter and set connection accordingly
if ($useManufacturer && ($product->getManufacturerId() || $manufacturerMustMatch)) {
$manufacturerFilter = $this->getManufacturerFilter($product);
$filterConnection = $manufacturerMustMatch ? MultiFilter::CONNECTION_AND : MultiFilter::CONNECTION_OR;
$filter = new MultiFilter($filterConnection, [
$manufacturerFilter->getFilter(),
$propertyFilter->getFilter()
]);
}
// Configure Criteria
$criteria = new Criteria();
$criteria->setLimit($this->systemConfigService->get('CogiPropertyCrossSelling.config.limit', $salesChannelId) ?: 15);
$criteria->addFilter($filter);
// Make sure current product is not shown in cross selling
$criteria->addFilter(new NotFilter(
NotFilter::CONNECTION_AND,
[new EqualsFilter('id', $product->getId())]
));
// Exclude variants of the same product
if ($product->getParentId()) {
$criteria->addFilter(new NotFilter(
NotFilter::CONNECTION_AND,
[new EqualsFilter('parentId', $product->getParentId())]
));
$criteria->addFilter(new NotFilter(
NotFilter::CONNECTION_AND,
[new EqualsFilter('id', $product->getParentId())]
));
}
// Exclude unavailable products if set in config
if ($this->systemConfigService->get('CogiPropertyCrossSelling.config.excludeUnavailable', $salesChannelId)) {
$criteria->addFilter(new ProductCloseoutFilter());
}
// Get products and set for cross selling
$result = $this->salesChannelProductRepository->search($criteria, $event->getSalesChannelContext());
$products = $result->getEntities();
$crossSelling->setProducts($products);
}
}
}
public function onPageLoaded(ProductPageLoadedEvent $event): void
{
$page = $event->getPage();
// For CMS product page cross sellings are loaded via DataResolver
if ($page->getCmsPage()) {
return;
}
$product = $page->getProduct();
$salesChannelId = $event->getSalesChannelContext()->getSalesChannel()->getId();
$autoAdd = false;
$insert = 'after';
if (!count($page->getCrossSellings()) && $this->systemConfigService->get('CogiPropertyCrossSelling.config.useAsDefault', $salesChannelId)) {
$autoAdd = true;
}
if ($this->systemConfigService->get('CogiPropertyCrossSelling.config.useAlways', $salesChannelId)) {
$autoAdd = true;
$insert = $this->systemConfigService->get('CogiPropertyCrossSelling.config.useAlwaysPosition', $salesChannelId) ?: 'after';
}
// Use property cross selling as default if no other cross selling defined
if ($autoAdd) {
$crossSellings = $page->getCrossSellings();
$crossSellingElement = new CrossSellingElement();
$crossSelling = new ProductCrossSellingEntity();
$defaultName = $this->translator->trans('cogiPropertyCrossSelling.defaultCrossSellingName');
$crossSelling->setName($defaultName);
$crossSelling->setTranslated(['name' => $defaultName]);
$crossSelling->setType('cogiPropertyCrossSelling');
$crossSelling->setId(md5('crossSelling' . $product->getId()));
$crossSelling->setActive(true);
$crossSellingElement->setCrossSelling($crossSelling);
$crossSellingElement->setTotal(1);
if ($insert === 'before') {
$elements = $crossSellings->getElements();
$crossSellings->clear();
$crossSellings->add($crossSellingElement);
foreach ($elements as $element) {
$crossSellings->add($element);
}
} else {
$crossSellings->add($crossSellingElement);
}
}
foreach ($page->getCrossSellings() as $crossSelling) {
if ($crossSelling->getCrossSelling()->getType() === 'cogiPropertyCrossSelling') {
$useManufacturer = $this->systemConfigService->get('CogiPropertyCrossSelling.config.useManufacturer', $salesChannelId);
$manufacturerMustMatch = $this->systemConfigService->get('CogiPropertyCrossSelling.config.manufacturerMustMatch', $salesChannelId);
// Get the property filter
$propertyFilter = $this->getPropertyFilter($product, $salesChannelId);
$filter = $propertyFilter->getFilter();
// If manufacturer should be used and is set for product, get filter and set connection accordingly
if ($useManufacturer && ($product->getManufacturerId() || $manufacturerMustMatch)) {
$manufacturerFilter = $this->getManufacturerFilter($product);
$filterConnection = $manufacturerMustMatch ? MultiFilter::CONNECTION_AND : MultiFilter::CONNECTION_OR;
$filter = new MultiFilter($filterConnection, [
$manufacturerFilter->getFilter(),
$propertyFilter->getFilter()
]);
}
// Configure Criteria
$criteria = new Criteria();
$criteria->setLimit($this->systemConfigService->get('CogiPropertyCrossSelling.config.limit', $salesChannelId) ?: 15);
$criteria->addFilter($filter);
// Make sure current product is not shown in cross selling
$criteria->addFilter(new NotFilter(
NotFilter::CONNECTION_AND,
[new EqualsFilter('id', $product->getId())]
));
// Exclude variants of the same product
if ($product->getParentId()) {
$criteria->addFilter(new NotFilter(
NotFilter::CONNECTION_AND,
[new EqualsFilter('parentId', $product->getParentId())]
));
$criteria->addFilter(new NotFilter(
NotFilter::CONNECTION_AND,
[new EqualsFilter('id', $product->getParentId())]
));
}
// Exclude unavailable products if set in config
if ($this->systemConfigService->get('CogiPropertyCrossSelling.config.excludeUnavailable', $salesChannelId)) {
$criteria->addFilter(new ProductCloseoutFilter());
}
// Exclude products from the
if ($this->systemConfigService->get('CogiPropertyCrossSelling.config.onlyProductsFromSameCategory', $salesChannelId)) {
$categoryFilterList = [];
// add a contains filter for each category Id
foreach ($product->getCategoryIds() as $categoryId){
$categoryFilterList[] = new ContainsFilter("categoryIds", $categoryId);
}
$criteria->addFilter(new MultiFilter(
NotFilter::CONNECTION_OR,
$categoryFilterList
));
}
// dd($product->getCategoryIds());
// dd($criteria);
// Get products and set for cross selling
$result = $this->salesChannelProductRepository->search($criteria, $event->getSalesChannelContext());
$products = $result->getEntities();
$crossSelling->setProducts($products);
}
}
}
protected function getManufacturerFilter(SalesChannelProductEntity $product)
{
// If current product has no manufacturer, use NULL for filter
$filter = new EqualsFilter('product.manufacturerId', null);
if (!empty($product->getManufacturerId())) {
$filter = new EqualsAnyFilter('product.manufacturerId', [$product->getManufacturerId()]);
}
return new Filter(
'manufacturer',
!empty($product->getManufacturerId()),
[new EntityAggregation('manufacturer', 'product.manufacturerId', 'product_manufacturer')],
$filter,
[$product->getManufacturerId()]
);
}
protected function getPropertyFilter(SalesChannelProductEntity $product, $salesChannelId)
{
// Get property options to match against
$productProperties = $product->getProperties();
$productOptions = $product->getOptions();
$configProperties = $this->systemConfigService->get('CogiPropertyCrossSelling.config.properties', $salesChannelId);
$ids = [];
// Gather properties
foreach ($productProperties as $productProperty) {
if (in_array($productProperty->getGroupId(), $configProperties, true)) {
$ids[] = $productProperty->getId();
}
}
// Gather options
foreach ($productOptions as $productOption) {
if (in_array($productOption->getGroupId(), $configProperties, true)) {
$ids[] = $productOption->getId();
}
}
// Set the filter
$propertyAggregation = new TermsAggregation('properties', 'product.properties.id');
$optionAggregation = new TermsAggregation('options', 'product.options.id');
$grouped = $this->connection->fetchAllAssociative(
'SELECT LOWER(HEX(property_group_id)) as property_group_id, LOWER(HEX(id)) as id FROM property_group_option WHERE id IN (:ids)',
['ids' => Uuid::fromHexToBytesList($ids)],
['ids' => Connection::PARAM_STR_ARRAY]
);
$grouped = FetchModeHelper::group($grouped);
$filters = [];
foreach ($grouped as $options) {
$options = array_column($options, 'id');
$filters[] = new MultiFilter(
MultiFilter::CONNECTION_OR,
[
new EqualsAnyFilter('product.optionIds', $options),
new EqualsAnyFilter('product.propertyIds', $options),
]
);
}
$op = $this->systemConfigService->get('CogiPropertyCrossSelling.config.propertyLogic', $salesChannelId) ?: 'or';
$operator = MultiFilter::CONNECTION_OR;
if (strtolower($op) === 'and') {
$operator = MultiFilter::CONNECTION_AND;
}
return new Filter(
'properties',
true,
[$propertyAggregation, $optionAggregation],
new MultiFilter($operator, $filters),
$ids,
false
);
}
}