530 lines
20 KiB
PHP
530 lines
20 KiB
PHP
<?php
|
|
|
|
use Doctrine\ORM\Cache\CacheException;
|
|
use KupShop\KupShopBundle\Context\CacheContext;
|
|
use KupShop\KupShopBundle\Context\ContextManager;
|
|
use KupShop\KupShopBundle\Context\LanguageContext;
|
|
use KupShop\KupShopBundle\Util\Compat\ServiceContainer;
|
|
use KupShop\KupShopBundle\Util\Contexts;
|
|
use Psr\Log\LoggerInterface;
|
|
use Symfony\Component\Lock\LockFactory;
|
|
use Symfony\Component\Lock\Store\FlockStore;
|
|
|
|
class MenuSectionTree
|
|
{
|
|
/**
|
|
* @var \KupShop\CatalogBundle\Entity\Section[]
|
|
*/
|
|
protected $cache;
|
|
|
|
protected $mapping = [];
|
|
|
|
protected ?int $timeCached = null;
|
|
|
|
protected $languageID;
|
|
|
|
/** @var LoggerInterface */
|
|
protected $logger;
|
|
|
|
public const SECTIONS_LAST_UPDATED_KEY = 'sections-last-updated';
|
|
|
|
public function __construct($languageID)
|
|
{
|
|
$this->languageID = $languageID;
|
|
}
|
|
|
|
public function updateSelection($selection)
|
|
{
|
|
$root = &$this->cache;
|
|
$item = null;
|
|
foreach ($selection as &$item_id) {
|
|
if (!isset($root[$item_id])) {
|
|
break;
|
|
}
|
|
|
|
$item = &$root[$item_id];
|
|
$root = &$item['submenu'];
|
|
|
|
$item['opened'] = true;
|
|
}
|
|
|
|
if ($item) {
|
|
$item['selected'] = true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return \KupShop\CatalogBundle\Entity\Section[]
|
|
*/
|
|
protected function createTreeMenuProducts($rootSection = 0)
|
|
{
|
|
$retTree = [];
|
|
|
|
$where = ($rootSection) ? 'S1.id = :rootSection' : 'SR1.id_topsection IS NULL';
|
|
|
|
$recursiveQuery = "
|
|
WITH RECURSIVE cte (level, id, name, id_topsection, position) AS (
|
|
SELECT 0, S1.id, S1.name, SR1.id_topsection, SR1.position
|
|
FROM sections S1
|
|
LEFT JOIN sections_relation SR1 on (S1.id = SR1.id_section)
|
|
WHERE {$where}
|
|
UNION ALL
|
|
SELECT cte.level + 1, S2.id, S2.name, SR2.id_topsection, SR2.position
|
|
FROM sections S2
|
|
INNER JOIN sections_relation SR2 on S2.id = SR2.id_section
|
|
INNER JOIN cte ON (cte.id = SR2.id_topsection)
|
|
WHERE cte.level < 50
|
|
)
|
|
SELECT * FROM cte
|
|
";
|
|
|
|
$defaultFilterParams = (new ProductList())->applydefaultFilterParams();
|
|
|
|
$productsSubQuery = sqlQueryBuilder()
|
|
// Počítání produktů v sekcích - Kvůli přesnosti počtů produktů se musí používat 'applydefaultFilterParams()'.
|
|
// To na sebe může joinout 1:n tabulky, takže se to musí groupnout podle 'p.id' a agregovat až ve vnější query.
|
|
// Alternativní možnost je joinout 'products_in_sections' a agregovat to už tady,
|
|
// ale kvůli možným 1:n joinům by se musel vytvořit další subquery, což už by byly 3 subquery
|
|
// v sobě a rychlostně to skončí podobně.
|
|
->select('p.id, p.in_store')
|
|
->from('products', 'p')
|
|
->joinProducersOnProducts()
|
|
->andWhere($defaultFilterParams->getSpec())
|
|
->groupBy('p.id');
|
|
|
|
$qb = sqlQueryBuilder()->select('sr.level, sr.id_topsection, s.*')
|
|
->from('('.$recursiveQuery.')', 'sr')
|
|
->innerJoin('sr', 'sections', 's', 's.id = sr.id')
|
|
->leftJoin('s', 'products_in_sections', 'ps', 's.id = ps.id_section')
|
|
->leftJoinSubQuery('ps', $productsSubQuery, 'p', 'ps.id_product = p.id')
|
|
->addSelect('COUNT(p.id) count, SUM(IF(p.in_store > 0, 1, 0)) AS in_stock_count')
|
|
->andWhere(
|
|
\Query\Translation::coalesceTranslatedFields(
|
|
\KupShop\I18nBundle\Translations\SectionsTranslation::class,
|
|
function ($columns) {
|
|
$columns['data'] = 'data_translated';
|
|
|
|
return $columns;
|
|
}
|
|
)
|
|
)
|
|
->groupBy('s.id')
|
|
->orderBy('sr.level, sr.position')->addOrderBy('s.name');
|
|
|
|
if ($rootSection) {
|
|
$qb->setParameter('rootSection', $rootSection);
|
|
}
|
|
|
|
if (findModule(\Modules::PRODUCTS_SUPPLIERS)) {
|
|
$productsSuppliersSubQuery = sqlQueryBuilder()
|
|
->select('ppos.id_product, SUM(IF(ppos.in_store > 0, 1, 0)) count')
|
|
->from('products_of_suppliers', 'ppos')
|
|
->groupBy('ppos.id_product');
|
|
|
|
$qb->leftJoinSubQuery('p', $productsSuppliersSubQuery, 'pos', 'pos.id_product = p.id');
|
|
|
|
$qb->addSelect('SUM(COALESCE((pos.count),0)) AS in_stock_suppliers_count');
|
|
}
|
|
|
|
$SQL = $qb->execute()->fetchAll();
|
|
|
|
$productsFilterSpecs = ServiceContainer::getService(\KupShop\CatalogBundle\Util\ProductsFilterSpecs::class);
|
|
|
|
foreach ($SQL as $row) {
|
|
unset($section);
|
|
|
|
$sectionData = array_replace_recursive(json_decode($row['data'] ?? '{}', true) ?: [], json_decode($row['data_translated'] ?? '{}', true) ?: []);
|
|
/* @var $parent \KupShop\CatalogBundle\Entity\Section */
|
|
|
|
$parent = ($row['id_topsection']) ? $this->mapping[$row['id_topsection']] ?? null : null;
|
|
|
|
// skip section when parent is not found, but id_topsection is set
|
|
// for example when hide_not_translated_objects is enabled for sections and parent section is not translated
|
|
// the child section is passed to section tree as root section, and it is invalid behaviour!
|
|
if (!$parent && $row['id_topsection']) {
|
|
continue;
|
|
}
|
|
|
|
$section = new \KupShop\CatalogBundle\Entity\Section();
|
|
$section->setId($row['id'])
|
|
->setIdBlock($row['id_block'])
|
|
->setName($row['name'])
|
|
->setNameShort($row['name_short'] ?? $row['name'])
|
|
->setFigure($row['figure'])
|
|
->setShowInSearch($row['show_in_search'])
|
|
->setBehaviour($row['behaviour'])
|
|
->setOrderby($row['orderby'])
|
|
->setOrderdir($row['orderdir'])
|
|
->setDateUpdated(new DateTime($row['date_updated']))
|
|
->setListVariations(findModule(\Modules::PRODUCTS_VARIATIONS) ? $row['list_variations'] : 'N')
|
|
->setLeadFigure($row['lead_figure'])
|
|
->setLeadText($row['lead_text'])
|
|
->setLeadProducts($row['lead_products'])
|
|
->setPhoto($row['photo'])
|
|
->setMetaTitle($row['meta_title'])
|
|
->setMetaDescription($row['meta_description'])
|
|
->setMetaKeywords($row['meta_keywords'])
|
|
->setFeedHeureka($row['feed_heureka'])
|
|
->setFeedGoogle($row['feed_google'])
|
|
->setFeedHeurekaSk($row['feed_heureka_sk'])
|
|
->setFeedGlami($row['feed_glami'] ?? null)
|
|
->setIdSlider($row['id_slider'] ?? null)
|
|
->setFlags(!$row['flags'] ? [] : explodeFlags($row['flags']))
|
|
->setFeedSeznam($row['feed_seznam'])
|
|
->setProducersFilter($row['producers_filter'])
|
|
->setParent($parent)
|
|
->setData($sectionData)
|
|
->setVirtual($row['virtual'])
|
|
->setCount($row['count'])
|
|
->setAnnotation($row['annotation'] ?? null)
|
|
->setRedirectUrl($row['redirect_url'])
|
|
->setPriority($row['priority']);
|
|
|
|
$inStockCount = $row['in_stock_count'];
|
|
if (findModule(\Modules::PRODUCTS_SUPPLIERS)) {
|
|
$inStockCount += (int) $row['in_stock_suppliers_count'];
|
|
}
|
|
$section->setInStockCount($inStockCount);
|
|
|
|
if ($section->getId() === 0 && empty($row['url'])) {
|
|
$section->setUrl(trim(path('kupshop_catalog_catalog_category'), '/'));
|
|
} elseif (findModule('products_sections', 'custom_url')) {
|
|
$section->setUrl($row['url']);
|
|
}
|
|
|
|
// nacteni poctu produktu ve virtualni sekci, ale pouze pokud nejsou zapnuty materializovany virt. sekce
|
|
if (!findModule(Modules::PRODUCTS_SECTIONS, Modules::SUB_VIRTUAL_TO_DB) && $section->isVirtual()) {
|
|
$virtualSettings = $section->getData()['virtual_settings'] ?? [];
|
|
$virtualCount = sqlQueryBuilder()->select('COUNT(DISTINCT p.id) as count')
|
|
->from('products', 'p')
|
|
->andWhere($productsFilterSpecs->getSpecs($virtualSettings))
|
|
->andWhere($defaultFilterParams->getSpec())
|
|
->execute()->fetchColumn();
|
|
|
|
$section->setCount($virtualCount);
|
|
$section->setInStockCount($virtualCount);
|
|
}
|
|
|
|
$this->mapping[$section->getId()] = &$section;
|
|
|
|
if ($parent && $section->getId() != $rootSection) {
|
|
$siblings = $parent->getChildren();
|
|
$siblings->set($section->getId(), $section);
|
|
$parent->setChildren($siblings);
|
|
} elseif ($section->getId() > 0) {
|
|
$retTree[$section->getId()] = $section;
|
|
}
|
|
}
|
|
|
|
foreach (array_reverse($this->mapping) as &$section) {
|
|
if ($section->getBehaviour() != 1) {
|
|
$count = $section->getCount();
|
|
$inStockCount = $section->getInStockCount();
|
|
|
|
foreach ($section->getChildren() as $child) {
|
|
/* @var $child \KupShop\CatalogBundle\Entity\Section */
|
|
$count += $child->getCount();
|
|
$inStockCount += $child->getInStockCount();
|
|
}
|
|
$section->setCount($count);
|
|
$section->setInStockCount($inStockCount);
|
|
}
|
|
}
|
|
|
|
if (!empty($this->mapping[0])) {
|
|
$section0 = $this->mapping[0];
|
|
|
|
// vytvoreni podsekci
|
|
$section0->setChildren(new \Doctrine\Common\Collections\ArrayCollection($retTree));
|
|
|
|
// calc products count
|
|
$count = $section0->getCount();
|
|
$inStockCount = $section0->getInStockCount();
|
|
foreach ($section0->getChildren() as $child) {
|
|
/* @var $child \KupShop\CatalogBundle\Entity\Section */
|
|
$count += $child->getCount();
|
|
$inStockCount += $child->getInStockCount();
|
|
}
|
|
$section0->setCount($count);
|
|
$section0->setInStockCount($inStockCount);
|
|
}
|
|
|
|
return $retTree;
|
|
}
|
|
|
|
/**
|
|
* @return \KupShop\CatalogBundle\Entity\Section|null
|
|
*/
|
|
public function getSectionById($id)
|
|
{
|
|
$this->initializeTree();
|
|
|
|
if (!empty($this->mapping[$id])) {
|
|
return $this->mapping[$id];
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param string $glue
|
|
*
|
|
* @return string|null
|
|
*/
|
|
public function getFullPath($id, $glue = '/')
|
|
{
|
|
$this->initializeTree();
|
|
|
|
if (!empty($this->mapping[$id])) {
|
|
return join($glue, $this->mapping[$id]['parents']);
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public function &getTree()
|
|
{
|
|
$this->initializeTree();
|
|
|
|
return $this->cache;
|
|
}
|
|
|
|
public function getTimeCached(): ?int
|
|
{
|
|
$this->initializeTree();
|
|
|
|
return $this->timeCached;
|
|
}
|
|
|
|
/**
|
|
* @throws CacheException
|
|
*/
|
|
public function initializeTree()
|
|
{
|
|
if (!$this->cache) {
|
|
$cacheContext = Contexts::get(CacheContext::class);
|
|
$contextManager = ServiceContainer::getService(ContextManager::class);
|
|
|
|
$contextManager->activateContexts([LanguageContext::class => $this->languageID], function () use ($cacheContext) {
|
|
$cacheKey = implode('-', [
|
|
'sections',
|
|
$cacheContext->getKey([CacheContext::TYPE_TEXT, CacheContext::TYPE_AVAILABILITY]),
|
|
]);
|
|
|
|
// First try local cache
|
|
// Administrators do not use local cache - problem with local cache cleaning in multiple shop instances
|
|
$isLocalFresh = null;
|
|
$localData = null;
|
|
if (!getAdminUser() && $localData = $this->getLocalCache($cacheKey, $isLocalFresh)) {
|
|
// If local cache is young enough, use it
|
|
if ($isLocalFresh) {
|
|
$this->useCache($localData);
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
// TODO: Temporary hack for OOM on menu load. Increase whenever we load second menu from memcached
|
|
increaseMemoryLimit(60);
|
|
|
|
$remoteData = $this->getRemoteCache($cacheKey);
|
|
|
|
// Then try remote
|
|
if ($remoteData && !$this->isCacheInvalidated($remoteData['timeCached'] ?? null)) {
|
|
// Update/Create local cache
|
|
$this->setLocalCache($cacheKey, $remoteData);
|
|
|
|
$this->useCache($remoteData);
|
|
|
|
return;
|
|
}
|
|
|
|
// TODO: Budu načítat a ukládat menu. Zvětším paměť, protože APC store, memcached store a další žerou hodně paměti
|
|
// TODO: Tohle si můžu dovolit, protože sem bych se neměl moc často dostat, cache je validní dlouho
|
|
increaseMemoryLimit(100);
|
|
|
|
$debugFirstRemoteData = (bool) $remoteData;
|
|
|
|
// Use local blocking store - better to not rely on any remote locks store
|
|
$store = new FlockStore();
|
|
$factory = new LockFactory($store);
|
|
// lock releases after 90s
|
|
$lock = $factory->createLock('lock-'.$cacheKey, 90);
|
|
|
|
// Pokud máme lokální/remote cache, nemá cenu stát na locku. Použiju lokální/remote, i když není fresh a spustím async job na vygenerování sekcí
|
|
// Když nemáme lokální/remote cache, musím čekat :-(
|
|
$SYNC_JOB = !$localData && !$remoteData;
|
|
$debugSecondRemoteData = null;
|
|
$debugSetRemoteData = null;
|
|
|
|
$jobId = md5(time().rand());
|
|
|
|
if ($SYNC_JOB) { // no cache is available, generate it synchronously or wait for the result
|
|
$this->kibanaLog('MenuSectionTree: blocking lock - old cache not available', ['job' => $jobId]);
|
|
|
|
if ($lock->acquire(blocking: true)) {
|
|
$debugAcquired = true;
|
|
$remoteData = $this->getRemoteCache($cacheKey);
|
|
$debugSecondRemoteData = (bool) $remoteData;
|
|
if (!$remoteData || $this->isCacheInvalidated($remoteData['timeCached'] ?? null)) {
|
|
$this->kibanaLog('MenuSectionTree: generating tree SYNC '.$this->languageID, ['job' => $jobId]);
|
|
$this->generateSectionsTreeJob($cacheKey);
|
|
$debugSetRemoteData = 'sync';
|
|
} else {
|
|
// cache has been already updated while waiting
|
|
$this->useCache($remoteData);
|
|
}
|
|
$lock->release();
|
|
} else {
|
|
throw new \LogicException('MenuSectionTree - Blocking acquire should never return false!');
|
|
}
|
|
} else { // Schedule an async job to generate the tree. Use old cache in the meantime
|
|
$asyncQueue = ServiceContainer::getService(\KupShop\KupShopBundle\Util\System\AsyncQueue::class);
|
|
$asyncQueue->addHandler(function () use ($cacheKey, $lock, $jobId) {
|
|
// blocking lock should not be needed. If the lock is already acquired, the tree is already being generated and is no need to wait for anything
|
|
if ($lock->acquire(blocking: false)) {
|
|
$this->kibanaLog('MenuSectionTree: generating tree ASYNC '.$this->languageID, ['job' => $jobId]);
|
|
$this->generateSectionsTreeAsyncJob($cacheKey);
|
|
$lock->release();
|
|
}
|
|
}, "SECTIONS - generate menuSectionTree ({$cacheKey})");
|
|
|
|
if (!$localData && !$remoteData) {
|
|
throw new \LogicException('MenuSectionTree - When non-blocking acquire fails, local or remote cache should be always available!');
|
|
}
|
|
|
|
$debugSetRemoteData = 'async';
|
|
$this->useCache($localData ?: $remoteData);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
protected function generateSectionsTreeAsyncJob(string $cacheKey)
|
|
{
|
|
$contextManager = ServiceContainer::getService(ContextManager::class);
|
|
|
|
return $contextManager->activateContexts([LanguageContext::class => $this->languageID], function () use ($cacheKey) {
|
|
return $this->generateSectionsTreeJob($cacheKey);
|
|
});
|
|
}
|
|
|
|
protected function generateSectionsTreeJob(string $cacheKey)
|
|
{
|
|
$this->timeCached = time();
|
|
$this->cache = $this->createTreeMenuProducts(0);
|
|
$remoteData = [
|
|
'cache' => $this->cache,
|
|
'mapping' => $this->mapping ?? [],
|
|
'timeCached' => $this->timeCached,
|
|
];
|
|
|
|
setCache($cacheKey, $remoteData, 3600);
|
|
|
|
// update apcu - should be needed only after regeneration, otherwise all released processes would spam setLocalCache
|
|
$this->setLocalCache($cacheKey, $remoteData);
|
|
|
|
return $remoteData;
|
|
}
|
|
|
|
protected function getRemoteCache(string $fromKey)
|
|
{
|
|
return getCache($fromKey);
|
|
}
|
|
|
|
protected function useCache(array $cache)
|
|
{
|
|
if (!empty($cache['cache'])) {
|
|
$this->cache = $cache['cache'];
|
|
}
|
|
|
|
if (!empty($cache['mapping'])) {
|
|
$this->mapping = $cache['mapping'];
|
|
}
|
|
|
|
$this->timeCached = $cache['timeCached'] ?? null;
|
|
|
|
return $this->cache;
|
|
}
|
|
|
|
protected function getLocalCache(string $key, ?bool &$isFresh)
|
|
{
|
|
$cache = getLocalCache();
|
|
|
|
$item = $cache->getItem($key);
|
|
|
|
if ($item->isHit()) {
|
|
$data = $item->get();
|
|
$isFresh = (time() - $data['time']) <= 5;
|
|
|
|
return $data;
|
|
}
|
|
|
|
$isFresh = false;
|
|
|
|
return false;
|
|
}
|
|
|
|
// The only correct method for clearing sectionsCache
|
|
// clearCache('sections', true) and other old ways are FORBIDDEN unless you know exactly what you are doing
|
|
public static function invalidateCache(?string $languageID = null)
|
|
{
|
|
$now = time();
|
|
setCache(self::SECTIONS_LAST_UPDATED_KEY.($languageID ? '-'.$languageID : ''), $now, 0);
|
|
}
|
|
|
|
protected function isCacheInvalidated(?int $cacheUpdated)
|
|
{
|
|
$lastUpdated = $this->getSectionsLastUpdated();
|
|
|
|
if (is_null($lastUpdated)) {
|
|
return false;
|
|
}
|
|
|
|
if (is_null($cacheUpdated)) {
|
|
return true;
|
|
}
|
|
|
|
return $cacheUpdated < $lastUpdated;
|
|
}
|
|
|
|
protected function getSectionsLastUpdated(): ?int
|
|
{
|
|
$lastUpdatedGlobal = getCache(self::SECTIONS_LAST_UPDATED_KEY);
|
|
$lastUpdatedLang = getCache(self::SECTIONS_LAST_UPDATED_KEY.'-'.$this->languageID);
|
|
|
|
if ($lastUpdatedGlobal && $lastUpdatedLang) {
|
|
return max($lastUpdatedGlobal, $lastUpdatedLang);
|
|
}
|
|
|
|
return $lastUpdatedLang ?: $lastUpdatedGlobal ?: null;
|
|
}
|
|
|
|
private function kibanaLog(string $message, array $data = []): void
|
|
{
|
|
if (!isProduction()) {
|
|
return;
|
|
}
|
|
|
|
$logger = ServiceContainer::getService('logger');
|
|
$logger->notice($message,
|
|
array_merge(
|
|
$data,
|
|
[
|
|
'languageID' => $this->languageID,
|
|
])
|
|
);
|
|
}
|
|
|
|
protected function setLocalCache(string $key, array $data)
|
|
{
|
|
$cache = getLocalCache();
|
|
|
|
$item = $cache->getItem($key);
|
|
$data['time'] = time();
|
|
$item->set($data);
|
|
|
|
$cache->save($item);
|
|
}
|
|
}
|