src/Provider/ProductProvider.php line 725

Open in your IDE?
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Provider;
  4. use App\Constant\SortingFields;
  5. use App\DataDecorator\DataDecoratorInterface;
  6. use App\Module\Catalog\Builder\StorefrontFacetBuilder;
  7. use App\Module\Catalog\Builder\StorefrontFilterBuilder;
  8. use App\Module\Catalog\Elasticsearch\PagedResult;
  9. use App\Module\Catalog\Elasticsearch\Pagination;
  10. use App\Module\Catalog\Elasticsearch\ScriptSort;
  11. use App\Module\Catalog\ValueObject\StorefrontFilter\NumberFilter;
  12. use App\Module\Catalog\ValueObject\StorefrontFilter\RangeFilter;
  13. use App\Module\Catalog\ValueObject\StorefrontFilter\TextFilter;
  14. use App\Store\StoreContext;
  15. use App\CommerceTool\ProductProvider as CtProductProvider;
  16. use App\CommerceTool\CategoryProvider as CTCategoryProvider;
  17. use BestIt\Redis\Storage\StorageClientInterface;
  18. use Denios\Data\Catalog\Category;
  19. use Denios\Data\Product\Product;
  20. use Denios\SharedConstant\Catalog\Facet;
  21. use Denios\SharedConstant\Catalog\SliderFacet;
  22. use Elasticsearch\Client;
  23. use Locale;
  24. use ONGR\ElasticsearchDSL\Aggregation\Bucketing\TermsAggregation;
  25. use ONGR\ElasticsearchDSL\Aggregation\Metric\MaxAggregation;
  26. use ONGR\ElasticsearchDSL\Aggregation\Metric\MinAggregation;
  27. use ONGR\ElasticsearchDSL\Query\Compound\BoolQuery;
  28. use ONGR\ElasticsearchDSL\Query\MatchAllQuery;
  29. use ONGR\ElasticsearchDSL\Query\TermLevel\RangeQuery;
  30. use ONGR\ElasticsearchDSL\Query\TermLevel\TermQuery;
  31. use ONGR\ElasticsearchDSL\Query\TermLevel\TermsQuery;
  32. use ONGR\ElasticsearchDSL\Search;
  33. use ONGR\ElasticsearchDSL\Sort\FieldSort;
  34. use Psr\Log\LoggerInterface;
  35. use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
  36. use Symfony\Contracts\Cache\CacheInterface;
  37. use Throwable;
  38. use Illuminate\Support\Collection;
  39. use Symfony\Component\HttpFoundation\RequestStack;
  40. use Commercetools\Core\Client as CommerceToolClient;
  41. use Commercetools\Client\ApiRequestBuilder;
  42. use App\CommerceTool\ProductSearchProvider;
  43. use TypeError;
  44. use function strtolower;
  45. /**
  46. * Get data from elastic search.
  47. *
  48. * @author Michel Chowanski <michel.chowanski@bestit-online.de>
  49. * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
  50. */
  51. class ProductProvider
  52. {
  53. public const PRICE_SORTING_SCRIPT = "if(doc.containsKey('master.currentPrices.actionPrice.centAmountGross') && doc['master.currentPrices.actionPrice.centAmountGross'].size() !=0){return doc['master.currentPrices.actionPrice.centAmountGross'].value}if(doc.containsKey('master.currentPrices.listPrice.centAmountGross') && doc['master.currentPrices.listPrice.centAmountGross'].size() !=0) { return doc['master.currentPrices.listPrice.centAmountGross'].value} if(doc.containsKey('mastermaster.currentPrices.tierPrice.centAmountGross') && doc['master.currentPrices.tierPrice.centAmountGross'].size() !=0) {return doc['master.currentPrices.tierPrice.centAmountGross'].value} else {return 999999}";
  54. public const KEEP_ORDER_SCRIPT = "if(params.containsKey(doc['master.sku'].value)){return params[doc['master.sku'].value]} else {for (item in doc['variants.sku']) { if(params.containsKey(item)){ return params[item]}} return -999999}";
  55. public const CACHE_PREFIX ="product";
  56. public const CACHE_TTL = 300;
  57. /**
  58. * Items per page.
  59. *
  60. * @var int
  61. */
  62. public const ITEMS_PER_PAGE = 28;
  63. /**
  64. * Amount of terms aggregations to fetch.
  65. *
  66. * @var int
  67. */
  68. public const TERMS_AGGREGATION_SIZE = 100;
  69. /**
  70. * Decorators.
  71. *
  72. * @var iterable|DataDecoratorInterface[]
  73. */
  74. private $dataDecorators;
  75. private StoreContext $storeContext;
  76. private Client $search;
  77. private StorageClientInterface $redis;
  78. private StorefrontFacetBuilder $facetBuilder;
  79. private StorefrontFilterBuilder $filterBuilder;
  80. private CacheInterface $cache;
  81. private CTCategoryProvider $ctCategoryProvider;
  82. private CtProductProvider $ctProductProvider;
  83. private RequestStack $request;
  84. private LoggerInterface $logger;
  85. private ApiRequestBuilder $apiRequestBuilder;
  86. private ProductSearchProvider $productSearchProvider;
  87. /**
  88. * ProductProvider constructor.
  89. *
  90. * @param DataDecoratorInterface[]|iterable $dataDecorators
  91. */
  92. public function __construct(
  93. Client $search,
  94. StorageClientInterface $redis,
  95. StoreContext $storeContext,
  96. iterable $dataDecorators,
  97. StorefrontFacetBuilder $facetBuilder,
  98. StorefrontFilterBuilder $filterBuilder,
  99. CommerceToolClient $commerceToolClient,
  100. RequestStack $request,
  101. TokenStorageInterface $tokenStorage,
  102. CacheInterface $cache,
  103. CTCategoryProvider $ctCategoryProvider,
  104. LoggerInterface $logger,
  105. ApiRequestBuilder $apiRequestBuilder,
  106. ProductSearchProvider $productSearchProvider
  107. ) {
  108. $this->search = $search;
  109. $this->redis = $redis;
  110. $this->storeContext = $storeContext;
  111. $this->dataDecorators = $dataDecorators;
  112. $this->facetBuilder = $facetBuilder;
  113. $this->filterBuilder = $filterBuilder;
  114. $this->ctProductProvider = new CtProductProvider($storeContext, $commerceToolClient, $request, $facetBuilder, $tokenStorage, $cache, $apiRequestBuilder, $productSearchProvider);
  115. $this->ctCategoryProvider = $ctCategoryProvider;
  116. $this->cache = $cache;
  117. $this->request = $request;
  118. $this->logger = $logger;
  119. $this->apiRequestBuilder = $apiRequestBuilder;
  120. $this->productSearchProvider = $productSearchProvider;
  121. }
  122. private function getProductByCategoryForPunchout(
  123. string $categoryId,
  124. int $page = 1,
  125. int $itemsPerPage = self::ITEMS_PER_PAGE,
  126. array $filters = [],
  127. $order = null
  128. ): PagedResult {
  129. $category = new Category($this->ctCategoryProvider->getCategory($categoryId));
  130. $productSearchResult = $this->ctProductProvider->productSearchWithCategory(
  131. $category,
  132. $itemsPerPage,
  133. $page,
  134. $filters,
  135. $order
  136. );
  137. $productSearch = $productSearchResult['products'];
  138. $totalProducts = $productSearchResult['total'];
  139. $facets = $productSearchResult['facets'];
  140. $aggregations = [];
  141. foreach ($category->facets as $facet) {
  142. if ($facet->type === 'selection' && $facets !== null) {
  143. $aggregations[$facet->key]['doc_count_error_upper_bound'] = 0;
  144. $aggregations[$facet->key]['sum_other_doc_count'] = 0;
  145. $aggregations[$facet->key]['buckets'] = [];
  146. $key = array_search($facet->key, array_column($facets, 'facet'));
  147. foreach ($facets[$key]['value']['terms'] as $term) {
  148. array_push(
  149. $aggregations[$facet->key]['buckets'],
  150. ['key' => $term['term'], 'doc_count' => $term['count']]
  151. );
  152. }
  153. }
  154. if ($facet->type === 'slider' && $facets !== null) {
  155. $key = array_search($facet->key, array_column($facets, 'facet'));
  156. $terms = $facets[$key]['value']['terms'];
  157. $aggregations[$facet->key . "_MAX"] = ['value' => floatval($terms[0]['term'])];
  158. $aggregations[$facet->key . "_MIN"] = ['value' => floatval($terms[0]['term'])];
  159. foreach ($terms as $term) {
  160. if (floatval($term['term']) > $aggregations[$facet->key . "_MAX"]['value']) {
  161. $aggregations[$facet->key . "_MAX"]['value'] = floatval($term['term']);
  162. }
  163. if (floatval($term['term']) < $aggregations[$facet->key . "_MIN"]['value']) {
  164. $aggregations[$facet->key . "_MIN"]['value'] = floatval($term['term']);
  165. }
  166. }
  167. }
  168. }
  169. $filters = Collection::make($filters);
  170. if ($filters->isNotEmpty()) {
  171. $filters = $this->filterBuilder->transformFilter($filters);
  172. }
  173. return new PagedResult(
  174. $productSearch,
  175. $this->facetBuilder->buildFacets(
  176. $category,
  177. Collection::make($aggregations),
  178. Collection::make($filters)
  179. )->toArray(),
  180. $this->buildPagination($totalProducts, $itemsPerPage, $page),
  181. $totalProducts
  182. );
  183. }
  184. public function getVariantsCount(
  185. string $categoryId,
  186. int $maxPages = 1,
  187. int $itemsPerPage = 1000,
  188. array $filters = [],
  189. $sort = null
  190. ): int {
  191. $variantsCount = 0;
  192. if ($this->storeContext->isPunchoutStore()) {
  193. //the old versions was very slow and don't set the variant count, so it was allways 0
  194. return $variantsCount;
  195. } else {
  196. for ($page = 1; $page <= $maxPages; $page++) {
  197. $variantsPerPage = $this->getVariantCountForPage(
  198. $categoryId,
  199. $page,
  200. $itemsPerPage,
  201. $filters
  202. );
  203. $variantsCount += $variantsPerPage;
  204. }
  205. return $variantsCount;
  206. }
  207. }
  208. private function buildQueryFromFilter($filter, &$boolQuery, &$queries){
  209. switch (get_class($filter)) {
  210. case RangeFilter::class:
  211. if ($filter->minUserSelected && $filter->maxUserSelected) {
  212. $boolQuery->add(
  213. new RangeQuery(
  214. sprintf('facetAttributes.%s', $filter->getKey()),
  215. [
  216. RangeQuery::GTE => $filter->min,
  217. RangeQuery::LTE => $filter->max,
  218. ]
  219. )
  220. );
  221. }
  222. break;
  223. case TextFilter::class:
  224. if (!isset($queries[$filter->getKey()])) {
  225. $queries[$filter->getKey()] = [];
  226. }
  227. $queries[$filter->getKey()][] =
  228. new TermsQuery(
  229. sprintf('facetAttributes.%s.keyword', $filter->getKey()),
  230. $filter->values
  231. );
  232. break;
  233. case NumberFilter::class:
  234. if (!isset($queries[$filter->getKey()])) {
  235. $queries[$filter->getKey()] = [];
  236. }
  237. $queries[$filter->getKey()][] =
  238. new TermsQuery(
  239. sprintf('facetAttributes.%s', $filter->getKey()),
  240. $filter->values
  241. );
  242. break;
  243. }
  244. }
  245. public function getVariantCountForPage(
  246. string $categoryId,
  247. int $page = 1,
  248. int $itemsPerPage = 1000,
  249. array $filters = [],
  250. $sort = null
  251. ) {
  252. $search = new Search();
  253. $search->setSize($itemsPerPage);
  254. $search->setFrom($itemsPerPage * ($page - 1));
  255. $boolQuery = new BoolQuery();
  256. $boolQuery->add(new MatchAllQuery());
  257. $boolQuery->add(new TermQuery('categories', $categoryId), BoolQuery::FILTER);
  258. $search->addQuery($boolQuery);
  259. $search->setSource(false);
  260. $search->setDocValueFields(['id', 'master.sku', 'variants.sku']);
  261. $unfilteredResponse = $this->search->search([
  262. 'index' => $this->buildRedisPrefix(),
  263. 'body' => $search->toArray(),
  264. ]);
  265. $variantCount = 0;
  266. $items = $unfilteredResponse['hits']['hits'] ?? [];
  267. foreach ($items as $productArray) {
  268. $variantCount = $variantCount + count($productArray['fields']['variants.sku']);
  269. }
  270. $items = [];
  271. $unfilteredResponse = null;
  272. return $variantCount;
  273. }
  274. public function getProductsByCategoryAndSkus(
  275. string $categoryId,
  276. array $skus,
  277. int $page = 1,
  278. int $itemsPerPage = self::ITEMS_PER_PAGE,
  279. array $filters = [],
  280. $sort = null
  281. ) {
  282. $search = new Search();
  283. if ($page && $page > 0 && $itemsPerPage && $itemsPerPage > 0) {
  284. $search->setSize($itemsPerPage);
  285. $search->setFrom($itemsPerPage * ($page - 1));
  286. }
  287. $prefix = implode('_', [
  288. $this->storeContext->getAlias(),
  289. strtolower(Locale::canonicalize($this->storeContext->getLocaleInfo()->getCurrentLocale())),
  290. 'category',
  291. ]);
  292. try {
  293. $category = new Category($this->redis->get($categoryId, $prefix));
  294. } catch (TypeError $exception) {
  295. throw new TypeError('Is not a active Category: '.$categoryId);
  296. } catch (Throwable $exception) {
  297. $this->logger->error($exception->getMessage());
  298. $category = new Category($this->ctCategoryProvider->getCategory($categoryId));
  299. }
  300. $boolQuery = new BoolQuery();
  301. $boolQuery->add(new TermsQuery('key', $skus), BoolQuery::SHOULD);
  302. $boolQuery->add(new TermsQuery('master.sku', $skus), BoolQuery::SHOULD);
  303. $boolQuery->add(new TermsQuery('variants.sku', $skus), BoolQuery::SHOULD);
  304. $search->addQuery($boolQuery);
  305. $boolQuery = new BoolQuery();
  306. $boolQuery->add(new MatchAllQuery());
  307. $boolQuery->add(new TermQuery('categories', $categoryId), BoolQuery::FILTER);
  308. $search->addQuery($boolQuery);
  309. $queries = [];
  310. if ($filters) {
  311. $boolQuery = new BoolQuery();
  312. $filters = Collection::make($filters);
  313. if ($filters->isNotEmpty()) {
  314. $filters = $this->filterBuilder->transformFilter($filters);
  315. [$queries, $boolQuery] = $this->generateFilterQuery($filters, $boolQuery);
  316. foreach ($queries as $innerQueries) {
  317. foreach ($innerQueries as $query) {
  318. $boolQuery->add($query, BoolQuery::FILTER);
  319. }
  320. }
  321. $search->addQuery($boolQuery);
  322. }
  323. /** @var TextFilter|RangeFilter $filter */
  324. foreach ($filters as $filter) {
  325. $this->buildQueryFromFilter($filter, $boolQuery, $queries);
  326. }
  327. }
  328. if ($sort) {
  329. $this->applySort($search, $sort, $skus);
  330. }
  331. foreach ($category->facets as $facet) {
  332. switch ($facet->type) {
  333. case Facet::TYPE_SLIDER:
  334. $search->addAggregation(
  335. new MinAggregation(
  336. $facet->key . SliderFacet::MIN_SUFFIX,
  337. sprintf('facetAttributes.%s', $facet->key)
  338. )
  339. );
  340. $search->addAggregation(
  341. new MaxAggregation(
  342. $facet->key . SliderFacet::MAX_SUFFIX,
  343. sprintf('facetAttributes.%s', $facet->key)
  344. )
  345. );
  346. break;
  347. default:
  348. $termAggregation = new TermsAggregation(
  349. $facet->key,
  350. sprintf('facetAttributes.%s.keyword', $facet->key)
  351. );
  352. $termAggregation->addParameter('size', self::TERMS_AGGREGATION_SIZE);
  353. $search->addAggregation($termAggregation);
  354. }
  355. }
  356. $overwritingAggretations = $this->getPossibleAggregations($search, $boolQuery, $queries);
  357. foreach ($queries as $key => $innerQueries) {
  358. foreach ($innerQueries as $query) {
  359. $boolQuery->add($query, BoolQuery::FILTER);
  360. }
  361. }
  362. $response = $this->search->search([
  363. 'index' => $this->buildRedisPrefix(),
  364. 'body' => $search->toArray(),
  365. ]);
  366. assert(is_array($response));
  367. foreach ($overwritingAggretations as $key => $value) {
  368. $response['aggregations'][$key] = $value;
  369. }
  370. $items = $this->buildProducts($response['hits']['hits'] ?? []);
  371. $variantsCount = 0;
  372. foreach ($items as $product) {
  373. $variantsCount += count($product->variants);
  374. }
  375. return new PagedResult(
  376. $items,
  377. $this->facetBuilder->buildFacets(
  378. $category,
  379. Collection::make($response['aggregations'] ?? []),
  380. Collection::make($filters)
  381. )->toArray(),
  382. $this->buildPagination($response['hits']['total']['value'], $itemsPerPage, $page),
  383. 0,
  384. $variantsCount ?? 0
  385. );
  386. }
  387. /**
  388. * Get products by category.
  389. */
  390. public function getProductsByCategory(
  391. string $categoryId,
  392. int $page = 1,
  393. int $itemsPerPage = self::ITEMS_PER_PAGE,
  394. array $filters = [],
  395. $sort = null
  396. ): PagedResult {
  397. if ($this->storeContext->isPunchoutStore()) {
  398. return $this->getProductByCategoryForPunchout($categoryId, $page, $itemsPerPage, $filters, $sort);
  399. } else {
  400. if ($page === 0) {
  401. $page = 1;
  402. }
  403. $search = new Search();
  404. $boolQuery = new BoolQuery();
  405. $boolQuery->add(new MatchAllQuery());
  406. $boolQuery->add(new TermQuery('categories', $categoryId), BoolQuery::FILTER);
  407. $search->addQuery($boolQuery);
  408. $unfilteredCount = 0;
  409. $variantsCount = 0;
  410. $filters = Collection::make($filters);
  411. if ($filters->isNotEmpty()) {
  412. $filters = $this->filterBuilder->transformFilter($filters);
  413. $unfilteredResponse = $this->search->count([
  414. 'index' => $this->buildRedisPrefix(),
  415. 'body' => $search->toArray(),
  416. ]);
  417. $unfilteredCount = $unfilteredResponse['count'];
  418. }
  419. $search->setSize($itemsPerPage);
  420. $search->setFrom(min($itemsPerPage * ($page - 1), 10000-$itemsPerPage));
  421. $prefix = implode('_', [
  422. $this->storeContext->getAlias(),
  423. strtolower(Locale::canonicalize($this->storeContext->getLocaleInfo()->getCurrentLocale())),
  424. 'category',
  425. ]);
  426. try {
  427. $category = new Category($this->redis->get($categoryId, $prefix));
  428. } catch (TypeError $exception) {
  429. throw new TypeError('Is not a active Category. CatID: '.$categoryId);
  430. } catch (Throwable $exception) {
  431. $this->logger->error($exception->getMessage());
  432. $category = new Category($this->ctCategoryProvider->getCategory($categoryId));
  433. }
  434. // Apply facets
  435. foreach ($category->facets as $facet) {
  436. switch ($facet->type) {
  437. case Facet::TYPE_SLIDER:
  438. if (isset($facet->dataType) && $facet->dataType ==='number') {
  439. $search->addAggregation(
  440. new MinAggregation(
  441. $facet->key . SliderFacet::MIN_SUFFIX,
  442. sprintf('facetAttributes.%s', $facet->key)
  443. )
  444. );
  445. $search->addAggregation(
  446. new MaxAggregation(
  447. $facet->key . SliderFacet::MAX_SUFFIX,
  448. sprintf('facetAttributes.%s', $facet->key)
  449. )
  450. );
  451. }
  452. break;
  453. default:
  454. $facetKeyword = 'facetAttributes.' . $facet->key . '.keyword';
  455. $facetValue = 'facetAttributes.' . $facet->key;
  456. $script =
  457. 'if (doc.containsKey("' . $facetValue . '")) {
  458. try {
  459. if (doc["' . $facetValue . '"] instanceof Number) {
  460. return doc["' . $facetValue . '"]
  461. }
  462. else {
  463. return doc["' . $facetKeyword . '"]
  464. }
  465. } catch(Exception e) {
  466. try {
  467. return doc["' . $facetKeyword . '"]
  468. }
  469. catch(Exception ex) {
  470. return doc["' . $facetValue . '"]
  471. }
  472. }
  473. }';
  474. $termAggregation = new TermsAggregation(
  475. $facet->key,
  476. null,
  477. $script
  478. );
  479. $termAggregation->addParameter('size', self::TERMS_AGGREGATION_SIZE);
  480. $search->addAggregation($termAggregation);
  481. }
  482. }
  483. $queries = [];
  484. $overwritingAggretations = [];
  485. if ($filters->isNotEmpty()) {
  486. /** @var TextFilter|RangeFilter $filter */
  487. foreach ($filters as $filter) {
  488. $this->buildQueryFromFilter($filter, $boolQuery, $queries);
  489. }
  490. /*
  491. * Check the overall filter options for each filter to get options for OR conjunctions in filters.
  492. *
  493. * Example:
  494. * Possible Filters:
  495. * Colour: Green, Blue, Red, White
  496. * Material: Plastic, Metal, Wood
  497. *
  498. * Lets say we want to filter by material (Metal) and color (Blue).
  499. * Before:
  500. * Colour Red, White, Green would not show up although
  501. * there may be red, green or white articles with material metal,
  502. * because they are not metal, blue AND red/green/white.
  503. *
  504. * After:
  505. * Red, White, Green show up, because there are possible metal articles,
  506. * which are blue OR red OR white OR green.
  507. */
  508. $overwritingAggretations = $this->getPossibleAggregations($search, $boolQuery, $queries);
  509. foreach ($queries as $key => $innerQueries) {
  510. foreach ($innerQueries as $query) {
  511. $boolQuery->add($query, BoolQuery::FILTER);
  512. }
  513. }
  514. }
  515. if ($sort) {
  516. $this->applySort($search, $sort, []);
  517. } else {
  518. $search->addSort($this->getSortRankSort('DESC'));
  519. }
  520. $response = $this->search->search([
  521. 'index' => $this->buildRedisPrefix(),
  522. 'body' => $search->toArray(),
  523. ]);
  524. assert(is_array($response));
  525. foreach ($overwritingAggretations as $key => $value) {
  526. $response['aggregations'][$key] = $value;
  527. }
  528. $items = $this->buildProducts($response['hits']['hits'] ?? []);
  529. foreach ($items as $product) {
  530. $variantsCount += count($product->variants);
  531. }
  532. $facets = $this->facetBuilder->buildFacets(
  533. $category,
  534. Collection::make($response['aggregations'] ?? []),
  535. Collection::make($filters)
  536. )->toArray();
  537. return new PagedResult(
  538. $items,
  539. $facets,
  540. $this->buildPagination($response['hits']['total']['value'], $itemsPerPage, $page),
  541. ($unfilteredCount > 0 ? $unfilteredCount : $response['hits']['total']['value']),
  542. $variantsCount
  543. );
  544. }
  545. }
  546. private function generateFilterQuery($filters, $boolQuery)
  547. {
  548. $queries = [];
  549. /** @var TextFilter|RangeFilter $filter */
  550. foreach ($filters as $filter) {
  551. $this->buildQueryFromFilter($filter, $boolQuery, $queries);
  552. }
  553. return [$queries, $boolQuery];
  554. }
  555. private function getPriceSort($order)
  556. {
  557. return new ScriptSort(self::PRICE_SORTING_SCRIPT, 'number', $order);
  558. }
  559. private function getKeepOrderSort($order, $params)
  560. {
  561. return new ScriptSort(self::KEEP_ORDER_SCRIPT, 'number', $order, $params);
  562. }
  563. private function getSortRankSort($order)
  564. {
  565. return new FieldSort('master.sortRank', $order);
  566. }
  567. private function getNewUntilSort($order)
  568. {
  569. return new FieldSort('master.attributes.CS_NEW_UNTIL', $order);
  570. }
  571. protected function getPossibleAggregations(Search $search, BoolQuery $baseQuery, array $queries): array
  572. {
  573. $aggregations = [];
  574. foreach (array_keys($queries) as $key) {
  575. /** @var Search $searchClone */
  576. $searchClone = unserialize(serialize($search));
  577. $query = clone $baseQuery;
  578. $searchClone->addQuery($query);
  579. foreach ($queries as $innerKey => $innerFilters) {
  580. if ($innerKey !== $key) {
  581. foreach ($innerFilters as $innerFilter) {
  582. $query->add($innerFilter, BoolQuery::FILTER);
  583. }
  584. }
  585. }
  586. try{
  587. $response = $this->search->search([
  588. 'index' => $this->buildRedisPrefix(),
  589. 'body' => $searchClone->toArray(),
  590. ]);
  591. } catch (\Exception $e) {
  592. $this->logger->error('Error invalid query: '.json_encode($baseQuery) .' . '.json_encode($queries));
  593. throw $e;
  594. }
  595. assert(is_array($response));
  596. $aggregations[$key] = $response['aggregations'][$key] ?? null;
  597. }
  598. return $aggregations;
  599. }
  600. /**
  601. * @param array $skus
  602. * @param array|null $sort
  603. * @param int|null $page
  604. * @param int|null $itemsPerPage
  605. * @param array|null $filter
  606. * @return array
  607. */
  608. public function getResponseBySkus(array $skus, array $sort = null, int $page = null, int $itemsPerPage = null, array $filter = null): array
  609. {
  610. $search = new Search();
  611. if ($page && $page > 0 && $itemsPerPage && $itemsPerPage > 0) {
  612. $search->setSize($itemsPerPage);
  613. $search->setFrom($itemsPerPage * ($page - 1));
  614. }
  615. $boolQuery = new BoolQuery();
  616. $boolQuery->add(new TermsQuery('key', $skus), BoolQuery::SHOULD);
  617. $boolQuery->add(new TermsQuery('master.sku', $skus), BoolQuery::SHOULD);
  618. $boolQuery->add(new TermsQuery('variants.sku', $skus), BoolQuery::SHOULD);
  619. $search->addQuery($boolQuery);
  620. if ($filter) {
  621. $boolQuery = new BoolQuery();
  622. $filters = Collection::make($filter);
  623. if ($filters->isNotEmpty()) {
  624. $filters = $this->filterBuilder->transformFilter($filters);
  625. [$queries, $boolQuery] = $this->generateFilterQuery($filters, $boolQuery);
  626. foreach ($queries as $innerQueries) {
  627. foreach ($innerQueries as $query) {
  628. $boolQuery->add($query, BoolQuery::FILTER);
  629. }
  630. }
  631. $search->addQuery($boolQuery);
  632. }
  633. }
  634. if ($sort) {
  635. $this->applySort($search, $sort, $skus);
  636. }
  637. $response = $this->search->search([
  638. 'index' => $this->buildRedisPrefix(),
  639. 'body' => $search->toArray(),
  640. ]);
  641. return $response;
  642. }
  643. /**
  644. * @param array $skus
  645. * @param array|null $sort
  646. * @param int|null $page
  647. * @param int|null $itemsPerPage
  648. * @param array|null $filter
  649. * @return array
  650. * @throws \Commercetools\Core\Fixtures\FixtureException
  651. */
  652. public function getProductsBySkus(array $skus, array $sort = null, int $page = null, int $itemsPerPage = null, array $filter = null): array
  653. {
  654. if ($this->storeContext->isPunchoutStore()) {
  655. $products = [];
  656. foreach ($skus as $sku) {
  657. $product = $this->ctProductProvider->getProductBySkuCT($sku);
  658. if ($product !== null) {
  659. array_push($products, $product);
  660. }
  661. }
  662. return $products;
  663. } else {
  664. $response = $this->getResponseBySkus($skus, $sort, $page, $itemsPerPage, $filter);
  665. $hits = $response['hits']['hits'] ?? null;
  666. $products = array_column($hits, '_source');
  667. return array_map(
  668. [$this, 'createProduct'],
  669. $products
  670. );
  671. }
  672. }
  673. public function getProductsAndTotalBySkus(array $skus, array $sort = null, int $page = null, int $itemsPerPage = null, array $filter = null): array
  674. {
  675. if ($this->storeContext->isPunchoutStore()) {
  676. $result = $this->ctProductProvider->productSearchWithFilter($skus, $page, $itemsPerPage, $filter ?? [], $sort);
  677. return [$result["products"], $result["total"]];
  678. } else {
  679. $response = $this->getResponseBySkus($skus, $sort, $page, $itemsPerPage, $filter);
  680. $hits = $response['hits']['hits'] ?? null;
  681. $products = array_column($hits, '_source');
  682. $products = array_map(
  683. [$this, 'createProduct'],
  684. $products
  685. );
  686. return [$products, $response['hits']['total']['value'] ?? 0];
  687. }
  688. }
  689. /**
  690. * @param string $sku
  691. *
  692. * @return Product|null
  693. */
  694. public function getProductBySku(string $sku): ?Product
  695. {
  696. $product = null;
  697. $cacheKey = $this->buildCacheKey($sku);
  698. $cacheItem = $this->cache->getItem($cacheKey);
  699. //cach not wanted
  700. if (true) {
  701. $products = $this->getProductsBySkus([$sku]);
  702. if (count($products) <1 or empty(end($products))) {
  703. return null;
  704. }
  705. $product = array_pop($products);
  706. $cacheItem->set($product);
  707. $cacheItem->expiresAfter(self::CACHE_TTL);
  708. $this->cache->save($cacheItem);
  709. } else {
  710. $product = $cacheItem->get();
  711. if ($product === null) {
  712. $this->cache->delete($cacheKey);
  713. }
  714. }
  715. return $product;
  716. }
  717. /**
  718. * @param string $search
  719. *
  720. * @return Product|null
  721. */
  722. public function getProductsByTextSearch(string $search): ?array
  723. {
  724. $products = $this->ctProductProvider->productSearchWithText($search, 20, 1, []);
  725. if (count($products) >= 1) {
  726. return $products;
  727. }
  728. return [];
  729. }
  730. /**
  731. * Get Product by slug or null
  732. *
  733. * @param string $slug
  734. *
  735. * @return Product|null
  736. */
  737. public function getProductBySlug(string $slug): ?Product
  738. {
  739. $product = null;
  740. $request = $this->request->getCurrentRequest();
  741. $excludingVat = $request->attributes->getBoolean('exclude_vat');
  742. $cacheKey = $this->buildCacheKey($slug.'vat_'.$excludingVat);
  743. $cacheItem = $this->cache->getItem($cacheKey);
  744. if (!$cacheItem->isHit()) {
  745. if ($this->storeContext->isPunchoutStore()) {
  746. $product = $this->ctProductProvider->getProductBySlug($slug);
  747. } else {
  748. $search = new Search();
  749. $search->addQuery(new TermQuery('slug', $slug));
  750. $response = $this->search->search([
  751. 'index' => $this->buildRedisPrefix(),
  752. 'body' => $search->toArray()
  753. ]);
  754. $hits = $response['hits']['hits'] ?? null;
  755. $products = array_map(
  756. [$this, 'createProduct'],
  757. array_column($hits, '_source')
  758. );
  759. if (count($products) < 1 or empty(end($products))) {
  760. return null;
  761. }
  762. /** @var Product $product */
  763. $product = array_pop($products);
  764. }
  765. $cacheItem->set($product);
  766. $cacheItem->expiresAfter(self::CACHE_TTL);
  767. $this->cache->save($cacheItem);
  768. return $product;
  769. } else {
  770. $product = $cacheItem->get();
  771. }
  772. return $product;
  773. }
  774. /**
  775. * Returns product by id from redis.
  776. */
  777. public function getProductById(string $productId): ?Product
  778. {
  779. if ($this->storeContext->isPunchoutStore()) {
  780. $products = $this->getProductsByIds([$productId]);
  781. if (count($products) >= 1) {
  782. return array_pop($products);
  783. }
  784. return null;
  785. } else {
  786. $product = $this->redis->get($productId, $this->buildRedisPrefix());
  787. return $product ? $this->createProduct($product) : null;
  788. }
  789. }
  790. /**
  791. * Returns product SKU-list from redis.
  792. */
  793. public function getCacheDataByCacheKey(string $cacheKey): ?array
  794. {
  795. $dataList = $this->redis->get($cacheKey, 'cache');
  796. return $dataList ?? null;
  797. }
  798. /**
  799. * Save product SKU-list from redis.
  800. */
  801. public function setCacheValuesByChannelId(string $cacheKey, $value): void
  802. {
  803. $this->redis->set($cacheKey, $value, 'cache');
  804. }
  805. /**
  806. * Returns product by id from redis.
  807. */
  808. public function deleteProductSkusByChannelId(string $cacheKey): void
  809. {
  810. $this->redis->delete($cacheKey, 'cache');
  811. }
  812. /**
  813. * ToDo: temporary fix for punchout menu tree; REMOVE after Punchout Replatforming
  814. */
  815. public function deleteCacheValuesByCurrentStoreContext(): void
  816. {
  817. $alias = $this->storeContext->getAlias();
  818. $locale = $this->storeContext->getLocaleInfo()->getCurrentLocale();
  819. $cacheKey = sprintf('%s_%s_%s', self::CACHE_PREFIX, $alias, $locale);
  820. $this->redis->delete($cacheKey);
  821. }
  822. private function getProductByIdsFromCT(array $productIds)
  823. {
  824. $products = [];
  825. foreach ($productIds as $id) {
  826. $product = $this->ctProductProvider->getProductByID($id);
  827. if ($product !== null) {
  828. $products[] = $product;
  829. }
  830. }
  831. return $products;
  832. }
  833. /**
  834. * Returns multiple products by id from redis.
  835. *
  836. * @return array|Product[]
  837. */
  838. public function getProductsByIds(array $productIds): array
  839. {
  840. if ($this->storeContext->isPunchoutStore()) {
  841. return $this->getProductByIdsFromCT($productIds);
  842. } else {
  843. try {
  844. return array_map(
  845. [$this, 'createProduct'],
  846. array_filter($this->redis->getMany($productIds, $this->buildRedisPrefix()))
  847. );
  848. } catch (Throwable $exception) {
  849. $this->logger->error($exception->getMessage());
  850. return $this->getProductByIdsFromCT($productIds);
  851. }
  852. }
  853. }
  854. /**
  855. * @return string
  856. */
  857. private function buildRedisPrefix(): string
  858. {
  859. return implode('_', [
  860. $this->storeContext->getAlias(),
  861. strtolower(Locale::canonicalize($this->storeContext->getLocaleInfo()->getCurrentLocale())),
  862. 'product'
  863. ]);
  864. }
  865. /**
  866. * Create product by source
  867. *
  868. * @param array $source
  869. *
  870. * @return Product
  871. */
  872. public function createProduct(array $source): Product
  873. {
  874. $product = new Product($source);
  875. foreach ($this->dataDecorators as $decorator) {
  876. if ($decorator->supports($product)) {
  877. $decorator->decorate($product);
  878. }
  879. }
  880. return $product;
  881. }
  882. /**
  883. * Build pagination from elastic search results
  884. *
  885. * @param int $totalHits
  886. * @param int $itemsPerPage
  887. * @param int $currentPage
  888. *
  889. * @return Pagination
  890. */
  891. private function buildPagination(int $totalHits, int $itemsPerPage, int $currentPage): Pagination
  892. {
  893. return new Pagination(
  894. $totalHits,
  895. (int)ceil($totalHits / $itemsPerPage),
  896. $itemsPerPage,
  897. $currentPage
  898. );
  899. }
  900. /**
  901. * Build products from elastic search hits
  902. *
  903. * @param array $hits
  904. *
  905. * @return array
  906. */
  907. private function buildProducts(array $hits): array
  908. {
  909. $products = [];
  910. foreach ($hits as $hit) {
  911. $products[] = $this->createProduct($hit['_source']);
  912. $hit = null;
  913. }
  914. return $products;
  915. }
  916. private function buildCacheKey(string $input): string
  917. {
  918. $hash = md5(json_encode($input));
  919. $alias = $this->storeContext->getAlias();
  920. $locale = $this->storeContext->getLocaleInfo()->getCurrentLocale();
  921. return sprintf('%s_%s_%s_%s', self::CACHE_PREFIX, $alias, $locale, $hash);
  922. }
  923. private function applySort(&$search, array $sort, array $skus): void
  924. {
  925. switch ($sort['field']) {
  926. case SortingFields::PRICE:
  927. $search->addSort($this->getPriceSort($sort['order']));
  928. break;
  929. case SortingFields::NEW_UNTIL:
  930. $search->addSort($this->getNewUntilSort($sort['order']));
  931. break;
  932. case SortingFields::SORT_RANK:
  933. $search->addSort($this->getSortRankSort($sort['order']));
  934. break;
  935. case SortingFields::KEEP_ORDER:
  936. $params = [];
  937. foreach ($skus as $key => $sku) {
  938. $params[$sku] = -$key;
  939. }
  940. $search->addSort($this->getKeepOrderSort($sort['order'], $params));
  941. break;
  942. }
  943. }
  944. }