first commit

This commit is contained in:
2025-08-02 16:30:27 +02:00
commit 23646bfcee
14851 changed files with 1750626 additions and 0 deletions

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace KupShop\DynamicRelatedProductsBundle\Admin\Actions;
use KupShop\AdminBundle\Admin\Actions\AbstractAction;
use KupShop\AdminBundle\Admin\Actions\ActionResult;
use KupShop\AdminBundle\Admin\Actions\IAction;
use KupShop\DynamicRelatedProductsBundle\Utils\DynamicRelatedProductsService;
use Symfony\Contracts\Service\Attribute\Required;
class RefreshDataAction extends AbstractAction implements IAction
{
#[Required]
public DynamicRelatedProductsService $relatedProductsService;
public function getTypes(): array
{
return ['DynamicRelatedProducts'];
}
public function getIcon(): string
{
return 'glyphicon glyphicon-refresh';
}
public function showInMassEdit(): bool
{
return true;
}
public function getName(): string
{
return 'Aktualizovat data';
}
public function execute(&$data, array $config, string $type): ActionResult
{
$this->relatedProductsService->updateDynamicRelatedProductsById((int) $this->getId());
return new ActionResult(true);
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace KupShop\DynamicRelatedProductsBundle\Admin;
use KupShop\AdminBundle\Admin\ProductsFilter;
class DynamicRelatedProducts extends \Window
{
protected $template = 'window/DynamicRelatedProducts.tpl';
protected $tableName = 'products_related_dynamic';
public function get_vars(): array
{
$data = parent::get_vars();
$this->unserializeCustomData($data['body']['data']);
return $data;
}
public function processFormData(): array
{
$data = parent::processFormData();
$this->unserializeCustomData($data);
$data['data']['filter_selection'] = ProductsFilter::cleanFilter($data['data']['filter_selection']);
$data['data']['filter_match'] = ProductsFilter::cleanFilter($data['data']['filter_match']);
$this->serializeCustomData($data);
return $data;
}
}
return DynamicRelatedProducts::class;

View File

@@ -0,0 +1,30 @@
<?php
$txt_str['DynamicRelatedProducts'] = [
'name' => 'Název pravidla',
'related_type' => 'Typ souvisejícího zboží',
'manual_reload' => 'Manuálně vygenerovat produkty',
'flapDynamicRelatedProduct' => 'Správa pravidla dynamického souvisejícího zboží',
'filter_selection_title' => 'Produkty, ke kterým chcete přiřadit související zboží',
'filter_match_title' => 'Produkty, které se přiřadí jako související zboží',
'toolbar_list' => 'Seznam pravidel',
'toolbar_add' => 'Přidat pravidlo',
'titleEdit' => 'Editovat pravidlo',
'titleAdd' => 'Přidat pravidlo',
'created' => 'Vytvořeno',
'updated_at' => 'Vygenerováno',
'related_count' => 'Počet vygenerovaných záznamů',
'activityEdited' => 'Upraveny dynamické produkty: %s',
'activityAdded' => 'Přidány dynamické produkty: %s',
'activityDeleted' => 'Smazány dynamické produkty: %s',
'search' => 'Hledat',
'selectType' => 'Vyberte typ',
'searchName' => 'Hledat podle názvu',
'searchOnlyThis' => 'Vyhledat pouze tento údaj',
];

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
use KupShop\AdminBundle\AdminList\BaseList;
class DynamicRelatedProductsList extends BaseList
{
protected $tableName = 'products_related_dynamic';
protected ?string $tableAlias = 't';
protected $tableDef = [
'id' => 't.id',
'fields' => [
'name' => ['field' => 't.name', 'translate' => true],
'related_type' => [],
'created' => ['field' => 't.created_at', 'render' => 'renderDateTime', 'translate' => true],
'updated_at' => ['field' => 't.updated_at', 'render' => 'renderDateTime', 'translate' => true],
'related_count' => [],
],
];
public function customizeTableDef($tableDef): array
{
$tableDef = parent::customizeTableDef($tableDef);
$tableDef['fields']['related_type'] = [
'field' => 'type_name',
'translate' => true,
'spec' => function (Query\QueryBuilder $qb) {
$qb->addSelect('b.name as type_name')
->leftJoin('t', 'products_related_types', 'b', 't.id_products_related_types = b.id');
}];
$tableDef['fields']['related_count'] = [
'field' => 'count',
'translate' => true,
'spec' => function (Query\QueryBuilder $qb) {
$count = sqlQueryBuilder()
->select('COUNT(*)')
->from('products_related', 'pr')
->andWhere('pr.id_products_related_dynamic = t.id');
$qb->addSubselect($count, 'count');
}];
return $tableDef;
}
public function getFilterQuery(): \Query\QueryBuilder
{
$qb = parent::getFilterQuery();
if ($name = getVal('name')) {
$qb->andWhere(\KupShop\CatalogBundle\Query\Search::searchFields($name, [
['field' => 'name', 'match' => 'both'],
]));
}
if (!empty($relatedTypes = getVal('product_related_types'))) {
$qb->andWhere(\KupShop\AdminBundle\Query\Invert::checkInvert(
\Query\Operator::inIntArray($relatedTypes, 'id_products_related_types'),
(bool) getVal('product_related_types_invert')));
}
return $qb;
}
}

View File

@@ -0,0 +1,9 @@
{extends "[AdminBundle]actions/baseAction.tpl"}
{block actionContent}
<div class="infobox">
<p>
Kliknutím na Provést ihned aktualizujete související zboží u všech produktů, kterých se toto nastavení týká. Standardně k aktualizaci dochází jednou denně v noci.
</p>
</div>
{/block}

View File

@@ -0,0 +1,37 @@
{extends file="[shared]/menu.tpl"}
{block name="menu-items" append}
<li class="nav-header smaller"><i class="glyphicon glyphicon-search"></i><span>{'search'|translate}</span></li>
<ul class="nav-sub nav-pills">
<form target="mainFrame" method="get" data-search="form" action="launch.php?s=list.php&type=DynamicRelatedProducts"
class="form-inline">
<input type="hidden" name="s" value="list.php" data-search="always">
<input type="hidden" name="type" value="DynamicRelatedProducts" data-search="always"/>
<li class="small-hidden">
<div class="form-group" data-search="item">
<div class="input-group">
<input type="text" class="form-control input-sm" name="name" value="" placeholder="{'searchName'|translate}"/>
<span class="input-group-btn">
<button type="submit" class="btn btn-primary btn-sm" title="{'searchOnlyThis'|translate}"><i
class="glyphicon glyphicon-search"></i></button>
</span>
</div>
</div>
<div class="form-group">
<div class="input-group invert">
<select data-autocomplete="productsRelatedTypes" data-preload="productsRelatedTypes" id="product_related_types"
name="product_related_types[]" multiple="multiple"
class="selecter selecter-ajax"
data-placeholder="{'selectType'|translate}"></select>
{inversion field="product_related_types"}
</div>
</div>
<div class="form-group">
<input type="reset" id="resetBtn" value="{'delete'|translate:"orders"}" class="btn btn-danger btn-sm"/>
<input type="submit" value="{'searchBtn'|translate:"orders"}" class="btn btn-primary btn-sm"/>
<input type="hidden" name="s" value="list.php"><input type="hidden" name="type" value="{$type}"/>
</div>
</li>
</form>
</ul>
{/block}

View File

@@ -0,0 +1,48 @@
{extends file="[shared]/window.tpl"}
{block tabs}
{windowTab id='flapDynamicRelatedProduct'}
{/block}
{block tabsContent}
<div id="flapDynamicRelatedProduct" class="tab-pane fade active in boxFlex">
<div class="form-group">
<div class="col-md-6">
<div class="">
<label>{'name'|translate}</label>
</div>
<div class="">
<input type="text" class="form-control input-sm" name="data[name]" maxlength="100" value="{$body.data.name}"/>
</div>
</div>
<div class="col-md-6">
<div class="">
<label>{'related_type'|translate}</label>
</div>
<div class="">
<select
data-autocomplete="productsRelatedTypes"
data-preload="productsRelatedTypes"
name="data[id_products_related_types]"
class="required selecter small"
required="required"
>
<option value="{$body.data.id_products_related_types}" selected></option>
</select>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<h5>{'filter_selection_title'|translate}</h5>
{include "block.productsFilter.tpl" filter_size=2 filter=$body.data.data.filter_selection filterName="selection" filterInputName="data[data][filter_selection]"}
</div>
<div class="col-md-6">
<h5>{'filter_match_title'|translate}</h5>
{include "block.productsFilter.tpl" filter_size=2 filter=$body.data.data.filter_match filterName="match" filterInputName="data[data][filter_match]"}
</div>
</div>
{/block}

View File

@@ -0,0 +1,30 @@
<?php
namespace KupShop\DynamicRelatedProductsBundle\AdminRegister;
use KupShop\AdminBundle\AdminRegister\AdminRegister;
use KupShop\AdminBundle\AdminRegister\IAdminRegisterDynamic;
use KupShop\AdminBundle\AdminRegister\IAdminRegisterStatic;
class DynamicRelatedProductsAdminRegister extends AdminRegister implements IAdminRegisterDynamic, IAdminRegisterStatic
{
public function getDynamicMenu(): array
{
return [
static::createMenuItem('productsMenu',
[
'name' => 'DynamicRelatedProducts',
'title' => translate('DynamicRelatedProducts', 'navigation'),
'left' => 's=menu.php&type=DynamicRelatedProducts',
'right' => 's=list.php&type=DynamicRelatedProducts',
]),
];
}
public static function getPermissions(): array
{
return [
static::createPermissions('DynamicRelatedProducts', [], ['DYNAMIC_RELATED_PRODUCTS']),
];
}
}

View File

@@ -0,0 +1,9 @@
<?php
namespace KupShop\DynamicRelatedProductsBundle;
use Symfony\Component\HttpKernel\Bundle\Bundle;
class DynamicRelatedProductsBundle extends Bundle
{
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace KupShop\DynamicRelatedProductsBundle\EventListener;
use KupShop\DynamicRelatedProductsBundle\Utils\DynamicRelatedProductsService;
use KupShop\KupShopBundle\Event\CronEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class CronListener implements EventSubscriberInterface
{
public function __construct(
protected readonly DynamicRelatedProductsService $productsRelatedDynamicService,
) {
}
public static function getSubscribedEvents(): array
{
return [
CronEvent::RUN_EXPENSIVE => [
['handleReload', 200],
],
];
}
public function handleReload(): void
{
$collectionOfDynamicRelations = sqlQueryBuilder()
->select('prodreldyn.id')
->from('products_related_dynamic', 'prodreldyn')
->execute()
->fetchFirstColumn();
foreach ($collectionOfDynamicRelations as $id) {
$this->productsRelatedDynamicService->updateDynamicRelatedProductsById($id);
}
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace KupShop\DynamicRelatedProductsBundle\Inspections\Compile;
use KupShop\SystemInspectionBundle\Inspections\Compile\CompileInspectionInterface;
use KupShop\SystemInspectionBundle\Inspections\Inspection;
use KupShop\SystemInspectionBundle\InspectionWriters\MessageTypes\SimpleMessage;
class ModuleInspection extends Inspection implements CompileInspectionInterface
{
public function runInspection(): ?array
{
$errors = [];
if (
!findModule(\Modules::PRODUCTS_RELATED, \Modules::SUB_TYPES)
&& !findModule(\Modules::DYNAMIC_RELATED_PRODUCTS)
) {
$errors[] = new SimpleMessage(
sprintf('Module "%s" must be enabled with submodule "%s".', \Modules::PRODUCTS_RELATED, \Modules::SUB_TYPES)
);
}
return $errors;
}
}

View File

@@ -0,0 +1,7 @@
services:
_defaults:
autoconfigure: true
autowire: true
KupShop\DynamicRelatedProductsBundle\:
resource: ../../{Admin/Actions,Utils,AdminRegister,EventListener,Services,Inspections}

View File

@@ -0,0 +1,66 @@
<?php
namespace KupShop\DynamicRelatedProductsBundle\Resources\upgrade;
class DynamicRelatedProductsUpgrade extends \UpgradeNew
{
public function check_ProductsRelatedDynamicTable(): bool
{
return $this->checkTableExists('products_related_dynamic');
}
/** [2023-09-26] Create dynamic products related table */
public function upgrade_ProductsRelatedDynamicTable(): void
{
sqlQuery('
CREATE TABLE products_related_dynamic (
id INT AUTO_INCREMENT,
id_products_related_types INT NOT NULL,
name VARCHAR(100) NOT NULL,
data LONGTEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP() NULL,
updated_at DATETIME NULL,
CONSTRAINT products_related_dynamic_pk PRIMARY KEY (id)
);
');
$this->upgradeOK();
}
public function check_AddColumnToProductsRelated(): bool
{
return $this->checkColumnExists('products_related', 'id_products_related_dynamic');
}
/** [2023-09-26] Added id_products_related_dynamic column to products_related table */
public function upgrade_AddColumnToProductsRelated(): void
{
sqlQuery('
ALTER TABLE products_related
ADD id_products_related_dynamic INT NULL,
ADD CONSTRAINT products_related_products_related_dynamic_id_fk
FOREIGN KEY (id_products_related_dynamic) REFERENCES products_related_dynamic (id)
ON UPDATE CASCADE ON DELETE CASCADE;
');
$this->upgradeOK();
}
public function check_AddForeignKey(): bool
{
return findModule(\Modules::PRODUCTS_RELATED, \Modules::SUB_TYPES) && $this->checkForeignKeyExists('products_related_dynamic', 'id_products_related_types');
}
/** [2023-10-17] Added foreign key for products_related_types */
public function upgrade_AddForeignKey(): void
{
sqlQuery('
ALTER TABLE products_related_dynamic
ADD CONSTRAINT products_related_dynamic_products_related_types_id_fk
FOREIGN KEY (id_products_related_types) REFERENCES products_related_types (id)
ON UPDATE CASCADE ON DELETE CASCADE;
');
$this->upgradeOK();
}
}

View File

@@ -0,0 +1,19 @@
{
"products_related_types": [
{
"id": 1,
"name": "Foo"
}
],
"products_related": [],
"products_related_dynamic": [
{
"id": 1,
"id_products_related_types": 1,
"name": "Bar",
"data": "{\"filter_selection\":{\"products\":[\"1\"]},\"filter_match\":{\"products\":[\"5\"]}}",
"created_at": "2023-10-02 07:56:49",
"updated_at": null
}
]
}

View File

@@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
namespace KupShop\DynamicRelatedProductsBundle\Tests;
use KupShop\DynamicRelatedProductsBundle\Utils\DynamicRelatedProductsService;
use KupShop\KupShopBundle\Util\Compat\ServiceContainer;
use Query\Operator;
class DynamicRelatedProductsServiceTest extends \DatabaseTestCase
{
private const defaultId = 1;
/**
* @dataProvider provideFilterData
*/
public function testQuantityAfterInsert(
int $automaticQuantity,
int $manualQuantity,
string $filter,
?array $insertRelated = null,
?callable $after = null,
): void {
sqlQueryBuilder()
->update('products_related_dynamic')
->directValues(['data' => $filter])
->where(Operator::equals(['id' => self::defaultId]))
->execute();
if ($insertRelated) {
foreach ($insertRelated as [$a,$b,$c]) {
sqlQueryBuilder()
->insert('products_related')
->directValues([
'id_top_product' => $a,
'id_rel_product' => $b,
'type' => self::defaultId,
'id_products_related_dynamic' => $c,
])
->execute();
}
}
/** @var DynamicRelatedProductsService $service */
$service = ServiceContainer::getService(DynamicRelatedProductsService::class);
$result = $service->updateDynamicRelatedProductsById(self::defaultId);
$this->assertTrue($result);
$prepareMatch = function (bool $automatic = false) {
$conds = $automatic ? 'isNotNull' : 'isNull';
return sqlQueryBuilder()
->select('*')
->from('products_related')
->andWhere(Operator::equals(['id_top_product' => 1]))
->andWhere(Operator::$conds('id_products_related_dynamic'))
->execute()
->rowCount();
};
$this->assertEquals($manualQuantity, $prepareMatch(false));
$this->assertEquals($automaticQuantity, $prepareMatch(true));
if ($after) {
$after();
}
}
public function provideFilterData(): \Generator
{
yield 'add products to empty list' => [
'automaticQuantity' => 1,
'manualQuantity' => 2,
'filter' => '{"filter_selection":{"products":["1"]},"filter_match":{"products":["5"]}}',
'insertRelated' => [
[1, 2, null],
[1, 3, null],
],
];
yield 'insert with existing manual relation' => [
'automaticQuantity' => 0,
'manualQuantity' => 1,
'filter' => '{"filter_selection":{"products":["1"]},"filter_match":{"products":["2"]}}',
'insertRelated' => [
[1, 2, null],
],
];
yield 'replace existing relations' => [
'automaticQuantity' => 2,
'manualQuantity' => 2,
'filter' => '{"filter_selection":{"products":["1"]},"filter_match":{"products":["6", "7"]}}',
'insertRelated' => [
[1, 2, null],
[1, 3, null],
[1, 4, self::defaultId],
[1, 5, self::defaultId],
],
];
yield 'added to existing relations' => [
'automaticQuantity' => 4,
'manualQuantity' => 2,
'filter' => '{"filter_selection":{"products":["1"]},"filter_match":{"products":["4", "5", "6", "7"]}}',
'insertRelated' => [
[1, 2, null],
[1, 3, null],
[1, 4, self::defaultId],
[1, 5, self::defaultId],
],
];
yield 'delete when filter does not match relations' => [
'automaticQuantity' => 0,
'manualQuantity' => 2,
'filter' => '{"filter_selection":{"products":["1"]},"filter_match":{"products":[]}}',
'insertRelated' => [
[1, 2, null],
[1, 3, null],
[1, 4, self::defaultId],
[1, 5, self::defaultId],
],
];
yield 'update when other relations exists' => [
'automaticQuantity' => 2,
'manualQuantity' => 2,
'filter' => '{"filter_selection":{"products":["1"]},"filter_match":{"products":["4", "5"]}}',
'insertRelated' => [
[1, 2, null],
[1, 3, null],
[2, 4, self::defaultId],
[2, 5, self::defaultId],
[1, 4, self::defaultId],
[1, 5, self::defaultId],
],
];
yield 'replace exists relations with wrong id_top_product' => [
'automaticQuantity' => 0,
'manualQuantity' => 2,
'filter' => '{"filter_selection":{"products":["2"]},"filter_match":{"products":["4", "5"]}}',
'insertRelated' => [
[1, 2, null],
[1, 3, null],
[1, 4, self::defaultId],
[1, 5, self::defaultId],
],
'after' => function () {
$this->assertEquals(
2,
sqlQueryBuilder()
->select('*')
->from('products_related')
->andWhere(Operator::equals(['id_top_product' => 2]))
->andWhere(Operator::isNotNull('id_products_related_dynamic'))
->execute()
->rowCount()
);
},
];
yield 'before added 2 products' => [
'automaticQuantity' => 2,
'manualQuantity' => 2,
'filter' => '{"filter_selection":{"products":["1"]},"filter_match":{"products":["4", "5"]}}',
'insertRelated' => [
[1, 2, null],
[1, 3, null],
],
];
yield 'delete one product id and check prev state' => [
'automaticQuantity' => 1,
'manualQuantity' => 2,
'filter' => '{"filter_selection":{"products":["1"]},"filter_match":{"products":["4"]}}',
'insertRelated' => [
[1, 2, null],
[1, 3, null],
[1, 4, self::defaultId],
[1, 5, self::defaultId],
],
];
}
public function getDataSet(): \IteratorAggregate
{
return $this->getJsonDataSetFromFile();
}
}

View File

@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace KupShop\DynamicRelatedProductsBundle\Utils;
use KupShop\CatalogBundle\Util\ProductsFilterSpecs;
use Query\Operator;
class DynamicRelatedProductsService
{
public function __construct(
private readonly ProductsFilterSpecs $productsFilterSpecs,
) {
}
private function applyFilter($data): array
{
$specs = $this->productsFilterSpecs->getSpecs($data);
if (!$specs) {
return [];
}
$array = sqlQueryBuilder()
->select('p.id')
->from('products', 'p')
->andWhere($specs)
->orderBy('p.id', 'DESC')
->execute()
->fetchFirstColumn();
return array_unique($array);
}
public function prepareDataForDifferenceInsert(int $id, array $values): void
{
$oldValues = sqlQueryBuilder()
->select('id_top_product', 'id_rel_product', 'position', 'type', 'id_products_related_dynamic')
->from('products_related')
->where(Operator::equals([
'id_products_related_dynamic' => $id,
]))
->execute()
->fetchAllAssociative();
$currentDatabaseValues = [];
foreach ($oldValues as $v) {
$key = sprintf('%s-%s-%s', $v['id_top_product'], $v['id_rel_product'], $v['id_products_related_dynamic']);
$currentDatabaseValues[$key] = $v;
}
$savedValues = array_keys($currentDatabaseValues);
$valuesKeys = array_keys($values);
$delete = array_diff($savedValues, $valuesKeys);
$insert = array_diff($valuesKeys, $savedValues);
foreach ($delete as $v) {
sqlQueryBuilder()
->delete('products_related')
->where(Operator::equals($currentDatabaseValues[$v]))
->execute();
}
foreach ($insert as $v) {
sqlQueryBuilder()
->insert('products_related')
->directValues($values[$v])
->onDuplicateKeyUpdate([
'id_top_product' => 'id_top_product',
])
->execute();
}
}
public function updateDynamicRelatedProductsById(int $id): bool
{
$data = sqlQueryBuilder()->select('*')
->from('products_related_dynamic')
->andWhere(Operator::equals(['id' => $id]))
->execute()
->fetch();
$filter = json_decode($data['data'], true);
$fixedPosition = 1000;
$fixedLengthOfMatch = 10;
$multiCollection = [];
$primary = $this->applyFilter($filter['filter_selection'] ?? []);
$secondary = array_slice($this->applyFilter($filter['filter_match'] ?? []), 0, $fixedLengthOfMatch);
$adds = [
'type' => $data['id_products_related_types'],
'id_products_related_dynamic' => $data['id'],
];
foreach ($primary as $item) {
foreach ($secondary as $subItem) {
if ($item === $subItem) {
continue;
}
$key = sprintf('%s-%s-%s', $item, $subItem, $data['id_products_related_types']);
$multiCollection[$key] = [
'id_top_product' => $item,
'id_rel_product' => $subItem,
'position' => $fixedPosition,
] + $adds;
}
}
$this->prepareDataForDifferenceInsert($id, $multiCollection);
$this->updateLastGeneratedDate($id);
return true;
}
public function updateLastGeneratedDate(int $id): void
{
sqlQueryBuilder()
->update('products_related_dynamic')
->directValues([
'updated_at' => (new \DateTimeImmutable(timezone: new \DateTimeZone('Europe/Prague')))->format('Y-m-d H:i:s'),
])
->where(Operator::equals(['id' => $id]))
->execute();
}
}