<?php
namespace Cogi\PropertyCrossSelling\Subscriber;
use Doctrine\DBAL\Connection;
use Shopware\Core\Checkout\Cart\LineItem\LineItemCollection;
use Shopware\Core\Checkout\Order\Aggregate\OrderLineItem\OrderLineItemCollection;
use Shopware\Core\Content\Product\Aggregate\ProductCrossSelling\ProductCrossSellingEntity;
use Shopware\Core\Content\Product\ProductCollection;
use Shopware\Core\Content\Product\SalesChannel\CrossSelling\CrossSellingElement;
use Shopware\Core\Content\Product\SalesChannel\CrossSelling\CrossSellingElementCollection;
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\Dbal\CriteriaQueryBuilder;
use Shopware\Core\Framework\DataAbstractionLayer\Doctrine\FetchModeHelper;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepositoryInterface;
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\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\Checkout\Finish\CheckoutFinishPageLoadedEvent;
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;
class CheckoutFinishPageSubscriber implements EventSubscriberInterface
{
/**
* @var SystemConfigService
*/
private $systemConfigService;
/**
* @var SalesChannelRepositoryInterface
*/
private $salesChannelProductRepository;
/**
* @var Connection
*/
private $connection;
/**
* @var Translator
*/
private $translator;
/**
* @var EntityRepositoryInterface
*/
private $propertyGroupOptionRepository;
public function __construct(
Connection $connection,
SalesChannelRepositoryInterface $salesChannelProductRepository,
EntityRepositoryInterface $propertyGroupOptionRepository,
SystemConfigService $systemConfigService,
Translator $translator
)
{
$this->connection = $connection;
$this->salesChannelProductRepository = $salesChannelProductRepository;
$this->propertyGroupOptionRepository = $propertyGroupOptionRepository;
$this->systemConfigService = $systemConfigService;
$this->translator = $translator;
}
public static function getSubscribedEvents(): array
{
return [
CheckoutFinishPageLoadedEvent::class => 'onPageLoaded'
];
}
public function onPageLoaded(CheckoutFinishPageLoadedEvent $event): void
{
if (!$this->systemConfigService->get('CogiCmsFinishPage.config.active')) {
return;
}
$page = $event->getPage();
$order = $page->getOrder();
$lineItems = $order->getLineItems();
$salesChannelId = $event->getSalesChannelContext()->getSalesChannel()->getId();
// Use property cross selling as default if no other cross selling defined
$crossSellings = new CrossSellingElementCollection();
$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' . $order->getId()));
$crossSelling->setActive(true);
$useManufacturer = $this->systemConfigService->get('CogiPropertyCrossSelling.config.useManufacturer', $salesChannelId);
$manufacturerMustMatch = $this->systemConfigService->get('CogiPropertyCrossSelling.config.manufacturerMustMatch', $salesChannelId);
// Get the property filter
$propertyFilter = $this->getPropertyFilter($lineItems, $salesChannelId, $event->getSalesChannelContext());
$filter = $propertyFilter->getFilter();
// If manufacturer should be used and is set for product, get filter and set connection accordingly
if ($useManufacturer && (array_filter(array_map(function($lineItem) { return (($payload = $lineItem->getPayload()) && !empty($payload['manufacturerId'])) ? $payload['manufacturerId'] : null; }, $lineItems->getElements())) || $manufacturerMustMatch)) {
$manufacturerFilter = $this->getManufacturerFilter($lineItems);
$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 EqualsAnyFilter('id', array_values(array_map(function($lineItem) { return $lineItem->getProductId(); },$lineItems->getElements())))]
));
// Exclude variants of the same product
$parentIds = array_values(array_filter(array_map(function($lineItem) { return (($payload = $lineItem->getPayload()) && !empty($payload['parentId'])) ? $payload['parentId'] : null; },$lineItems->getElements())));
if ($parentIds) {
$criteria->addFilter(new MultiFilter(
MultiFilter::CONNECTION_OR,
[
new NotFilter(
NotFilter::CONNECTION_OR,
[new EqualsAnyFilter('parentId', $parentIds)]
),
new EqualsFilter(
'parentId',
null
)
]
));
$criteria->addFilter(new NotFilter(
NotFilter::CONNECTION_AND,
[new EqualsAnyFilter('id', $parentIds)]
));
}
// 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();
$crossSellingElement->setProducts($products);
$crossSellingElement->setCrossSelling($crossSelling);
$crossSellingElement->setTotal(1);
$crossSellings->add($crossSellingElement);
$page->cogiCrossSellings = $crossSellings;
}
protected function getManufacturerFilter(OrderLineItemCollection $lineItems)
{
$manufacturerIds = array_values(array_filter(array_map(function($lineItem) { return (($payload = $lineItem->getPayload()) && !empty($payload['manufacturerId'])) ? $payload['manufacturerId'] : null; }, $lineItems->getElements())));
// If products have no manufacturer, use NULL for filter
$filter = new EqualsFilter('product.manufacturerId', null);
if (!empty($manufacturerIds)) {
$filter = new EqualsAnyFilter('product.manufacturerId', $manufacturerIds);
}
return new Filter(
'manufacturer',
!empty($manufacturerIds),
[new EntityAggregation('manufacturer', 'product.manufacturerId', 'product_manufacturer')],
$filter,
$manufacturerIds
);
}
protected function getPropertyFilter(OrderLineItemCollection $lineItems, $salesChannelId, $salesChannelContext)
{
$ids = [];
foreach ($lineItems as $lineItem) {
$payload = $lineItem->getPayload();
if (!empty($payload['optionIds'])) {
$ids = array_merge($ids, $payload['optionIds']);
}
if (!empty($payload['propertyIds'])) {
$ids = array_merge($ids, $payload['propertyIds']);
}
}
// Filter by property groups defined in config
$criteria = new Criteria($ids);
$result = $this->propertyGroupOptionRepository->search($criteria, $salesChannelContext->getContext());
$configProperties = $this->systemConfigService->get('CogiPropertyCrossSelling.config.properties', $salesChannelId);
$ids = array_filter($ids, function($propertyId) use ($result, $configProperties) {
$property = $result->get($propertyId);
return $property && in_array($property->getGroupId(), $configProperties ?: []);
});
// 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
);
}
}