<?php
declare(strict_types=1);
namespace App\Provider;
use App\Constant\SortingFields;
use App\DataDecorator\DataDecoratorInterface;
use App\Module\Catalog\Builder\StorefrontFacetBuilder;
use App\Module\Catalog\Builder\StorefrontFilterBuilder;
use App\Module\Catalog\Elasticsearch\PagedResult;
use App\Module\Catalog\Elasticsearch\Pagination;
use App\Module\Catalog\Elasticsearch\ScriptSort;
use App\Module\Catalog\ValueObject\StorefrontFilter\NumberFilter;
use App\Module\Catalog\ValueObject\StorefrontFilter\RangeFilter;
use App\Module\Catalog\ValueObject\StorefrontFilter\TextFilter;
use App\Store\StoreContext;
use App\CommerceTool\ProductProvider as CtProductProvider;
use App\CommerceTool\CategoryProvider as CTCategoryProvider;
use BestIt\Redis\Storage\StorageClientInterface;
use Denios\Data\Catalog\Category;
use Denios\Data\Product\Product;
use Denios\SharedConstant\Catalog\Facet;
use Denios\SharedConstant\Catalog\SliderFacet;
use Elasticsearch\Client;
use Locale;
use ONGR\ElasticsearchDSL\Aggregation\Bucketing\TermsAggregation;
use ONGR\ElasticsearchDSL\Aggregation\Metric\MaxAggregation;
use ONGR\ElasticsearchDSL\Aggregation\Metric\MinAggregation;
use ONGR\ElasticsearchDSL\Query\Compound\BoolQuery;
use ONGR\ElasticsearchDSL\Query\MatchAllQuery;
use ONGR\ElasticsearchDSL\Query\TermLevel\RangeQuery;
use ONGR\ElasticsearchDSL\Query\TermLevel\TermQuery;
use ONGR\ElasticsearchDSL\Query\TermLevel\TermsQuery;
use ONGR\ElasticsearchDSL\Search;
use ONGR\ElasticsearchDSL\Sort\FieldSort;
use Psr\Log\LoggerInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Throwable;
use Illuminate\Support\Collection;
use Symfony\Component\HttpFoundation\RequestStack;
use Commercetools\Core\Client as CommerceToolClient;
use Commercetools\Client\ApiRequestBuilder;
use App\CommerceTool\ProductSearchProvider;
use TypeError;
use function strtolower;
/**
* Get data from elastic search.
*
* @author Michel Chowanski <michel.chowanski@bestit-online.de>
* @SuppressWarnings(PHPMD.CouplingBetweenObjects)
*/
class ProductProvider
{
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}";
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}";
public const CACHE_PREFIX ="product";
public const CACHE_TTL = 300;
/**
* Items per page.
*
* @var int
*/
public const ITEMS_PER_PAGE = 28;
/**
* Amount of terms aggregations to fetch.
*
* @var int
*/
public const TERMS_AGGREGATION_SIZE = 100;
/**
* Decorators.
*
* @var iterable|DataDecoratorInterface[]
*/
private $dataDecorators;
private StoreContext $storeContext;
private Client $search;
private StorageClientInterface $redis;
private StorefrontFacetBuilder $facetBuilder;
private StorefrontFilterBuilder $filterBuilder;
private CacheInterface $cache;
private CTCategoryProvider $ctCategoryProvider;
private CtProductProvider $ctProductProvider;
private RequestStack $request;
private LoggerInterface $logger;
private ApiRequestBuilder $apiRequestBuilder;
private ProductSearchProvider $productSearchProvider;
/**
* ProductProvider constructor.
*
* @param DataDecoratorInterface[]|iterable $dataDecorators
*/
public function __construct(
Client $search,
StorageClientInterface $redis,
StoreContext $storeContext,
iterable $dataDecorators,
StorefrontFacetBuilder $facetBuilder,
StorefrontFilterBuilder $filterBuilder,
CommerceToolClient $commerceToolClient,
RequestStack $request,
TokenStorageInterface $tokenStorage,
CacheInterface $cache,
CTCategoryProvider $ctCategoryProvider,
LoggerInterface $logger,
ApiRequestBuilder $apiRequestBuilder,
ProductSearchProvider $productSearchProvider
) {
$this->search = $search;
$this->redis = $redis;
$this->storeContext = $storeContext;
$this->dataDecorators = $dataDecorators;
$this->facetBuilder = $facetBuilder;
$this->filterBuilder = $filterBuilder;
$this->ctProductProvider = new CtProductProvider($storeContext, $commerceToolClient, $request, $facetBuilder, $tokenStorage, $cache, $apiRequestBuilder, $productSearchProvider);
$this->ctCategoryProvider = $ctCategoryProvider;
$this->cache = $cache;
$this->request = $request;
$this->logger = $logger;
$this->apiRequestBuilder = $apiRequestBuilder;
$this->productSearchProvider = $productSearchProvider;
}
private function getProductByCategoryForPunchout(
string $categoryId,
int $page = 1,
int $itemsPerPage = self::ITEMS_PER_PAGE,
array $filters = [],
$order = null
): PagedResult {
$category = new Category($this->ctCategoryProvider->getCategory($categoryId));
$productSearchResult = $this->ctProductProvider->productSearchWithCategory(
$category,
$itemsPerPage,
$page,
$filters,
$order
);
$productSearch = $productSearchResult['products'];
$totalProducts = $productSearchResult['total'];
$facets = $productSearchResult['facets'];
$aggregations = [];
foreach ($category->facets as $facet) {
if ($facet->type === 'selection' && $facets !== null) {
$aggregations[$facet->key]['doc_count_error_upper_bound'] = 0;
$aggregations[$facet->key]['sum_other_doc_count'] = 0;
$aggregations[$facet->key]['buckets'] = [];
$key = array_search($facet->key, array_column($facets, 'facet'));
foreach ($facets[$key]['value']['terms'] as $term) {
array_push(
$aggregations[$facet->key]['buckets'],
['key' => $term['term'], 'doc_count' => $term['count']]
);
}
}
if ($facet->type === 'slider' && $facets !== null) {
$key = array_search($facet->key, array_column($facets, 'facet'));
$terms = $facets[$key]['value']['terms'];
$aggregations[$facet->key . "_MAX"] = ['value' => floatval($terms[0]['term'])];
$aggregations[$facet->key . "_MIN"] = ['value' => floatval($terms[0]['term'])];
foreach ($terms as $term) {
if (floatval($term['term']) > $aggregations[$facet->key . "_MAX"]['value']) {
$aggregations[$facet->key . "_MAX"]['value'] = floatval($term['term']);
}
if (floatval($term['term']) < $aggregations[$facet->key . "_MIN"]['value']) {
$aggregations[$facet->key . "_MIN"]['value'] = floatval($term['term']);
}
}
}
}
$filters = Collection::make($filters);
if ($filters->isNotEmpty()) {
$filters = $this->filterBuilder->transformFilter($filters);
}
return new PagedResult(
$productSearch,
$this->facetBuilder->buildFacets(
$category,
Collection::make($aggregations),
Collection::make($filters)
)->toArray(),
$this->buildPagination($totalProducts, $itemsPerPage, $page),
$totalProducts
);
}
public function getVariantsCount(
string $categoryId,
int $maxPages = 1,
int $itemsPerPage = 1000,
array $filters = [],
$sort = null
): int {
$variantsCount = 0;
if ($this->storeContext->isPunchoutStore()) {
//the old versions was very slow and don't set the variant count, so it was allways 0
return $variantsCount;
} else {
for ($page = 1; $page <= $maxPages; $page++) {
$variantsPerPage = $this->getVariantCountForPage(
$categoryId,
$page,
$itemsPerPage,
$filters
);
$variantsCount += $variantsPerPage;
}
return $variantsCount;
}
}
private function buildQueryFromFilter($filter, &$boolQuery, &$queries){
switch (get_class($filter)) {
case RangeFilter::class:
if ($filter->minUserSelected && $filter->maxUserSelected) {
$boolQuery->add(
new RangeQuery(
sprintf('facetAttributes.%s', $filter->getKey()),
[
RangeQuery::GTE => $filter->min,
RangeQuery::LTE => $filter->max,
]
)
);
}
break;
case TextFilter::class:
if (!isset($queries[$filter->getKey()])) {
$queries[$filter->getKey()] = [];
}
$queries[$filter->getKey()][] =
new TermsQuery(
sprintf('facetAttributes.%s.keyword', $filter->getKey()),
$filter->values
);
break;
case NumberFilter::class:
if (!isset($queries[$filter->getKey()])) {
$queries[$filter->getKey()] = [];
}
$queries[$filter->getKey()][] =
new TermsQuery(
sprintf('facetAttributes.%s', $filter->getKey()),
$filter->values
);
break;
}
}
public function getVariantCountForPage(
string $categoryId,
int $page = 1,
int $itemsPerPage = 1000,
array $filters = [],
$sort = null
) {
$search = new Search();
$search->setSize($itemsPerPage);
$search->setFrom($itemsPerPage * ($page - 1));
$boolQuery = new BoolQuery();
$boolQuery->add(new MatchAllQuery());
$boolQuery->add(new TermQuery('categories', $categoryId), BoolQuery::FILTER);
$search->addQuery($boolQuery);
$search->setSource(false);
$search->setDocValueFields(['id', 'master.sku', 'variants.sku']);
$unfilteredResponse = $this->search->search([
'index' => $this->buildRedisPrefix(),
'body' => $search->toArray(),
]);
$variantCount = 0;
$items = $unfilteredResponse['hits']['hits'] ?? [];
foreach ($items as $productArray) {
$variantCount = $variantCount + count($productArray['fields']['variants.sku']);
}
$items = [];
$unfilteredResponse = null;
return $variantCount;
}
public function getProductsByCategoryAndSkus(
string $categoryId,
array $skus,
int $page = 1,
int $itemsPerPage = self::ITEMS_PER_PAGE,
array $filters = [],
$sort = null
) {
$search = new Search();
if ($page && $page > 0 && $itemsPerPage && $itemsPerPage > 0) {
$search->setSize($itemsPerPage);
$search->setFrom($itemsPerPage * ($page - 1));
}
$prefix = implode('_', [
$this->storeContext->getAlias(),
strtolower(Locale::canonicalize($this->storeContext->getLocaleInfo()->getCurrentLocale())),
'category',
]);
try {
$category = new Category($this->redis->get($categoryId, $prefix));
} catch (TypeError $exception) {
throw new TypeError('Is not a active Category: '.$categoryId);
} catch (Throwable $exception) {
$this->logger->error($exception->getMessage());
$category = new Category($this->ctCategoryProvider->getCategory($categoryId));
}
$boolQuery = new BoolQuery();
$boolQuery->add(new TermsQuery('key', $skus), BoolQuery::SHOULD);
$boolQuery->add(new TermsQuery('master.sku', $skus), BoolQuery::SHOULD);
$boolQuery->add(new TermsQuery('variants.sku', $skus), BoolQuery::SHOULD);
$search->addQuery($boolQuery);
$boolQuery = new BoolQuery();
$boolQuery->add(new MatchAllQuery());
$boolQuery->add(new TermQuery('categories', $categoryId), BoolQuery::FILTER);
$search->addQuery($boolQuery);
$queries = [];
if ($filters) {
$boolQuery = new BoolQuery();
$filters = Collection::make($filters);
if ($filters->isNotEmpty()) {
$filters = $this->filterBuilder->transformFilter($filters);
[$queries, $boolQuery] = $this->generateFilterQuery($filters, $boolQuery);
foreach ($queries as $innerQueries) {
foreach ($innerQueries as $query) {
$boolQuery->add($query, BoolQuery::FILTER);
}
}
$search->addQuery($boolQuery);
}
/** @var TextFilter|RangeFilter $filter */
foreach ($filters as $filter) {
$this->buildQueryFromFilter($filter, $boolQuery, $queries);
}
}
if ($sort) {
$this->applySort($search, $sort, $skus);
}
foreach ($category->facets as $facet) {
switch ($facet->type) {
case Facet::TYPE_SLIDER:
$search->addAggregation(
new MinAggregation(
$facet->key . SliderFacet::MIN_SUFFIX,
sprintf('facetAttributes.%s', $facet->key)
)
);
$search->addAggregation(
new MaxAggregation(
$facet->key . SliderFacet::MAX_SUFFIX,
sprintf('facetAttributes.%s', $facet->key)
)
);
break;
default:
$termAggregation = new TermsAggregation(
$facet->key,
sprintf('facetAttributes.%s.keyword', $facet->key)
);
$termAggregation->addParameter('size', self::TERMS_AGGREGATION_SIZE);
$search->addAggregation($termAggregation);
}
}
$overwritingAggretations = $this->getPossibleAggregations($search, $boolQuery, $queries);
foreach ($queries as $key => $innerQueries) {
foreach ($innerQueries as $query) {
$boolQuery->add($query, BoolQuery::FILTER);
}
}
$response = $this->search->search([
'index' => $this->buildRedisPrefix(),
'body' => $search->toArray(),
]);
assert(is_array($response));
foreach ($overwritingAggretations as $key => $value) {
$response['aggregations'][$key] = $value;
}
$items = $this->buildProducts($response['hits']['hits'] ?? []);
$variantsCount = 0;
foreach ($items as $product) {
$variantsCount += count($product->variants);
}
return new PagedResult(
$items,
$this->facetBuilder->buildFacets(
$category,
Collection::make($response['aggregations'] ?? []),
Collection::make($filters)
)->toArray(),
$this->buildPagination($response['hits']['total']['value'], $itemsPerPage, $page),
0,
$variantsCount ?? 0
);
}
/**
* Get products by category.
*/
public function getProductsByCategory(
string $categoryId,
int $page = 1,
int $itemsPerPage = self::ITEMS_PER_PAGE,
array $filters = [],
$sort = null
): PagedResult {
if ($this->storeContext->isPunchoutStore()) {
return $this->getProductByCategoryForPunchout($categoryId, $page, $itemsPerPage, $filters, $sort);
} else {
if ($page === 0) {
$page = 1;
}
$search = new Search();
$boolQuery = new BoolQuery();
$boolQuery->add(new MatchAllQuery());
$boolQuery->add(new TermQuery('categories', $categoryId), BoolQuery::FILTER);
$search->addQuery($boolQuery);
$unfilteredCount = 0;
$variantsCount = 0;
$filters = Collection::make($filters);
if ($filters->isNotEmpty()) {
$filters = $this->filterBuilder->transformFilter($filters);
$unfilteredResponse = $this->search->count([
'index' => $this->buildRedisPrefix(),
'body' => $search->toArray(),
]);
$unfilteredCount = $unfilteredResponse['count'];
}
$search->setSize($itemsPerPage);
$search->setFrom(min($itemsPerPage * ($page - 1), 10000-$itemsPerPage));
$prefix = implode('_', [
$this->storeContext->getAlias(),
strtolower(Locale::canonicalize($this->storeContext->getLocaleInfo()->getCurrentLocale())),
'category',
]);
try {
$category = new Category($this->redis->get($categoryId, $prefix));
} catch (TypeError $exception) {
throw new TypeError('Is not a active Category. CatID: '.$categoryId);
} catch (Throwable $exception) {
$this->logger->error($exception->getMessage());
$category = new Category($this->ctCategoryProvider->getCategory($categoryId));
}
// Apply facets
foreach ($category->facets as $facet) {
switch ($facet->type) {
case Facet::TYPE_SLIDER:
if (isset($facet->dataType) && $facet->dataType ==='number') {
$search->addAggregation(
new MinAggregation(
$facet->key . SliderFacet::MIN_SUFFIX,
sprintf('facetAttributes.%s', $facet->key)
)
);
$search->addAggregation(
new MaxAggregation(
$facet->key . SliderFacet::MAX_SUFFIX,
sprintf('facetAttributes.%s', $facet->key)
)
);
}
break;
default:
$facetKeyword = 'facetAttributes.' . $facet->key . '.keyword';
$facetValue = 'facetAttributes.' . $facet->key;
$script =
'if (doc.containsKey("' . $facetValue . '")) {
try {
if (doc["' . $facetValue . '"] instanceof Number) {
return doc["' . $facetValue . '"]
}
else {
return doc["' . $facetKeyword . '"]
}
} catch(Exception e) {
try {
return doc["' . $facetKeyword . '"]
}
catch(Exception ex) {
return doc["' . $facetValue . '"]
}
}
}';
$termAggregation = new TermsAggregation(
$facet->key,
null,
$script
);
$termAggregation->addParameter('size', self::TERMS_AGGREGATION_SIZE);
$search->addAggregation($termAggregation);
}
}
$queries = [];
$overwritingAggretations = [];
if ($filters->isNotEmpty()) {
/** @var TextFilter|RangeFilter $filter */
foreach ($filters as $filter) {
$this->buildQueryFromFilter($filter, $boolQuery, $queries);
}
/*
* Check the overall filter options for each filter to get options for OR conjunctions in filters.
*
* Example:
* Possible Filters:
* Colour: Green, Blue, Red, White
* Material: Plastic, Metal, Wood
*
* Lets say we want to filter by material (Metal) and color (Blue).
* Before:
* Colour Red, White, Green would not show up although
* there may be red, green or white articles with material metal,
* because they are not metal, blue AND red/green/white.
*
* After:
* Red, White, Green show up, because there are possible metal articles,
* which are blue OR red OR white OR green.
*/
$overwritingAggretations = $this->getPossibleAggregations($search, $boolQuery, $queries);
foreach ($queries as $key => $innerQueries) {
foreach ($innerQueries as $query) {
$boolQuery->add($query, BoolQuery::FILTER);
}
}
}
if ($sort) {
$this->applySort($search, $sort, []);
} else {
$search->addSort($this->getSortRankSort('DESC'));
}
$response = $this->search->search([
'index' => $this->buildRedisPrefix(),
'body' => $search->toArray(),
]);
assert(is_array($response));
foreach ($overwritingAggretations as $key => $value) {
$response['aggregations'][$key] = $value;
}
$items = $this->buildProducts($response['hits']['hits'] ?? []);
foreach ($items as $product) {
$variantsCount += count($product->variants);
}
$facets = $this->facetBuilder->buildFacets(
$category,
Collection::make($response['aggregations'] ?? []),
Collection::make($filters)
)->toArray();
return new PagedResult(
$items,
$facets,
$this->buildPagination($response['hits']['total']['value'], $itemsPerPage, $page),
($unfilteredCount > 0 ? $unfilteredCount : $response['hits']['total']['value']),
$variantsCount
);
}
}
private function generateFilterQuery($filters, $boolQuery)
{
$queries = [];
/** @var TextFilter|RangeFilter $filter */
foreach ($filters as $filter) {
$this->buildQueryFromFilter($filter, $boolQuery, $queries);
}
return [$queries, $boolQuery];
}
private function getPriceSort($order)
{
return new ScriptSort(self::PRICE_SORTING_SCRIPT, 'number', $order);
}
private function getKeepOrderSort($order, $params)
{
return new ScriptSort(self::KEEP_ORDER_SCRIPT, 'number', $order, $params);
}
private function getSortRankSort($order)
{
return new FieldSort('master.sortRank', $order);
}
private function getNewUntilSort($order)
{
return new FieldSort('master.attributes.CS_NEW_UNTIL', $order);
}
protected function getPossibleAggregations(Search $search, BoolQuery $baseQuery, array $queries): array
{
$aggregations = [];
foreach (array_keys($queries) as $key) {
/** @var Search $searchClone */
$searchClone = unserialize(serialize($search));
$query = clone $baseQuery;
$searchClone->addQuery($query);
foreach ($queries as $innerKey => $innerFilters) {
if ($innerKey !== $key) {
foreach ($innerFilters as $innerFilter) {
$query->add($innerFilter, BoolQuery::FILTER);
}
}
}
try{
$response = $this->search->search([
'index' => $this->buildRedisPrefix(),
'body' => $searchClone->toArray(),
]);
} catch (\Exception $e) {
$this->logger->error('Error invalid query: '.json_encode($baseQuery) .' . '.json_encode($queries));
throw $e;
}
assert(is_array($response));
$aggregations[$key] = $response['aggregations'][$key] ?? null;
}
return $aggregations;
}
/**
* @param array $skus
* @param array|null $sort
* @param int|null $page
* @param int|null $itemsPerPage
* @param array|null $filter
* @return array
*/
public function getResponseBySkus(array $skus, array $sort = null, int $page = null, int $itemsPerPage = null, array $filter = null): array
{
$search = new Search();
if ($page && $page > 0 && $itemsPerPage && $itemsPerPage > 0) {
$search->setSize($itemsPerPage);
$search->setFrom($itemsPerPage * ($page - 1));
}
$boolQuery = new BoolQuery();
$boolQuery->add(new TermsQuery('key', $skus), BoolQuery::SHOULD);
$boolQuery->add(new TermsQuery('master.sku', $skus), BoolQuery::SHOULD);
$boolQuery->add(new TermsQuery('variants.sku', $skus), BoolQuery::SHOULD);
$search->addQuery($boolQuery);
if ($filter) {
$boolQuery = new BoolQuery();
$filters = Collection::make($filter);
if ($filters->isNotEmpty()) {
$filters = $this->filterBuilder->transformFilter($filters);
[$queries, $boolQuery] = $this->generateFilterQuery($filters, $boolQuery);
foreach ($queries as $innerQueries) {
foreach ($innerQueries as $query) {
$boolQuery->add($query, BoolQuery::FILTER);
}
}
$search->addQuery($boolQuery);
}
}
if ($sort) {
$this->applySort($search, $sort, $skus);
}
$response = $this->search->search([
'index' => $this->buildRedisPrefix(),
'body' => $search->toArray(),
]);
return $response;
}
/**
* @param array $skus
* @param array|null $sort
* @param int|null $page
* @param int|null $itemsPerPage
* @param array|null $filter
* @return array
* @throws \Commercetools\Core\Fixtures\FixtureException
*/
public function getProductsBySkus(array $skus, array $sort = null, int $page = null, int $itemsPerPage = null, array $filter = null): array
{
if ($this->storeContext->isPunchoutStore()) {
$products = [];
foreach ($skus as $sku) {
$product = $this->ctProductProvider->getProductBySkuCT($sku);
if ($product !== null) {
array_push($products, $product);
}
}
return $products;
} else {
$response = $this->getResponseBySkus($skus, $sort, $page, $itemsPerPage, $filter);
$hits = $response['hits']['hits'] ?? null;
$products = array_column($hits, '_source');
return array_map(
[$this, 'createProduct'],
$products
);
}
}
public function getProductsAndTotalBySkus(array $skus, array $sort = null, int $page = null, int $itemsPerPage = null, array $filter = null): array
{
if ($this->storeContext->isPunchoutStore()) {
$result = $this->ctProductProvider->productSearchWithFilter($skus, $page, $itemsPerPage, $filter ?? [], $sort);
return [$result["products"], $result["total"]];
} else {
$response = $this->getResponseBySkus($skus, $sort, $page, $itemsPerPage, $filter);
$hits = $response['hits']['hits'] ?? null;
$products = array_column($hits, '_source');
$products = array_map(
[$this, 'createProduct'],
$products
);
return [$products, $response['hits']['total']['value'] ?? 0];
}
}
/**
* @param string $sku
*
* @return Product|null
*/
public function getProductBySku(string $sku): ?Product
{
$product = null;
$cacheKey = $this->buildCacheKey($sku);
$cacheItem = $this->cache->getItem($cacheKey);
//cach not wanted
if (true) {
$products = $this->getProductsBySkus([$sku]);
if (count($products) <1 or empty(end($products))) {
return null;
}
$product = array_pop($products);
$cacheItem->set($product);
$cacheItem->expiresAfter(self::CACHE_TTL);
$this->cache->save($cacheItem);
} else {
$product = $cacheItem->get();
if ($product === null) {
$this->cache->delete($cacheKey);
}
}
return $product;
}
/**
* @param string $search
*
* @return Product|null
*/
public function getProductsByTextSearch(string $search): ?array
{
$products = $this->ctProductProvider->productSearchWithText($search, 20, 1, []);
if (count($products) >= 1) {
return $products;
}
return [];
}
/**
* Get Product by slug or null
*
* @param string $slug
*
* @return Product|null
*/
public function getProductBySlug(string $slug): ?Product
{
$product = null;
$request = $this->request->getCurrentRequest();
$excludingVat = $request->attributes->getBoolean('exclude_vat');
$cacheKey = $this->buildCacheKey($slug.'vat_'.$excludingVat);
$cacheItem = $this->cache->getItem($cacheKey);
if (!$cacheItem->isHit()) {
if ($this->storeContext->isPunchoutStore()) {
$product = $this->ctProductProvider->getProductBySlug($slug);
} else {
$search = new Search();
$search->addQuery(new TermQuery('slug', $slug));
$response = $this->search->search([
'index' => $this->buildRedisPrefix(),
'body' => $search->toArray()
]);
$hits = $response['hits']['hits'] ?? null;
$products = array_map(
[$this, 'createProduct'],
array_column($hits, '_source')
);
if (count($products) < 1 or empty(end($products))) {
return null;
}
/** @var Product $product */
$product = array_pop($products);
}
$cacheItem->set($product);
$cacheItem->expiresAfter(self::CACHE_TTL);
$this->cache->save($cacheItem);
return $product;
} else {
$product = $cacheItem->get();
}
return $product;
}
/**
* Returns product by id from redis.
*/
public function getProductById(string $productId): ?Product
{
if ($this->storeContext->isPunchoutStore()) {
$products = $this->getProductsByIds([$productId]);
if (count($products) >= 1) {
return array_pop($products);
}
return null;
} else {
$product = $this->redis->get($productId, $this->buildRedisPrefix());
return $product ? $this->createProduct($product) : null;
}
}
/**
* Returns product SKU-list from redis.
*/
public function getCacheDataByCacheKey(string $cacheKey): ?array
{
$dataList = $this->redis->get($cacheKey, 'cache');
return $dataList ?? null;
}
/**
* Save product SKU-list from redis.
*/
public function setCacheValuesByChannelId(string $cacheKey, $value): void
{
$this->redis->set($cacheKey, $value, 'cache');
}
/**
* Returns product by id from redis.
*/
public function deleteProductSkusByChannelId(string $cacheKey): void
{
$this->redis->delete($cacheKey, 'cache');
}
/**
* ToDo: temporary fix for punchout menu tree; REMOVE after Punchout Replatforming
*/
public function deleteCacheValuesByCurrentStoreContext(): void
{
$alias = $this->storeContext->getAlias();
$locale = $this->storeContext->getLocaleInfo()->getCurrentLocale();
$cacheKey = sprintf('%s_%s_%s', self::CACHE_PREFIX, $alias, $locale);
$this->redis->delete($cacheKey);
}
private function getProductByIdsFromCT(array $productIds)
{
$products = [];
foreach ($productIds as $id) {
$product = $this->ctProductProvider->getProductByID($id);
if ($product !== null) {
$products[] = $product;
}
}
return $products;
}
/**
* Returns multiple products by id from redis.
*
* @return array|Product[]
*/
public function getProductsByIds(array $productIds): array
{
if ($this->storeContext->isPunchoutStore()) {
return $this->getProductByIdsFromCT($productIds);
} else {
try {
return array_map(
[$this, 'createProduct'],
array_filter($this->redis->getMany($productIds, $this->buildRedisPrefix()))
);
} catch (Throwable $exception) {
$this->logger->error($exception->getMessage());
return $this->getProductByIdsFromCT($productIds);
}
}
}
/**
* @return string
*/
private function buildRedisPrefix(): string
{
return implode('_', [
$this->storeContext->getAlias(),
strtolower(Locale::canonicalize($this->storeContext->getLocaleInfo()->getCurrentLocale())),
'product'
]);
}
/**
* Create product by source
*
* @param array $source
*
* @return Product
*/
public function createProduct(array $source): Product
{
$product = new Product($source);
foreach ($this->dataDecorators as $decorator) {
if ($decorator->supports($product)) {
$decorator->decorate($product);
}
}
return $product;
}
/**
* Build pagination from elastic search results
*
* @param int $totalHits
* @param int $itemsPerPage
* @param int $currentPage
*
* @return Pagination
*/
private function buildPagination(int $totalHits, int $itemsPerPage, int $currentPage): Pagination
{
return new Pagination(
$totalHits,
(int)ceil($totalHits / $itemsPerPage),
$itemsPerPage,
$currentPage
);
}
/**
* Build products from elastic search hits
*
* @param array $hits
*
* @return array
*/
private function buildProducts(array $hits): array
{
$products = [];
foreach ($hits as $hit) {
$products[] = $this->createProduct($hit['_source']);
$hit = null;
}
return $products;
}
private function buildCacheKey(string $input): string
{
$hash = md5(json_encode($input));
$alias = $this->storeContext->getAlias();
$locale = $this->storeContext->getLocaleInfo()->getCurrentLocale();
return sprintf('%s_%s_%s_%s', self::CACHE_PREFIX, $alias, $locale, $hash);
}
private function applySort(&$search, array $sort, array $skus): void
{
switch ($sort['field']) {
case SortingFields::PRICE:
$search->addSort($this->getPriceSort($sort['order']));
break;
case SortingFields::NEW_UNTIL:
$search->addSort($this->getNewUntilSort($sort['order']));
break;
case SortingFields::SORT_RANK:
$search->addSort($this->getSortRankSort($sort['order']));
break;
case SortingFields::KEEP_ORDER:
$params = [];
foreach ($skus as $key => $sku) {
$params[$sku] = -$key;
}
$search->addSort($this->getKeepOrderSort($sort['order'], $params));
break;
}
}
}