diff --git a/CHANGELOG.md b/CHANGELOG.md index 7988d2b3e1..0723c88d20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 4.3.0 - Unreleased +- Sales and Discounts now support using related entries in their conditions. ([#3134](https://github.com/craftcms/commerce/issues/3134)) - It’s now possible to query products by shipping category and tax category. ([#3219](https://github.com/craftcms/commerce/issues/3219)) - It’s now possible to modify the purchasables shown in the add line item table on the Edit Order page. ([#3194](https://github.com/craftcms/commerce/issues/3194)) - Added `craft\commerce\events\ModifyPurchasablesQueryEvent`. diff --git a/src/controllers/DiscountsController.php b/src/controllers/DiscountsController.php index 4d2b87e2bd..5274ee4277 100644 --- a/src/controllers/DiscountsController.php +++ b/src/controllers/DiscountsController.php @@ -21,6 +21,7 @@ use craft\commerce\services\Coupons; use craft\commerce\web\assets\coupons\CouponsAsset; use craft\elements\Category; +use craft\elements\Entry; use craft\errors\MissingComponentException; use craft\helpers\ArrayHelper; use craft\helpers\DateTimeHelper; @@ -113,7 +114,7 @@ public function actionEdit(int $id = null, Discount $discount = null): Response /** * @throws HttpException */ - public function actionSave(): void + public function actionSave(): ?Response { $this->requirePostRequest(); @@ -183,7 +184,8 @@ public function actionSave(): void $discount->percentDiscount = -Localization::normalizePercentage($percentDiscount); // Set purchasable conditions - if ($discount->allPurchasables = (bool)$this->request->getBodyParam('allPurchasables')) { + $allPurchasables = !$this->request->getBodyParam('allPurchasables', false); + if ($discount->allPurchasables = $allPurchasables) { $discount->setPurchasableIds([]); } else { $purchasables = []; @@ -197,15 +199,21 @@ public function actionSave(): void $discount->setPurchasableIds($purchasables); } + // False in the allCategories param is true in the DB + $allCategories = !$this->request->getBodyParam('allCategories', false); // Set category conditions - if ($discount->allCategories = (bool)$this->request->getBodyParam('allCategories')) { + if ($discount->allCategories = $allCategories) { $discount->setCategoryIds([]); } else { - $categories = $this->request->getBodyParam('categories', []); - if (!$categories) { - $categories = []; + $relatedElements = []; + $relatedElementByType = $this->request->getBodyParam('relatedElements') ?: []; + foreach ($relatedElementByType as $type) { + if (is_array($type)) { + array_push($relatedElements, ...$type); + } } - $discount->setCategoryIds($categories); + $relatedElements = array_unique($relatedElements); + $discount->setCategoryIds($relatedElements); } $coupons = $this->request->getBodyParam('coupons') ?: []; @@ -214,7 +222,7 @@ public function actionSave(): void // Save it if (Plugin::getInstance()->getDiscounts()->saveDiscount($discount)) { $this->setSuccessFlash(Craft::t('commerce', 'Discount saved.')); - $this->redirectToPostedUrl($discount); + return $this->redirectToPostedUrl($discount); } else { $this->setFailFlash(Craft::t('commerce', 'Couldn’t save discount.')); @@ -230,6 +238,8 @@ public function actionSave(): void $this->_populateVariables($variables); Craft::$app->getUrlManager()->setRouteParams($variables); + + return null; } /** @@ -489,8 +499,12 @@ private function _populateVariables(array &$variables): void } $variables['categoryElementType'] = Category::class; + $variables['entryElementType'] = Entry::class; $variables['categories'] = null; + $variables['entries'] = null; + $categories = []; + $entries = []; if (empty($variables['id']) && $this->request->getParam('categoryIds')) { $categoryIds = explode('|', $this->request->getParam('categoryIds')); @@ -500,15 +514,22 @@ private function _populateVariables(array &$variables): void foreach ($categoryIds as $categoryId) { $id = (int)$categoryId; - $categories[] = Craft::$app->getElements()->getElementById($id); + $element = Craft::$app->getElements()->getElementById($id); + + if ($element instanceof Category) { + $categories[] = $element; + } elseif ($element instanceof Entry) { + $entries[] = $element; + } } $variables['categories'] = $categories; + $variables['entries'] = $entries; - $variables['categoryRelationshipTypeOptions'] = [ - DiscountRecord::CATEGORY_RELATIONSHIP_TYPE_SOURCE => Craft::t('commerce', 'Source - The category relationship field is on the purchasable'), - DiscountRecord::CATEGORY_RELATIONSHIP_TYPE_TARGET => Craft::t('commerce', 'Target - The purchasable relationship field is on the category'), - DiscountRecord::CATEGORY_RELATIONSHIP_TYPE_BOTH => Craft::t('commerce', 'Either (Default) - The relationship field is on the purchasable or the category'), + $variables['elementRelationshipTypeOptions'] = [ + DiscountRecord::CATEGORY_RELATIONSHIP_TYPE_SOURCE => Craft::t('commerce', 'The purchasable defines the relationship'), + DiscountRecord::CATEGORY_RELATIONSHIP_TYPE_TARGET => Craft::t('commerce', 'The purchasable is related by another element'), + DiscountRecord::CATEGORY_RELATIONSHIP_TYPE_BOTH => Craft::t('commerce', 'Either way'), ]; $variables['appliedTo'] = [ diff --git a/src/controllers/SalesController.php b/src/controllers/SalesController.php index 3dd2a2a823..e75b04036a 100644 --- a/src/controllers/SalesController.php +++ b/src/controllers/SalesController.php @@ -16,6 +16,7 @@ use craft\commerce\Plugin; use craft\commerce\records\Sale as SaleRecord; use craft\elements\Category; +use craft\elements\Entry; use craft\helpers\ArrayHelper; use craft\helpers\DateTimeHelper; use craft\helpers\Json; @@ -76,6 +77,8 @@ public function actionEdit(int $id = null, Sale $sale = null): Response $variables = compact('id', 'sale'); + $variables['isNewSale'] = false; + if (!$variables['sale']) { if ($variables['id']) { $variables['sale'] = Plugin::getInstance()->getSales()->getSaleById($variables['id']); @@ -85,6 +88,7 @@ public function actionEdit(int $id = null, Sale $sale = null): Response } } else { $variables['sale'] = new Sale(); + $variables['isNewSale'] = true; $variables['sale']->allCategories = true; $variables['sale']->allPurchasables = true; $variables['sale']->allGroups = true; @@ -152,7 +156,8 @@ public function actionSave(): ?Response } // Set purchasable conditions - if ($sale->allPurchasables = (bool)$this->request->getBodyParam('allPurchasables')) { + $allPurchasables = !$this->request->getBodyParam('allPurchasables', false); + if ($sale->allPurchasables = $allPurchasables) { $sale->setPurchasableIds([]); } else { $purchasables = []; @@ -165,15 +170,21 @@ public function actionSave(): ?Response $sale->setPurchasableIds($purchasables); } + // False in the allCategories param is true in the DB + $allCategories = !$this->request->getBodyParam('allCategories', false); // Set category conditions - if ($sale->allCategories = (bool)$this->request->getBodyParam('allCategories')) { + if ($sale->allCategories = $allCategories) { $sale->setCategoryIds([]); } else { - $categories = $this->request->getBodyParam('categories', []); - if (!$categories) { - $categories = []; + $relatedElements = []; + $relatedElementByType = $this->request->getBodyParam('relatedElements') ?: []; + foreach ($relatedElementByType as $type) { + if (is_array($type)) { + array_push($relatedElements, ...$type); + } } - $sale->setCategoryIds($categories); + $relatedElements = array_unique($relatedElements); + $sale->setCategoryIds($relatedElements); } // Set user group conditions @@ -458,8 +469,12 @@ private function _populateVariables(&$variables): void } $variables['categoryElementType'] = Category::class; + $variables['entryElementType'] = Entry::class; $variables['categories'] = null; + $variables['entries'] = null; + $categories = []; + $entries = []; if (empty($variables['id']) && $this->request->getParam('categoryIds')) { $categoryIds = explode('|', $this->request->getParam('categoryIds')); @@ -469,15 +484,22 @@ private function _populateVariables(&$variables): void foreach ($categoryIds as $categoryId) { $id = (int)$categoryId; - $categories[] = Craft::$app->getElements()->getElementById($id); + $element = Craft::$app->getElements()->getElementById($id); + + if ($element instanceof Category) { + $categories[] = $element; + } elseif ($element instanceof Entry) { + $entries[] = $element; + } } $variables['categories'] = $categories; + $variables['entries'] = $entries; - $variables['categoryRelationshipType'] = [ - SaleRecord::CATEGORY_RELATIONSHIP_TYPE_SOURCE => Craft::t('commerce', 'Source - The category relationship field is on the purchasable'), - SaleRecord::CATEGORY_RELATIONSHIP_TYPE_TARGET => Craft::t('commerce', 'Target - The purchasable relationship field is on the category'), - SaleRecord::CATEGORY_RELATIONSHIP_TYPE_BOTH => Craft::t('commerce', 'Either (Default) - The relationship field is on the purchasable or the category'), + $variables['elementRelationshipTypeOptions'] = [ + SaleRecord::CATEGORY_RELATIONSHIP_TYPE_SOURCE => Craft::t('commerce', 'The purchasable defines the relationship'), + SaleRecord::CATEGORY_RELATIONSHIP_TYPE_TARGET => Craft::t('commerce', 'The purchasable is related by another element'), + SaleRecord::CATEGORY_RELATIONSHIP_TYPE_BOTH => Craft::t('commerce', 'Either way'), ]; $variables['purchasables'] = null; diff --git a/src/migrations/Install.php b/src/migrations/Install.php index 3edc055cc6..6b283dc57c 100644 --- a/src/migrations/Install.php +++ b/src/migrations/Install.php @@ -130,6 +130,7 @@ public function createTables(): void 'uid' => $this->uid(), ]); + // TODO: rename to `discount_entries` table in Commerce 5 or remove if purchasable condition builder can replace it $this->archiveTableIfExists(Table::DISCOUNT_CATEGORIES); $this->createTable(Table::DISCOUNT_CATEGORIES, [ 'id' => $this->primaryKey(), @@ -558,6 +559,7 @@ public function createTables(): void 'uid' => $this->uid(), ]); + // TODO: rename to `sale_entries` table in Commerce 5 or remove if purchasable condition builder can replace it $this->archiveTableIfExists(Table::SALE_CATEGORIES); $this->createTable(Table::SALE_CATEGORIES, [ 'id' => $this->primaryKey(), @@ -935,7 +937,7 @@ public function addForeignKeys(): void $this->addForeignKey(null, Table::CUSTOMERS, ['primaryPaymentSourceId'], Table::PAYMENTSOURCES, ['id'], 'SET NULL'); $this->addForeignKey(null, Table::CUSTOMER_DISCOUNTUSES, ['customerId'], CraftTable::ELEMENTS, ['id'], 'CASCADE', 'CASCADE'); $this->addForeignKey(null, Table::CUSTOMER_DISCOUNTUSES, ['discountId'], Table::DISCOUNTS, ['id'], 'CASCADE', 'CASCADE'); - $this->addForeignKey(null, Table::DISCOUNT_CATEGORIES, ['categoryId'], '{{%categories}}', ['id'], 'CASCADE', 'CASCADE'); + $this->addForeignKey(null, Table::DISCOUNT_CATEGORIES, ['categoryId'], CraftTable::ELEMENTS, ['id'], 'CASCADE', 'CASCADE'); $this->addForeignKey(null, Table::DISCOUNT_CATEGORIES, ['discountId'], Table::DISCOUNTS, ['id'], 'CASCADE', 'CASCADE'); $this->addForeignKey(null, Table::DISCOUNT_PURCHASABLES, ['discountId'], Table::DISCOUNTS, ['id'], 'CASCADE', 'CASCADE'); $this->addForeignKey(null, Table::DISCOUNT_PURCHASABLES, ['purchasableId'], Table::PURCHASABLES, ['id'], 'CASCADE', 'CASCADE'); @@ -980,7 +982,7 @@ public function addForeignKeys(): void $this->addForeignKey(null, Table::PRODUCTTYPES_TAXCATEGORIES, ['productTypeId'], Table::PRODUCTTYPES, ['id'], 'CASCADE'); $this->addForeignKey(null, Table::PRODUCTTYPES_TAXCATEGORIES, ['taxCategoryId'], Table::TAXCATEGORIES, ['id'], 'CASCADE'); $this->addForeignKey(null, Table::PURCHASABLES, ['id'], '{{%elements}}', ['id'], 'CASCADE'); - $this->addForeignKey(null, Table::SALE_CATEGORIES, ['categoryId'], '{{%categories}}', ['id'], 'CASCADE', 'CASCADE'); + $this->addForeignKey(null, Table::SALE_CATEGORIES, ['categoryId'], CraftTable::ELEMENTS, ['id'], 'CASCADE', 'CASCADE'); $this->addForeignKey(null, Table::SALE_CATEGORIES, ['saleId'], Table::SALES, ['id'], 'CASCADE', 'CASCADE'); $this->addForeignKey(null, Table::SALE_PURCHASABLES, ['purchasableId'], Table::PURCHASABLES, ['id'], 'CASCADE', 'CASCADE'); $this->addForeignKey(null, Table::SALE_PURCHASABLES, ['saleId'], Table::SALES, ['id'], 'CASCADE', 'CASCADE'); diff --git a/src/migrations/m230724_080855_entrify_promotions.php b/src/migrations/m230724_080855_entrify_promotions.php new file mode 100644 index 0000000000..96084443f1 --- /dev/null +++ b/src/migrations/m230724_080855_entrify_promotions.php @@ -0,0 +1,43 @@ +db); + Db::dropForeignKeyIfExists(Table::DISCOUNT_CATEGORIES, ['discountId'], $this->db); + Db::dropForeignKeyIfExists(Table::SALE_CATEGORIES, ['categoryId'], $this->db); + Db::dropForeignKeyIfExists(Table::SALE_CATEGORIES, ['saleId'], $this->db); + + // Add the FKs back but to the Elements table not the categories table + $this->addForeignKey(null, Table::DISCOUNT_CATEGORIES, ['categoryId'], CraftTable::ELEMENTS, ['id'], 'CASCADE', 'CASCADE'); + $this->addForeignKey(null, Table::DISCOUNT_CATEGORIES, ['discountId'], Table::DISCOUNTS, ['id'], 'CASCADE', 'CASCADE'); + $this->addForeignKey(null, Table::SALE_CATEGORIES, ['categoryId'], CraftTable::ELEMENTS, ['id'], 'CASCADE', 'CASCADE'); + $this->addForeignKey(null, Table::SALE_CATEGORIES, ['saleId'], Table::SALES, ['id'], 'CASCADE', 'CASCADE'); + + return true; + } + + /** + * @inheritdoc + */ + public function safeDown(): bool + { + echo "m230724_080855_entrify_promotions cannot be reverted.\n"; + return false; + } +} diff --git a/src/models/Discount.php b/src/models/Discount.php index 819da94293..c4d52f7abc 100644 --- a/src/models/Discount.php +++ b/src/models/Discount.php @@ -190,11 +190,15 @@ class Discount extends Model /** * @var bool Match all product types + * + * TODO: Rename to $allEntries in Commerce 5 */ public bool $allCategories = false; /** * @var string Type of relationship between Categories and Products + * + * TODO: Rename to $entryRelationshipType in Commerce 5 */ public string $categoryRelationshipType = DiscountRecord::CATEGORY_RELATIONSHIP_TYPE_BOTH; diff --git a/src/services/Discounts.php b/src/services/Discounts.php index 00287a83f3..bbdb9a7f43 100644 --- a/src/services/Discounts.php +++ b/src/services/Discounts.php @@ -29,6 +29,7 @@ use craft\commerce\records\EmailDiscountUse as EmailDiscountUseRecord; use craft\db\Query; use craft\elements\Category; +use craft\elements\Entry; use craft\elements\User; use craft\helpers\ArrayHelper; use craft\helpers\DateTimeHelper; @@ -423,8 +424,10 @@ public function getDiscountsRelatedToPurchasable(PurchasableInterface $purchasab $relatedTo = [$discount->categoryRelationshipType => $purchasable->getPromotionRelationSource()]; $categoryIds = $discount->getCategoryIds(); $relatedCategories = Category::find()->id($categoryIds)->relatedTo($relatedTo)->ids(); + $relatedEntries = Entry::find()->id($categoryIds)->relatedTo($relatedTo)->ids(); + $relatedCategoriesOrEntries = array_merge($relatedCategories, $relatedEntries); - if (in_array($id, $purchasableIds, false) || !empty($relatedCategories)) { + if (in_array($id, $purchasableIds, false) || !empty($relatedCategoriesOrEntries)) { $discounts[$discount->id] = $discount; } } @@ -459,13 +462,18 @@ public function matchLineItem(LineItem $lineItem, Discount $discount, bool $matc return false; } + // TODO: Rename to allEntries in Commerce 5 if (!$discount->allCategories) { $key = 'relationshipType:' . $discount->categoryRelationshipType . ':purchasableId:' . $purchasable->getId() . ':categoryIds:' . implode('|', $discount->getCategoryIds()); if (!isset($this->_matchingLineItemCategoryCondition[$key])) { $relatedTo = [$discount->categoryRelationshipType => $purchasable->getPromotionRelationSource()]; + + $relatedEntries = Entry::find()->relatedTo($relatedTo)->ids(); $relatedCategories = Category::find()->relatedTo($relatedTo)->ids(); - $purchasableIsRelateToOneOrMoreCategories = (bool)array_intersect($relatedCategories, $discount->getCategoryIds()); + + $relatedCategoriesOrEntries = array_merge($relatedEntries, $relatedCategories); + $purchasableIsRelateToOneOrMoreCategories = (bool)array_intersect($relatedCategoriesOrEntries, $discount->getCategoryIds()); if (!$purchasableIsRelateToOneOrMoreCategories) { return $this->_matchingLineItemCategoryCondition[$key] = false; } @@ -1103,7 +1111,7 @@ private function _populateDiscounts(array $discounts): array } $purchasables = []; - $categories = []; + $categoriesOrEntries = []; foreach ($discounts as $discount) { $id = $discount['id']; @@ -1112,7 +1120,7 @@ private function _populateDiscounts(array $discounts): array } if ($discount['categoryId']) { - $categories[$id][] = $discount['categoryId']; + $categoriesOrEntries[$id][] = $discount['categoryId']; } unset($discount['purchasableId'], $discount['categoryId']); @@ -1124,7 +1132,7 @@ private function _populateDiscounts(array $discounts): array foreach ($allDiscountsById as $id => $discount) { $discount->setPurchasableIds($purchasables[$id] ?? []); - $discount->setCategoryIds($categories[$id] ?? []); + $discount->setCategoryIds($categoriesOrEntries[$id] ?? []); } return $allDiscountsById; diff --git a/src/services/Sales.php b/src/services/Sales.php index 7c31bd0e57..2a7d34f23a 100644 --- a/src/services/Sales.php +++ b/src/services/Sales.php @@ -22,6 +22,7 @@ use craft\commerce\records\SaleUserGroup as SaleUserGroupRecord; use craft\db\Query; use craft\elements\Category; +use craft\elements\Entry; use craft\helpers\ArrayHelper; use DateTime; use yii\base\Component; @@ -288,9 +289,12 @@ public function getSalesRelatedToPurchasable(PurchasableInterface $purchasable): // Get related via category $relatedTo = [$sale->categoryRelationshipType => $purchasable->getPromotionRelationSource()]; $saleCategories = $sale->getCategoryIds(); + $relatedCategories = Category::find()->id($saleCategories)->relatedTo($relatedTo)->ids(); + $relatedEntries = Entry::find()->id($saleCategories)->relatedTo($relatedTo)->ids(); + $relatedCategoriesOrEntries = array_merge($relatedCategories, $relatedEntries); - if (in_array($id, $purchasableIds, false) || !empty($relatedCategories)) { + if (in_array($id, $purchasableIds, false) || !empty($relatedCategoriesOrEntries)) { $sales[] = $sale; } } @@ -424,7 +428,7 @@ public function matchPurchasableAndSale(PurchasableInterface $purchasable, Sale return false; } // User groups of the order's user - $userGroups = ArrayHelper::getColumn($user->getGroups(),'id'); + $userGroups = ArrayHelper::getColumn($user->getGroups(), 'id'); if (!$userGroups || !array_intersect($userGroups, $sale->getUserGroupIds())) { return false; } @@ -436,7 +440,7 @@ public function matchPurchasableAndSale(PurchasableInterface $purchasable, Sale // User groups of the currently logged in user $userGroups = null; if ($currentUser = Craft::$app->getUser()->getIdentity()) { - $userGroups = ArrayHelper::getColumn($currentUser->getGroups(),'id'); + $userGroups = ArrayHelper::getColumn($currentUser->getGroups(), 'id'); } if (!$userGroups || !array_intersect($userGroups, $sale->getUserGroupIds())) { @@ -449,8 +453,9 @@ public function matchPurchasableAndSale(PurchasableInterface $purchasable, Sale $relatedTo = [$sale->categoryRelationshipType => $purchasable->getPromotionRelationSource()]; $saleCategories = $sale->getCategoryIds(); $relatedCategories = Category::find()->id($saleCategories)->relatedTo($relatedTo)->ids(); - - if (empty($relatedCategories)) { + $relatedEntries = Entry::find()->id($saleCategories)->relatedTo($relatedTo)->ids(); + $relatedCategoriesOrEntries = array_merge($relatedCategories, $relatedEntries); + if (empty($relatedCategoriesOrEntries)) { return false; } } diff --git a/src/templates/promotions/discounts/_edit.twig b/src/templates/promotions/discounts/_edit.twig index f7970b0012..936b3c4247 100644 --- a/src/templates/promotions/discounts/_edit.twig +++ b/src/templates/promotions/discounts/_edit.twig @@ -7,11 +7,25 @@ { label: "Discounts"|t('commerce'), url: url('commerce/promotions/discounts') }, ] %} -{% set fullPageForm = false %} +{% set fullPageForm = true %} {% import "_includes/forms" as forms %} {% import "commerce/_includes/forms/commerceForms" as commerceForms %} +{% set mainFormAttributes = { + id: 'discountform', + method: 'post', + 'accept-charset': 'UTF-8' +} %} + +{% set formActions = [ + { + label: 'Save and continue editing'|t('app'), + redirect: (isNewDiscount ? 'commerce/promotions/discounts/{id}' : discount.getCpEditUrl())|hash, + retainScroll: true, + shortcut: true, + }] +%} {% set discountClasses = "" %} {% if (discount.getErrors('name')) %} @@ -23,8 +37,6 @@ {% set matchingItemsClasses = "error" %} {% endif %} -{% set conditionRulesClasses = "" %} - {% set conditionsClasses = "" %} {% if(discount.getErrors('startDate') or discount.getErrors('endDate')) %} {% set conditionsClasses = "error" %} @@ -39,7 +51,6 @@ discount: {'label':'Discount'|t('commerce'),'url':'#discount','class':discountClasses}, coupons: {'label':'Coupons'|t('commerce'),'url':'#coupons','class':couponClasses}, matchingItems: {'label':'Matching Items'|t('commerce'),'url':'#matching-items','class':matchingItemsClasses}, - conditionRules: {'label':'Conditions Rules'|t('commerce'),'url':'#condition-rules','class':conditionRulesClasses}, conditions: {'label':'Conditions'|t('commerce'),'url':'#conditions','class':conditionsClasses}, actions: {'label':'Actions'|t('commerce'),'url':'#actions'} } %} @@ -101,44 +112,16 @@ {% hook "cp.commerce.discounts.edit.details" %} {% endblock %} -{% block actionButton %} -