<?php declare(strict_types=1);
namespace Acris\Filter\Storefront\Subscriber;
use Acris\Filter\Custom\FilterEntity;
use Doctrine\DBAL\Connection;
use Shopware\Core\Content\Category\CategoryCollection;
use Shopware\Core\Content\Category\CategoryEntity;
use Shopware\Core\Content\Category\Tree\Tree;
use Shopware\Core\Content\Category\Tree\TreeItem;
use Shopware\Core\Content\Product\Events\ProductListingCollectFilterEvent;
use Shopware\Core\Content\Product\Events\ProductListingResultEvent;
use Shopware\Core\Content\Product\Events\ProductSearchCriteriaEvent;
use Shopware\Core\Content\Product\Events\ProductSearchResultEvent;
use Shopware\Core\Content\Product\Events\ProductSuggestCriteriaEvent;
use Shopware\Core\Content\Product\Events\ProductSuggestRouteCacheKeyEvent;
use Shopware\Core\Content\Product\SalesChannel\Listing\Filter;
use Shopware\Core\Content\Product\SalesChannel\Listing\ProductListingResult;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepositoryInterface;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Aggregation\Bucket\FilterAggregation;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Aggregation\Metric\EntityAggregation;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Aggregation\Metric\MaxAggregation;
use Shopware\Core\Framework\DataAbstractionLayer\Search\AggregationResult\Metric\EntityResult;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\DataAbstractionLayer\Search\EntitySearchResult;
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\RangeFilter;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Sorting\FieldSorting;
use Shopware\Core\Framework\Struct\ArrayEntity;
use Shopware\Core\Framework\Uuid\Uuid;
use Shopware\Core\System\SalesChannel\SalesChannelContext;
use Shopware\Core\System\SystemConfig\SystemConfigService;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Request;
class FilterSubscriber implements EventSubscriberInterface
{
private const CATEGORY_FILTER_AGGREGATION_TREE = 'acrisCategoryFilterAggregationTree';
/**
* @var Connection
*/
private $connection;
/**
* @var EntityRepositoryInterface
*/
private $optionRepository;
/**
* @var EntityRepositoryInterface
*/
private $filterRepository;
/**
* @var SystemConfigService
*/
private $systemConfigService;
/**
* @var null|array
*/
private $filters;
/**
* @var TreeItem
*/
private $treeItem;
public function __construct(Connection $connection, EntityRepositoryInterface $optionRepository, EntityRepositoryInterface $filterRepository, SystemConfigService $systemConfigService)
{
$this->connection = $connection;
$this->optionRepository = $optionRepository;
$this->filterRepository = $filterRepository;
$this->systemConfigService = $systemConfigService;
$this->filters = null;
$this->treeItem = new TreeItem(null, []);
}
public static function getSubscribedEvents(): array
{
return [
ProductListingResultEvent::class => 'onProductListingSearchResultEvent',
ProductSearchResultEvent::class => 'onProductListingSearchResultEvent',
ProductListingCollectFilterEvent::class => 'onProductListingCollectFilters',
ProductSearchCriteriaEvent::class => [
['onProductSearchCriteria', -100],
],
ProductSuggestCriteriaEvent::class => [
['onProductSuggestCriteria', -100],
],
ProductSuggestRouteCacheKeyEvent::class => [
['onProductSuggestRouteCacheKeyEvent', -200],
]
];
}
public function onProductSuggestRouteCacheKeyEvent(ProductSuggestRouteCacheKeyEvent $event): void
{
if (!empty($event->getRequest()) && !empty($event->getRequest()->get('categories'))) {
$event->setParts([...$event->getParts(), $event->getRequest()->get('categories')]);
}
}
public function onProductListingSearchResultEvent(ProductListingResultEvent $event): void
{
/** @var EntitySearchResult $filterResult */
$filterResult = $this->filterRepository->search((new Criteria())->addSorting(new FieldSorting('position', FieldSorting::ASCENDING))->addFilter(new EqualsFilter('active', true)), $event->getContext());
$this->sortFilterResult($filterResult);
if ($filterResult->getTotal() > 0 && $filterResult->first()) {
$event->getResult()->addExtension('acrisFilter', new ArrayEntity([
'sortedFilters' => $filterResult->getEntities()->getElements()
]));
}
$this->buildCategoryTree($event->getResult(), $event->getSalesChannelContext());
}
public function onProductListingCollectFilters(ProductListingCollectFilterEvent $event)
{
$acrisFilters = $this->filterRepository->search(new Criteria(), $event->getContext())->getElements();
$filters = $event->getFilters();
$request = $event->getRequest();
$isSearchPage = $this->isSearchPage($request);
/** @var FilterEntity $acrisFilter */
foreach ($acrisFilters as $acrisFilter) {
if ($acrisFilter->isActive() === true) {
switch ($acrisFilter->getIdentifier()) {
case 'properties':
$propertiesFilter = $filters->get('properties');
if ($propertiesFilter instanceof Filter) {
$filters->add($this->getPropertyRangeFilter($request, $event->getContext(), $propertiesFilter));
}
break;
case 'availability':
$filters->add($this->getAvailabilityFilter($request));
break;
case 'categories':
if ($isSearchPage === true) {
$filters->add($this->getCategoriesFilter($request));
}
break;
}
} else {
if ($filters->has($acrisFilter->getIdentifier())) {
$filters->remove($acrisFilter->getIdentifier());
}
}
}
}
private function getPropertyRangeFilter(Request $request, Context $context, Filter $propertiesFilter): Filter
{
$min = $request->get('min-property');
$max = $request->get('max-property');
$ranges = [];
if (!$min && !$max) {
return $propertiesFilter;
}
if ($min !== NULL) {
foreach ($min as $minKey => $minValue) {
$ranges[$minKey][RangeFilter::GTE] = floatval($minValue);
}
}
if ($max !== NULL) {
foreach ($max as $maxKey => $maxValue) {
$ranges[$maxKey][RangeFilter::LTE] = floatval($maxValue);
}
}
$grouped = [];
$ids = [];
foreach ($ranges as $groupId => $range) {
$optionIds = $this->optionRepository->searchIds(
(new Criteria())->addFilter(new MultiFilter(MultiFilter::CONNECTION_AND, [
new RangeFilter('customFields.acris_filter_numeric', $ranges[$groupId]),
new EqualsFilter('groupId', $groupId)
])
), $context)->getIds();
if (!empty($optionIds)) {
$grouped[$groupId] = $optionIds;
} else {
$grouped[$groupId] = [Uuid::randomHex()];
}
$ids = array_merge($optionIds, $ids);
}
// if no options were found no products should be shown
if (empty($ids) === true) {
$ids = [Uuid::randomHex()];
}
$ids = array_merge($propertiesFilter->getValues(), $ids);
$filters = [$propertiesFilter->getFilter()];
foreach ($grouped as $optionIds) {
$filters[] = new MultiFilter(
MultiFilter::CONNECTION_OR,
[
new EqualsAnyFilter('product.optionIds', $optionIds),
new EqualsAnyFilter('product.propertyIds', $optionIds),
]
);
}
return new Filter(
'properties',
true,
$propertiesFilter->getAggregations(),
new MultiFilter(MultiFilter::CONNECTION_AND, $filters),
$ids,
false
);
}
private function getAvailabilityFilter(Request $request): Filter
{
$filtered = (bool)$request->get('availability', false);
$ranges = [RangeFilter::GT => 0];
return new Filter(
'availability',
$filtered === true,
[
new FilterAggregation(
'availability-filter',
new MaxAggregation('availability', 'product.availableStock'),
[new RangeFilter('product.availableStock', $ranges)]
),
],
new RangeFilter('product.availableStock', $ranges),
$filtered
);
}
public function onProductSearchCriteria(ProductSearchCriteriaEvent $event): void
{
$criteria = $event->getCriteria();
if (empty($criteria) || empty($criteria->getTitle()) || $criteria->getTitle() !== "search-page" || empty($criteria->getAggregations())) return;
if (!$this->systemConfigService->get('AcrisFilterCS.config.loadPropertyFilterAtSearchPage', $event->getSalesChannelContext()->getSalesChannel()->getId())) return;
$aggregations = $criteria->getAggregations();
if (is_array($aggregations) && array_key_exists('properties', $aggregations) && !empty($aggregations['properties'])) {
unset($aggregations['properties']);
$criteria->resetAggregations();
if (!empty($aggregations)) {
foreach ($aggregations as $aggregation) {
$criteria->addAggregation($aggregation);
}
}
}
}
public function onProductSuggestCriteria(ProductSuggestCriteriaEvent $event): void
{
if (empty($event) || empty($event->getRequest()) || empty($event->getRequest()->query) || !$event->getRequest()->query->has('categories') || empty($event->getRequest()->query->get('categories'))) return;
$event->getCriteria()->addFilter(new EqualsFilter('categoriesRo.id', $event->getRequest()->query->get('categories')));
}
private function getCategoriesFilter(Request $request): Filter
{
$ids = $request->query->get('categories', '');
if ($request->isMethod(Request::METHOD_POST)) {
$ids = $request->request->get('categories', '');
}
if (!empty($ids) && is_string($ids)) {
$ids = explode('|', $ids);
}
if (empty($ids) === true || is_array($ids) === false) {
$ids = [];
}
return new Filter(
'categories',
!empty($ids),
[new EntityAggregation('categories', 'product.categoriesRo.id', 'category')],
new EqualsAnyFilter('product.categoriesRo.id', $ids),
$ids
);
}
private function isSearchPage(Request $request): bool
{
return $request->attributes->get('_route') === 'frontend.search.page' || $request->attributes->get('_route') === 'widgets.search.filter' || $request->attributes->get('_route') === 'widgets.search.pagelet.v2';
}
private function buildCategoryTree(ProductListingResult $productListingResult, SalesChannelContext $context): void
{
$categoryAggregation = $productListingResult->getAggregations()->get('categories');
if (!$categoryAggregation instanceof EntityResult || $categoryAggregation->getEntities()->count() === 0) {
return;
}
$categoryCollection = $categoryAggregation->getEntities();
if (!$categoryCollection instanceof CategoryCollection) {
return;
}
$clonedCategoryConnection = clone $categoryCollection;
$tree = $this->loadTree(null, $clonedCategoryConnection, $context);
$categoryAggregation->addExtension(self::CATEGORY_FILTER_AGGREGATION_TREE, new Tree(null, $tree));
}
/**
* Copied and modified from Core/Content/Category/Service/NavigationLoader.php
*
* @param CategoryEntity[] $categories
*
* @return TreeItem[]
*/
private function buildTree(?string $parentId, CategoryCollection $categories): array
{
$children = new CategoryCollection();
foreach ($categories->getElements() as $category) {
if ($category->getParentId() !== $parentId) {
continue;
}
$categories->remove($category->getId());
$children->add($category);
}
$children->sortByPosition();
$items = [];
foreach ($children as $child) {
if (!$child->getActive() || !$child->getVisible()) {
continue;
}
$item = clone $this->treeItem;
$item->setCategory($child);
$item->setChildren(
$this->buildTree($child->getId(), $categories)
);
$items[$child->getId()] = $item;
}
return $items;
}
private function loadTree(?string $parentId, CategoryCollection $categories, SalesChannelContext $context): array
{
$tree = $this->buildTree($parentId, $categories);
if (!empty($tree)) {
foreach ($tree as $key => $category) {
if ($key !== $context->getSalesChannel()->getNavigationCategoryId()) unset($tree[$key]);
}
}
return $tree;
}
private function sortFilterResult(EntitySearchResult $filterResult): void
{
$filterResult->getEntities()->sort(function (FilterEntity $a, FilterEntity $b) {
return $a->getPosition() <=> $b->getPosition();
});
}
}