diff --git a/core/components/minishop3/elements/snippets/ms3_products.php b/core/components/minishop3/elements/snippets/ms3_products.php index 74955ea3..57c076ab 100644 --- a/core/components/minishop3/elements/snippets/ms3_products.php +++ b/core/components/minishop3/elements/snippets/ms3_products.php @@ -1,14 +1,13 @@ addTime('Conditions prepared'); -// Workaround: pdoTools проверяет 'msCategory' в classMap, но MiniShop3 использует namespace -// Добавляем товары из дополнительных категорий (msCategoryMember) через кастомный WHERE -// TODO убрать этот блок в случае доработок pdoTools +// pdoTools parent filter ignores msCategoryMember; scope via CategoryProductScopeService (#481). $_ms3Parents = (string)($scriptProperties['parents'] ?? ''); -if ($_ms3Parents !== '' && $_ms3Parents !== '0') { +if ($_ms3Parents !== '' && $_ms3Parents !== '0' && $modx->services->has('ms3_category_product_scope')) { + /** @var CategoryProductScopeService $scopeService */ + $scopeService = $modx->services->get('ms3_category_product_scope'); $_ms3Depth = (int)($scriptProperties['depth'] ?? 10); - $_ms3ParentsIn = []; - $_ms3ParentsOut = []; - - // Разбираем parents: положительные - включить, отрицательные - исключить - foreach (array_map('trim', explode(',', $_ms3Parents)) as $_ms3Parent) { - $_ms3Parent = (int)$_ms3Parent; - if ($_ms3Parent > 0) { - $_ms3ParentsIn[] = $_ms3Parent; - } elseif ($_ms3Parent < 0) { - $_ms3ParentsOut[] = abs($_ms3Parent); - } - } - - // Получаем дочерние категории для включения (только msCategory, не все ресурсы) - if (!empty($_ms3ParentsIn) && $_ms3Depth > 0) { - $_ms3CatIds = $_ms3ParentsIn; - for ($_ms3i = 0; $_ms3i < $_ms3Depth; $_ms3i++) { - $_ms3CatQuery = $modx->newQuery(msCategory::class); - $_ms3CatQuery->where([ - 'class_key' => msCategory::class, - 'parent:IN' => $_ms3CatIds, - 'published' => 1, - 'deleted' => 0, - ]); - $_ms3CatQuery->select('id'); - - if ($_ms3CatQuery->prepare() && $_ms3CatQuery->stmt->execute()) { - $_ms3ChildIds = $_ms3CatQuery->stmt->fetchAll(\PDO::FETCH_COLUMN); - if (empty($_ms3ChildIds)) { - break; - } - $_ms3ParentsIn = array_merge($_ms3ParentsIn, $_ms3ChildIds); - $_ms3CatIds = $_ms3ChildIds; - } else { - break; - } - } - } - - // Получаем дочерние категории для исключения - if (!empty($_ms3ParentsOut) && $_ms3Depth > 0) { - $_ms3CatIds = $_ms3ParentsOut; - for ($_ms3i = 0; $_ms3i < $_ms3Depth; $_ms3i++) { - $_ms3CatQuery = $modx->newQuery(msCategory::class); - $_ms3CatQuery->where([ - 'class_key' => msCategory::class, - 'parent:IN' => $_ms3CatIds, - 'published' => 1, - 'deleted' => 0, - ]); - $_ms3CatQuery->select('id'); - - if ($_ms3CatQuery->prepare() && $_ms3CatQuery->stmt->execute()) { - $_ms3ChildIds = $_ms3CatQuery->stmt->fetchAll(\PDO::FETCH_COLUMN); - if (empty($_ms3ChildIds)) { - break; - } - $_ms3ParentsOut = array_merge($_ms3ParentsOut, $_ms3ChildIds); - $_ms3CatIds = $_ms3ChildIds; - } else { - break; - } - } - } - - // Вычитаем исключённые категории из включённых - $_ms3ParentsIn = array_unique($_ms3ParentsIn); - $_ms3ParentsOut = array_unique($_ms3ParentsOut); - if (!empty($_ms3ParentsOut)) { - $_ms3ParentsIn = array_diff($_ms3ParentsIn, $_ms3ParentsOut); - } - - // ВСЕГДА отключаем pdoTools parent processing - мы обрабатываем сами - if (!empty($_ms3ParentsIn)) { - $_ms3ParentsList = implode(',', array_map('intval', $_ms3ParentsIn)); - - // Получаем товары из дополнительных категорий - $_ms3MemberQuery = $modx->newQuery(msCategoryMember::class); - $_ms3MemberQuery->where(['category_id:IN' => $_ms3ParentsIn]); - $_ms3MemberQuery->select('product_id'); - - $_ms3MemberIds = []; - if ($_ms3MemberQuery->prepare() && $_ms3MemberQuery->stmt->execute()) { - $_ms3MemberIds = $_ms3MemberQuery->stmt->fetchAll(\PDO::FETCH_COLUMN); - } - - // Строим WHERE: parent IN категориях, опционально OR id IN доп. категориях - if (!empty($_ms3MemberIds)) { - $_ms3MembersList = implode(',', array_map('intval', $_ms3MemberIds)); - $where[] = "(`msProduct`.`parent` IN ({$_ms3ParentsList}) OR `msProduct`.`id` IN ({$_ms3MembersList}))"; - } else { - // Нет товаров в доп. категориях - просто фильтруем по parent - $where[] = "`msProduct`.`parent` IN ({$_ms3ParentsList})"; - } + $_ms3CategoryIds = $scopeService->resolveCategoryIdsFromParents($_ms3Parents, $_ms3Depth); - // ВСЕГДА отключаем стандартную фильтрацию pdoTools по parents + if ($_ms3CategoryIds !== []) { + $where[] = $scopeService->buildMsProductsWhereForCategories($_ms3CategoryIds); $scriptProperties['parents'] = 0; } } diff --git a/core/components/minishop3/src/Controllers/Api/Manager/CategoryProductsController.php b/core/components/minishop3/src/Controllers/Api/Manager/CategoryProductsController.php index 9eb91679..e9f2f33e 100644 --- a/core/components/minishop3/src/Controllers/Api/Manager/CategoryProductsController.php +++ b/core/components/minishop3/src/Controllers/Api/Manager/CategoryProductsController.php @@ -127,18 +127,17 @@ public function sort(array $params = []): array } $updated = 0; - - $scope = $this->scopeService(); + $scopeService = $this->scopeService(); foreach ($items as $item) { $productId = (int) ($item['id'] ?? 0); $menuindex = (int) ($item['menuindex'] ?? 0); - if (!$productId) { + if (!$productId || !$scopeService->canReorderInCategory($productId, $categoryId)) { continue; } - $product = $scope->findInCategory($categoryId, $productId, $nested); + $product = $this->modx->getObject(msProduct::class, $productId); if ($product) { $product->set('menuindex', $menuindex); diff --git a/core/components/minishop3/src/Controllers/Api/ProductDataController.php b/core/components/minishop3/src/Controllers/Api/ProductDataController.php index cc4681bd..92b6bc32 100644 --- a/core/components/minishop3/src/Controllers/Api/ProductDataController.php +++ b/core/components/minishop3/src/Controllers/Api/ProductDataController.php @@ -4,6 +4,7 @@ use MiniShop3\Router\HttpStatus; use MiniShop3\Router\Response; +use MiniShop3\Services\Category\CategoryProductScopeService; /** * API controller for working with product data (msProductData) @@ -70,12 +71,7 @@ public function update(array $params): Response unset($data['category_id'], $data['nested']); if ($categoryId > 0) { - /** @var \MiniShop3\Services\Category\CategoryProductsListService|null $listService */ - $listService = $this->modx->services->get('ms3_category_products_list'); - if ( - !$listService - || !$listService->isProductInCategoryScope($productId, $categoryId, $nested) - ) { + if (!$this->categoryProductScopeService()->findInCategory($categoryId, $productId, $nested)) { $this->modx->lexicon->load('minishop3:default'); return Response::error( @@ -107,4 +103,13 @@ public function update(array $params): Response return Response::error('Failed to save product data: ' . $e->getMessage(), HttpStatus::INTERNAL_SERVER_ERROR); } } + + private function categoryProductScopeService(): CategoryProductScopeService + { + $service = $this->modx->services->get('ms3_category_product_scope'); + + return $service instanceof CategoryProductScopeService + ? $service + : new CategoryProductScopeService($this->modx); + } } diff --git a/core/components/minishop3/src/Services/Category/CategoryProductScopeService.php b/core/components/minishop3/src/Services/Category/CategoryProductScopeService.php index c670aef5..2389034d 100644 --- a/core/components/minishop3/src/Services/Category/CategoryProductScopeService.php +++ b/core/components/minishop3/src/Services/Category/CategoryProductScopeService.php @@ -1,14 +1,19 @@ $categoryId, ]); - return $product; + if ($product) { + return $product; + } + + // Additional category membership (msCategoryMember) for the leaf category only. + $member = $this->modx->getObject(msCategoryMember::class, [ + 'category_id' => $categoryId, + 'product_id' => $productId, + ]); + + return $member ? $this->modx->getObject(msProduct::class, $productId) : null; } /** @var msProduct|null $product */ @@ -48,7 +63,211 @@ public function findInCategory(int $categoryId, int $productId, bool $nested = f return null; } - return in_array((int) $product->get('parent'), $allowedParents, true) ? $product : null; + if (in_array((int) $product->get('parent'), $allowedParents, true)) { + return $product; + } + + $member = $this->modx->getObject(msCategoryMember::class, [ + 'category_id:IN' => $allowedParents, + 'product_id' => $productId, + ]); + + return $member ? $product : null; + } + + /** + * @return array{include: list, exclude: list} + */ + public static function parseParentSpec(string $parents): array + { + $include = []; + $exclude = []; + + foreach (array_map('trim', explode(',', $parents)) as $part) { + if ($part === '') { + continue; + } + + $id = (int) $part; + if ($id > 0) { + $include[] = $id; + } elseif ($id < 0) { + $exclude[] = abs($id); + } + } + + return [ + 'include' => array_values(array_unique($include)), + 'exclude' => array_values(array_unique($exclude)), + ]; + } + + /** + * Resolve msProducts `parents` param into category IDs (include/exclude, depth expansion). + * + * @return list Empty when nothing to filter. + */ + public function resolveCategoryIdsFromParents(string $parents, int $depth): array + { + $parsed = self::parseParentSpec($parents); + $include = $parsed['include']; + $exclude = $parsed['exclude']; + + if ($include !== [] && $depth > 0) { + $include = $this->expandPublishedCategoryTree($include, $depth); + } + + if ($exclude !== [] && $depth > 0) { + $exclude = $this->expandPublishedCategoryTree($exclude, $depth); + } + + return self::finalizeIncludedCategoryIds($include, $exclude); + } + + /** + * @param list $include + * @param list $exclude + * + * @return list + */ + public static function finalizeIncludedCategoryIds(array $include, array $exclude): array + { + $include = array_values(array_unique($include)); + $exclude = array_values(array_unique($exclude)); + + if ($exclude === []) { + return $include; + } + + return array_values(array_diff($include, $exclude)); + } + + /** + * pdoTools WHERE for resolved category IDs (parent + msCategoryMember). + * + * @param list $categoryIds + */ + public function buildMsProductsWhereForCategories(array $categoryIds): string + { + return self::buildMsProductsWhereSql( + $categoryIds, + $this->getAdditionalProductIds($categoryIds) + ); + } + + /** + * @param list $categoryIds + * + * @return list + */ + public function getAdditionalProductIds(array $categoryIds): array + { + $categoryIds = array_values(array_unique(array_filter(array_map('intval', $categoryIds)))); + if ($categoryIds === []) { + return []; + } + + $query = $this->modx->newQuery(msCategoryMember::class); + $query->where(['category_id:IN' => $categoryIds]); + $query->select('product_id'); + + if (!$query->prepare() || !$query->stmt->execute()) { + return []; + } + + $ids = $query->stmt->fetchAll(\PDO::FETCH_COLUMN); + + return array_values(array_unique(array_map('intval', $ids ?: []))); + } + + /** + * Raw SQL fragment for pdoTools/msProducts (`parent IN …` optionally OR member product ids). + * + * @param list $categoryIds + * @param list $additionalProductIds + */ + public static function buildMsProductsWhereSql(array $categoryIds, array $additionalProductIds): string + { + $categoryIds = array_values(array_unique(array_filter(array_map('intval', $categoryIds)))); + if ($categoryIds === []) { + throw new \InvalidArgumentException('categoryIds must not be empty'); + } + + $parentsList = implode(',', $categoryIds); + + $additionalProductIds = array_values(array_unique(array_filter(array_map('intval', $additionalProductIds)))); + if ($additionalProductIds === []) { + return "`msProduct`.`parent` IN ({$parentsList})"; + } + + $membersList = implode(',', $additionalProductIds); + + return "(`msProduct`.`parent` IN ({$parentsList}) OR `msProduct`.`id` IN ({$membersList}))"; + } + + /** + * xPDO where array for admin category products grid (parent + msCategoryMember). + * + * @param list $categoryIds + * @param list $additionalProductIds + * + * @return array + */ + public static function buildProductCategoryScopeWhere(array $categoryIds, array $additionalProductIds): array + { + $categoryIds = array_values(array_unique(array_filter(array_map('intval', $categoryIds)))); + if ($categoryIds === []) { + return []; + } + + $additionalProductIds = array_values(array_unique(array_filter(array_map('intval', $additionalProductIds)))); + if ($additionalProductIds === []) { + return ['msProduct.parent:IN' => $categoryIds]; + } + + return [ + 'msProduct.parent:IN' => $categoryIds, + 'OR:msProduct.id:IN' => $additionalProductIds, + ]; + } + + /** + * menuindex reorder applies only to direct children (not additional-category-only links). + */ + public function canReorderInCategory(int $productId, int $categoryId): bool + { + if ($productId <= 0 || $categoryId <= 0) { + return false; + } + + $product = $this->modx->getObject(msProduct::class, $productId); + if (!$product) { + return false; + } + + return (int) $product->get('parent') === $categoryId; + } + + /** + * Restrict product query to categories (primary parent or msCategoryMember). + * + * @param list $categoryIds + */ + public function applyProductCategoryScope(xPDOQuery $c, array $categoryIds): void + { + $categoryIds = array_values(array_unique(array_filter(array_map('intval', $categoryIds)))); + if ($categoryIds === []) { + return; + } + + $where = self::buildProductCategoryScopeWhere( + $categoryIds, + $this->getAdditionalProductIds($categoryIds) + ); + + if ($where !== []) { + $c->where($where); + } } private function treeService(): CategoryTreeService @@ -59,4 +278,44 @@ private function treeService(): CategoryTreeService ? $service : new CategoryTreeService($this->modx); } + + /** + * @param list $rootIds + * + * @return list + */ + private function expandPublishedCategoryTree(array $rootIds, int $depth): array + { + $allIds = $rootIds; + $currentLevel = $rootIds; + + for ($i = 0; $i < $depth; $i++) { + if ($currentLevel === []) { + break; + } + + $query = $this->modx->newQuery(msCategory::class); + $query->where([ + 'class_key' => msCategory::class, + 'parent:IN' => $currentLevel, + 'published' => 1, + 'deleted' => 0, + ]); + $query->select('id'); + + if (!$query->prepare() || !$query->stmt->execute()) { + break; + } + + $childIds = array_map('intval', $query->stmt->fetchAll(\PDO::FETCH_COLUMN) ?: []); + if ($childIds === []) { + break; + } + + $allIds = array_merge($allIds, $childIds); + $currentLevel = $childIds; + } + + return array_values(array_unique($allIds)); + } } diff --git a/core/components/minishop3/src/Services/Category/CategoryProductsListService.php b/core/components/minishop3/src/Services/Category/CategoryProductsListService.php index 41d671f6..806fe333 100644 --- a/core/components/minishop3/src/Services/Category/CategoryProductsListService.php +++ b/core/components/minishop3/src/Services/Category/CategoryProductsListService.php @@ -144,7 +144,14 @@ private function buildProductListQuery(int $categoryId, array $params, bool $nes } $c->where(['msProduct.class_key' => msProduct::class]); - $c->where(['msProduct.parent:IN' => $this->getAllowedProductParentCategoryIds($categoryId, $nested)]); + + $scopeService = $this->getCategoryProductScopeService(); + if ($nested) { + $categoryIds = $this->treeService()->productParentIds($categoryId, true); + $scopeService->applyProductCategoryScope($c, $categoryIds); + } else { + $scopeService->applyProductCategoryScope($c, [$categoryId]); + } if ($query !== '') { $c->where([ @@ -219,41 +226,6 @@ private function quoteOptionKeyForJoinCondition(string $key): string return str_replace("'", "''", $key); } - /** - * Parent category IDs allowed for products in category grid scope (matches list filter). - * - * @return list - */ - public function getAllowedProductParentCategoryIds(int $categoryId, bool $nested): array - { - if (!$nested) { - return [$categoryId]; - } - - $ids = $this->treeService()->getDescendantCategoryIds($categoryId); - $ids[] = $categoryId; - - return $ids; - } - - /** - * Whether a product belongs to the category products grid scope (direct parent or nested tree). - */ - public function isProductInCategoryScope(int $productId, int $categoryId, bool $nested): bool - { - $product = $this->modx->getObject(msProduct::class, $productId); - if (!$product) { - return false; - } - - return CategoryProductScopePolicy::isParentInScope( - (int) $product->get('parent'), - $categoryId, - $nested, - $nested ? $this->treeService()->getDescendantCategoryIds($categoryId) : [] - ); - } - /** * @param list $optionFieldNames Allowed option field names (whitelist) * @@ -304,4 +276,16 @@ private function formatProductRow(array $row, bool $nested, array $optionFieldNa return $data; } + + private function getCategoryProductScopeService(): CategoryProductScopeService + { + if ($this->modx->services->has('ms3_category_product_scope')) { + $service = $this->modx->services->get('ms3_category_product_scope'); + if ($service instanceof CategoryProductScopeService) { + return $service; + } + } + + return new CategoryProductScopeService($this->modx); + } } diff --git a/core/components/minishop3/tests/CategoryProductScopeServiceTest.php b/core/components/minishop3/tests/CategoryProductScopeServiceTest.php index 81094b32..d11be262 100644 --- a/core/components/minishop3/tests/CategoryProductScopeServiceTest.php +++ b/core/components/minishop3/tests/CategoryProductScopeServiceTest.php @@ -1,7 +1,7 @@ 10, 'parent' => 1], $directCall, 'direct lookup uses id and parent'); +$parsed = CategoryProductScopeService::parseParentSpec('10, 20 ,-30,0,abc'); +$assertSame([10, 20], $parsed['include'], 'parse include'); +$assertSame([30], $parsed['exclude'], 'parse exclude'); + +$emptyParsed = CategoryProductScopeService::parseParentSpec(''); +$assertSame([], $emptyParsed['include'], 'empty include'); +$assertSame([], $emptyParsed['exclude'], 'empty exclude'); + +$finalized = CategoryProductScopeService::finalizeIncludedCategoryIds([10, 20, 30], [20, 99]); +$assertSame([10, 30], $finalized, 'finalize exclude diff'); + +$parentOnlySql = CategoryProductScopeService::buildMsProductsWhereSql([5, 12], []); +$assertSame( + '`msProduct`.`parent` IN (5,12)', + $parentOnlySql, + 'where parent only' +); + +$withMembersSql = CategoryProductScopeService::buildMsProductsWhereSql([5], [101, 102, 101]); +$assertSame( + '(`msProduct`.`parent` IN (5) OR `msProduct`.`id` IN (101,102))', + $withMembersSql, + 'where parent or member ids' +); + +try { + CategoryProductScopeService::buildMsProductsWhereSql([], [1]); + $fail('empty categoryIds must throw'); +} catch (\InvalidArgumentException) { + // expected +} + +$regressionSql = CategoryProductScopeService::buildMsProductsWhereSql([42], [999]); +if (!str_contains($regressionSql, 'OR `msProduct`.`id` IN (999)')) { + $fail('regression: member OR clause missing'); +} +if (!str_contains($regressionSql, '`msProduct`.`parent` IN (42)')) { + $fail('regression: parent IN clause missing'); +} + +$scopeWhere = CategoryProductScopeService::buildProductCategoryScopeWhere([7], []); +$assertSame(['msProduct.parent:IN' => [7]], $scopeWhere, 'admin scope parent only'); + +$scopeWhereMembers = CategoryProductScopeService::buildProductCategoryScopeWhere([7, 8], [100, 200]); +$assertSame( + [ + 'msProduct.parent:IN' => [7, 8], + 'OR:msProduct.id:IN' => [100, 200], + ], + $scopeWhereMembers, + 'admin scope parent or member ids' +); + fwrite(STDOUT, "OK: CategoryProductScopeServiceTest\n"); diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index f753fe59..1426b29f 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -381,7 +381,7 @@ parameters: - message: '#^Access to property \$services on an unknown class modX\.$#' identifier: class.notFound - count: 3 + count: 5 path: core/components/minishop3/elements/snippets/ms3_products.php -