Files
kupshop/bundles/KupShop/CatalogBundle/View/SearchView.php
2025-08-02 16:30:27 +02:00

681 lines
22 KiB
PHP

<?php
namespace KupShop\CatalogBundle\View;
use KupShop\CatalogBundle\Event\CatalogEvent;
use KupShop\CatalogBundle\ProductList\ProductList;
use KupShop\CatalogBundle\Query\Search;
use KupShop\CatalogBundle\Search\Exception\FulltextException;
use KupShop\CatalogBundle\Search\FulltextElastic;
use KupShop\CatalogBundle\Search\FulltextInterface;
use KupShop\ContentBundle\Util\ArticleList;
use KupShop\ContentBundle\Util\MenuUtil;
use KupShop\I18nBundle\Translations\MenuLinksTranslation;
use KupShop\KupShopBundle\Config;
use KupShop\KupShopBundle\Util\Compat\ServiceContainer;
use KupShop\KupShopBundle\Util\Logging\SentryLogger;
use KupShop\KupShopBundle\Views\Traits\RequestTrait;
use KupShop\KupShopBundle\Views\View;
use Query\Operator;
use Query\Product;
use Query\QueryBuilder;
use Query\Translation;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class SearchView extends View
{
use RequestTrait;
protected $template = 'search.tpl';
/** @var string String to search */
protected $searchTerm;
/** @var FulltextElastic */
protected $fulltextSearch;
protected $enabledTopics;
protected $topicsModules = [
'products' => [\Modules::SEARCH],
'articles' => [\Modules::ARTICLES],
'sections' => [\Modules::PRODUCTS_SECTIONS],
'producers' => [\Modules::PRODUCERS],
'pages' => [\Modules::MENU],
];
protected $multiSearchResult = [];
/** @var SentryLogger */
private $sentryLogger;
/** @var ProductList */
private $productList;
private EventDispatcherInterface $eventDispatcher;
public function __construct()
{
$this->proxyCacheEnabled = findModule(\Modules::PROXY_CACHE, 'search', findModule(\Modules::PROXY_CACHE, 'category'));
}
/**
* @required
*/
public function setSentryLogger(SentryLogger $sentryLogger): void
{
$this->sentryLogger = $sentryLogger;
}
/**
* @required
*/
public function setProductList(ProductList $productList): void
{
$this->productList = $productList;
}
/** @required */
public function setEventDispatcher(EventDispatcherInterface $eventDispatcher): void
{
$this->eventDispatcher = $eventDispatcher;
}
public function getTitle()
{
return $this->searchTerm ?? translate('title', 'search');
}
public function setSearchTerm(string $searchTerm): SearchView
{
$this->searchTerm = $searchTerm;
return $this;
}
public function getHeaderVariables()
{
return parent::getHeaderVariables() + ['searchString' => $this->searchTerm];
}
public function getBreadcrumbs()
{
return getReturnNavigation(-1, 'NO_TYPE', [translate('returnNav', 'search')]);
}
public function getBodyVariables()
{
$pageVars = array_merge(parent::getBodyVariables(), [
'title' => translate('h1_title', 'search'),
'search' => $this->searchTerm,
'searching' => 0,
'listShow' => $this->getListShowType(),
]);
// # VYHLEDAVANI V ESHOPU
if ($this->searchTerm) {
$pageVars['searching'] = 1;
$pageVars['results'] = $this->getSearchResults();
}
return $pageVars;
}
/** Search all enabled topics.
* @return array
*/
protected function getSearchResults()
{
$results = [];
// process multi-search
$enabledTopics = array_merge($this->fulltextSearch->getIndexTypes(), ['pages']);
if ($disabled_topics = Config::get()['Modules'][\Modules::SEARCH]['disabled_topics'] ?? null) {
$enabledTopics = array_diff($enabledTopics, $disabled_topics);
}
$this->setEnabledTopics($enabledTopics);
$enabledTypes = [];
foreach ($this->fulltextSearch->getIndexTypes() as $type) {
if ($this->isTopicEnabled($type)) {
$enabledTypes[] = $type;
}
}
$this->fulltextSearch->setDynamicFilters($this->request->get('dynamic_filter', []));
try {
$this->multiSearchResult = $this->fulltextSearch->search(
$this->searchTerm,
$this->getSearchConfig(),
$enabledTypes
);
} catch (FulltextException $e) {
$this->multiSearchResult = [];
$this->sentryLogger->captureException($e);
}
foreach ($enabledTypes as $type) {
switch ($type) {
case FulltextElastic::INDEX_PRODUCTS:
if ($this->fulltextSearch->supportsFilters()) {
$result = $this->searchProductsByIds();
} else {
$result = $this->searchProducts();
}
break;
case FulltextElastic::INDEX_ARTICLES:
$result = $this->searchArticles();
break;
default:
$result = $this->searchDefault($type);
}
$results[$type] = $result;
}
return $results;
}
protected function searchDefault($type)
{
return ['list' => $this->multiSearchResult[$type] ?? []];
}
protected function getSearchConfig(): array
{
$dbcfg = \Settings::getDefault();
$productsPage = (int) getVal('page', null, 1);
$productsNoOnPage = $dbcfg->cat_show_products_search ?: $dbcfg->cat_show_products;
$productsOffset = ceil(($productsPage - 1) * $productsNoOnPage);
$productsOrder = $this->getProductsOrder();
$dynamicFilters = $this->request->get('dynamic_filter', []);
if (!$this->fulltextSearch->supportsFilters() && (!empty($dynamicFilters) || findModule(\Modules::SEARCH, \Modules::SUB_FILTER))) {
$productsNoOnPage = 1000;
$productsOffset = 0;
$productsOrder = null;
}
$config = [];
foreach ($this->fulltextSearch->getIndexTypes() as $type) {
$config[$type] = [
'count' => 20,
'offset' => 0,
];
}
$config[FulltextElastic::INDEX_PRODUCTS] = [
'count' => (int) $productsNoOnPage,
'offset' => (int) $productsOffset,
'order' => $productsOrder,
'exact' => getVal('exact') ? true : false,
];
return $config;
}
protected function isTopicEnabled($topic): bool
{
if (!in_array($topic, $this->enabledTopics)) {
return false;
}
foreach ($this->topicsModules[$topic] ?? [] as $module) {
if (findModule($module)) {
return true;
}
}
return empty($this->topicsModules[$topic]);
}
protected function setEnabledTopics($topics)
{
$this->enabledTopics = $topics;
return $this;
}
protected function getProductList(): ProductList
{
$productList = clone $this->productList;
// TODO: Direct copy from class.Category - split class.Category to View and "FilteredProductList" and use FilteredProductList here
// start
$productList->fetchImages(2);
$productList->fetchProducers();
// Variations in product list
$variations = findModule('products_variations', 'category_variations');
if ($variations) {
$productList->fetchVariations($variations);
}
// Parameters in product list
$parameters = findModule('products_parameters', 'category_params');
if ($parameters) {
$productList->fetchParameters($parameters);
}
// Product sets in product list
if (findModule('products_sets')) {
$productList->fetchSets();
}
// Product sets in product list
if (findModule('reviews', 'show_in_category')) {
$productList->fetchReviews();
}
// end
return $productList;
}
protected function searchProductsByIds()
{
$productList = $this->getProductList();
$products = $this->multiSearchResult[FulltextElastic::INDEX_PRODUCTS] ?? [];
$productList
->andSpec(function (QueryBuilder $qb) use ($products) {
$qb->setParameter('products', array_keys($products), \Doctrine\DBAL\Connection::PARAM_INT_ARRAY)
->andWhere('p.id IN (:products)');
});
$noProducts = $this->fulltextSearch->getRowsCount();
return [
'list' => $productList->getProducts(),
'pager' => $this->getPager($noProducts),
];
}
protected function getPager($noProducts): \Pager
{
$dbcfg = \Settings::getDefault();
$noOnPage = $dbcfg->cat_show_products_search ?: $dbcfg->cat_show_products;
$page = getVal('page', null, 1);
$noPages = ceil($noProducts / $noOnPage);
$param = [
'URL' => 'launch.php',
's' => 'search',
'search' => $this->searchTerm,
'submit' => 'ok',
'so' => 'products',
'ESCAPE' => 'NO',
];
if (isset($_GET['show'])) {
$param['show'] = $_GET['show'];
}
$url = createScriptURL($param);
$dynamic_filter = $this->request->get('dynamic_filter', []);
if (!empty($dynamic_filter)) {
$url .= '&'.http_build_query(['dynamic_filter' => $dynamic_filter]);
}
$pager = new \Pager($noPages, $page);
$pager->setUrl($url);
$pager->setTotal($noProducts, $noOnPage);
if (!isAjax() && $noOnPage == 0 && $page != 1) {
throw new NotFoundHttpException('Page \''.$page.'\' is out of range.');
}
return $pager;
}
protected function searchProducts()
{
$noProducts = null;
$dbcfg = \Settings::getDefault();
$orderBy = getVal('orderby', null, getVal('order_by'));
$orderDir = getVal('orderdir', null, getVal('order_dir'));
$order = (int) getVal('order');
if ($order) {
$orderBy = abs($order);
$orderDir = $order >= 0 ? 1 : 2;
}
// ProductList
$page = getVal('page', null, 1);
$noOnPage = $dbcfg->cat_show_products_search ?: $dbcfg->cat_show_products;
$dynamic_filter = $this->request->get('dynamic_filter', []);
$productList = $this->getProductList();
$filterParams = $productList->applyDefaultFilterParams();
$this->eventDispatcher->dispatch(
new CatalogEvent($productList, $filterParams, CatalogEvent::TYPE_SEARCH)
);
// Show both hidden and not in store products to administrators
if ($dbcfg->prod_show_admin_hidden == 'Y' && getAdminUser()) {
$filterParams->setVisible(null);
$filterParams->setInStore(null);
}
// HACK: Když chce admin vidět ve vyhledávání i skrytý produkty, nepoužiju fulltext ale staré hledání
if (!($dbcfg->prod_show_admin_hidden == 'Y' && getAdminUser())) {
$products = $this->multiSearchResult[FulltextElastic::INDEX_PRODUCTS] ?? [];
if (!empty($dynamic_filter) || findModule(\Modules::SEARCH, \Modules::SUB_FILTER)) {
$filter = new \Filter($filterParams);
$filter->setFilterData($dynamic_filter);
if (!empty($dynamic_filter)) {
$this->setEnabledTopics(['products']);
}
$result['filter'] = $filter;
$filterParams->andSpec(function (QueryBuilder $qb) use ($products) {
$qb->andWhere(Product::productsIds(array_keys($products)));
});
if ($orderBy) {
// Sort by user defined sort
$sortParams = $this->getSortParams($orderBy, $orderDir, $productList);
$productList->orderBy($sortParams['orderby'], $sortParams['orderdir']);
} else {
// Sort by fulltext
$productList->orderBy($this->getOrderBy());
}
$productList
->limit($noOnPage, ceil(($page - 1) * $noOnPage));
$result['list'] = $productList->getProducts($noProducts);
} else {
$noProducts = $this->fulltextSearch->getRowsCount();
if (!$noProducts) {
$result['suggestion'] = $this->fulltextSearch->suggestTerm($this->searchTerm);
if ($result['suggestion']) {
$products = $this->fulltextSearch->searchProducts($result['suggestion'], $noOnPage, ceil(($page - 1) * $noOnPage), $this->getProductsOrder());
$noProducts = $this->fulltextSearch->getRowsCount();
}
}
$productList
->andSpec(function (QueryBuilder $qb) use ($products) {
$qb->setParameter('products', array_keys($products), \Doctrine\DBAL\Connection::PARAM_INT_ARRAY)
->andWhere('p.id IN (:products)');
})
->orderBy('FIELD(p.id, :products)')
->limit($noOnPage);
$result['list'] = $productList->getProducts($noProductsReal);
// Pokud mám vrácených položek z fulltextu jen na jednu stránku, můžu zobrazit reálný počet produktů
// (ponížených například o skryté produkty stále v indexu a podobně)
if ($noProducts < $noOnPage) {
$noProducts = $noProductsReal;
}
}
} else {
// HACK: Když chce admin vidět ve vyhledávání i skrytý produkty, nepoužiju fulltext ale staré hledání
$sortParams = $this->getSortParams($orderBy, $orderDir, $productList);
// Ordering by positions and in_store
if ($dbcfg->prod_subtract_from_store) {
$sortParams['order_start'] = 'IF(p.in_store > 0, COALESCE(p.position, '.POSITION_STANDARD.'), COALESCE(p.position, '.POSITION_STANDARD.') + 10000)';
} else {
$sortParams['order_start'] = 'COALESCE(p.position, '.POSITION_STANDARD.')';
}
$sortParams['orderby'] = "{$sortParams['order_start']}, {$sortParams['orderby']}";
// In store
if (getVal('filter_insupplier') || getVal('inSupplier', $dynamic_filter)) {
$filterParams->setInStore(\Filter::IN_STORE_SUPPLIER);
} elseif (getVal('filter_instore') || getVal('inStore', $dynamic_filter)) {
$filterParams->setInStore(\Filter::IN_STORE);
}
$fields = [
['field' => 'p.title', 'match' => 'both', 'order' => true],
['field' => 'p.short_descr', 'match' => 'both'],
['field' => 'p.code', 'match' => 'left', 'order' => true],
['field' => 'pr.name', 'match' => 'both'],
];
$productList->andSpec(function (QueryBuilder $qb) {
$qb->joinProducersOnProducts();
});
if (is_numeric($this->searchTerm)) {
$fields[] = ['field' => 'p.ean', 'match' => 'numeric', 'order' => true];
if (findModule(\Modules::PRODUCTS_VARIATIONS)) {
$fields[] = ['field' => 'pv.ean', 'match' => 'numeric', 'order' => true];
}
}
if (findModule(\Modules::PRODUCTS_VARIATIONS, 'variationCode')) {
$fields[] = ['field' => 'pv.code', 'match' => 'left', 'order' => true];
}
$productList->andSpec(function (QueryBuilder $qb) use ($sortParams, $dbcfg) {
$qb->addOrderBy($sortParams['orderby'], $sortParams['orderdir']);
if (!($dbcfg->prod_show_admin_hidden == 'Y' && getAdminUser())) {
return Operator::equals(['p.show_in_search' => 'Y']);
}
})->andSpec(Search::searchFields($this->searchTerm, $fields));
$productList->limit($noOnPage, ceil(($page - 1) * $noOnPage));
$result['list'] = $productList->getProducts($noProducts);
}
// #####################################################
if (!empty($dynamic_filter)) {
$result['dynamic_filter'] = $dynamic_filter;
}
$result['pager'] = $this->getPager($noProducts);
return $result;
}
public function getOrderBy(): string
{
return 'FIELD(p.id, :products)';
}
protected function searchPages()
{
$fields = [
['field' => 'ml.name', 'match' => 'both'],
['field' => 'b.content', 'match' => 'both'],
];
$search = get_search_query($this->searchTerm, $fields);
$qb = sqlQueryBuilder()
->select('ml.id')
->from('menu_links', 'ml')
->join('ml', 'blocks', 'b', 'b.id_root = ml.id_block')
->where($search['where'])
->andWhere(Operator::equals(['ml.type' => MenuUtil::TYPE_PAGE]))
->andWhere(Translation::coalesceTranslatedFields(
MenuLinksTranslation::class,
['name', 'url']
))
->andWhere(Operator::Not(Operator::equals(['ml.figure' => 'N'])))
->andWhere(Operator::Not(Operator::equals(['ml.show_in_search' => 'N'])))
->addParameters($search['data'])
->groupBy('ml.id')
->execute();
$result = sqlFetchAll($qb);
foreach ($result as &$page) {
$page['url'] = '/'.$page['url'];
}
return [
'list' => $result,
];
}
protected function searchArticles()
{
$result['list'] = [];
$result['pager'] = [];
$searchResult = $this->multiSearchResult[FulltextElastic::INDEX_ARTICLES] ?? [];
if (!empty($searchResult)) {
$articleIds = array_map(function ($x) {
return $x['id'];
}, $searchResult);
/** @var ArticleList $articleList */
$articleList = ServiceContainer::getService(ArticleList::class);
$result['list'] = $articleList->getArticles(function (\Query\QueryBuilder $qb) use ($articleIds) {
$qb->andWhere(Operator::inIntArray($articleIds, 'a.id'))
->setParameter('articles', $articleIds, \Doctrine\DBAL\Connection::PARAM_INT_ARRAY)
->addOrderBy('FIELD(a.id, :articles)');
});
}
return $result;
}
protected function getListShowType()
{
if (!empty($_COOKIE['cat_show']) && empty($_GET['show'])) {
$_GET['show'] = $_COOKIE['cat_show'];
} else {
if (empty($_GET['show']) || !preg_match('/^[[:digit:]]{1}$/i', $_GET['show'])) {
$_GET['show'] = \Settings::getDefault()->cat_show_style;
} else {
SetCookies('cat_show', $_GET['show'], 86400);
}
}
return $_GET['show'];
}
protected function getProductsOrder(): ?string
{
$orderBy = getVal('orderby', null, getVal('order_by'));
$orderDir = getVal('orderdir', null, getVal('order_dir'));
$order = null;
switch ($orderBy) {
case 1:
$order = 'code';
break;
case 2:
$order = 'title';
break;
case 3:
$order = 'price';
break;
case 5:
$order = 'sold';
break;
}
if ($order && $orderDir == 2) {
$order = '-'.$order;
}
return $order;
}
/**
* @required
*/
public function setFulltextSearch(FulltextInterface $fulltextSearch): SearchView
{
$this->fulltextSearch = $fulltextSearch;
$this->fulltextSearch->setCurlTimeout(10);
return $this;
}
public function getSortParams($orderBy, $orderDir, ProductList $productList): array
{
switch ($orderBy) {
case 1:
$result['orderby'] = 'p.code';
break;
case 2:
$result['orderby'] = 'p.title';
break;
case 3:
$productList->andSpec(function (QueryBuilder $qb) {
$qb->joinVatsOnProducts();
});
$result['orderby'] = 'MIN(COALESCE(pv.price, p.price))*(1+v.vat/100)*(1-p.discount/100)';
break;
case 4:
$result['orderby'] = 'p.date_added';
break;
case 5:
$result['orderby'] = 'p.pieces_sold';
break;
case 6:
$result['orderby'] = 'p.updated';
break;
case 7:
$result['orderby'] = 'p.in_store > 0 DESC';
// if (findModule('products_suppliers'))
// $query['orderby'] .= ", in_store_suppliers DESC";
$result['orderby'] .= ', p.title';
break;
default:
$result['orderby'] = 'p.title';
break;
}
switch ($orderDir) {
case 1:
$result['orderdir'] = ' ASC ';
break;
case 2:
$result['orderdir'] = ' DESC ';
break;
default:
$result['orderdir'] = ' ASC ';
break;
}
return $result;
}
public function getFilters(): array
{
return $this->fulltextSearch->getFilters();
}
public function getCorrectUrl(): ?string
{
$urlCorrect = createScriptURL([
'URL' => 'launch.php',
's' => 'search',
'search' => $this->searchTerm,
]);
return $urlCorrect;
}
}