Files
kupshop/class/class.MenuSectionTree.php
2025-08-02 16:30:27 +02:00

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);
}
}