Repository: PrestaShop/PrestaShop (PHP)

Issue:
Enable/disable discounts in bulk
# Context
Back office user can disable in bulk from the discount list.
A disabled discount is no longer applied (if it was the case).

# Mockup

![Image](https://github.com/user-attachments/assets/dc37cf8f-fc41-4fcd-b911-5266317a2852)

# Tests
- disable via bulk action => selection must be disabled (if enabled) + discount from the selection are no longer applied
- enable via bulk action => selection must be enabled (if disabled) + discount from the selection must be applied (automatic ones)



Fix (patch):
diff --git a/src/Adapter/Discount/CommandHandler/BulkUpdateDiscountsStatusHandler.php b/src/Adapter/Discount/CommandHandler/BulkUpdateDiscountsStatusHandler.php
new file mode 100644
index 0000000000000..91d34a3f3385c
--- /dev/null
+++ b/src/Adapter/Discount/CommandHandler/BulkUpdateDiscountsStatusHandler.php
@@ -0,0 +1,93 @@
+<?php
+/**
+ * Copyright since 2007 PrestaShop SA and Contributors
+ * PrestaShop is an International Registered Trademark & Property of PrestaShop SA
+ *
+ * NOTICE OF LICENSE
+ *
+ * This source file is subject to the Open Software License (OSL 3.0)
+ * that is bundled with this package in the file LICENSE.md.
+ * It is also available through the world-wide-web at this URL:
+ * https://opensource.org/licenses/OSL-3.0
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@prestashop.com so we can send you a copy immediately.
+ *
+ * DISCLAIMER
+ *
+ * Do not edit or add to this file if you wish to upgrade PrestaShop to newer
+ * versions in the future. If you wish to customize PrestaShop for your
+ * needs please refer to https://devdocs.prestashop.com/ for more information.
+ *
+ * @author    PrestaShop SA and Contributors <contact@prestashop.com>
+ * @copyright Since 2007 PrestaShop SA and Contributors
+ * @license   https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
+ */
+
+namespace PrestaShop\PrestaShop\Adapter\Discount\CommandHandler;
+
+use CartRule;
+use PrestaShop\PrestaShop\Core\CommandBus\Attributes\AsCommandHandler;
+use PrestaShop\PrestaShop\Core\Domain\AbstractBulkCommandHandler;
+use PrestaShop\PrestaShop\Core\Domain\Discount\Command\BulkUpdateDiscountsStatusCommand;
+use PrestaShop\PrestaShop\Core\Domain\Discount\CommandHandler\BulkUpdateDiscountsStatusHandlerInterface;
+use PrestaShop\PrestaShop\Core\Domain\Discount\Exception\BulkDiscountException;
+use PrestaShop\PrestaShop\Core\Domain\Discount\Exception\CannotUpdateDiscountException;
+use PrestaShop\PrestaShop\Core\Domain\Discount\Exception\DiscountException;
+use PrestaShop\PrestaShop\Core\Domain\Discount\Exception\DiscountNotFoundException;
+use PrestaShop\PrestaShop\Core\Domain\Discount\ValueObject\DiscountId;
+use PrestaShop\PrestaShop\Core\Domain\Exception\BulkCommandExceptionInterface;
+
+#[AsCommandHandler]
+final class BulkUpdateDiscountsStatusHandler extends AbstractBulkCommandHandler implements BulkUpdateDiscountsStatusHandlerInterface
+{
+    /**
+     * {@inheritdoc}
+     *
+     * @throws CannotUpdateDiscountException
+     * @throws DiscountNotFoundException
+     */
+    public function handle(BulkUpdateDiscountsStatusCommand $command): void
+    {
+        $this->handleBulkAction($command->getDiscountIds(), DiscountException::class, $command);
+    }
+
+    /**
+     * @param DiscountId $id
+     * @param BulkUpdateDiscountsStatusCommand $command
+     *
+     * @return void
+     */
+    protected function handleSingleAction(mixed $id, mixed $command): void
+    {
+        $entity = new CartRule($id->getValue());
+        $entity->active = $command->getNewStatus();
+
+        if (!$entity->id) {
+            throw new DiscountNotFoundException(sprintf('Discount with id "%s" was not found', $id->getValue()));
+        }
+        if (!$entity->update()) {
+            throw new CannotUpdateDiscountException(sprintf('Cannot update status for discount with id "%s"', $id->getValue()));
+        }
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    protected function buildBulkException(array $caughtExceptions): BulkCommandExceptionInterface
+    {
+        return new BulkDiscountException(
+            $caughtExceptions,
+            'Errors occurred during discount bulk change status action',
+            BulkDiscountException::FAILED_BULK_UPDATE_STATUS
+        );
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    protected function supports($id): bool
+    {
+        return $id instanceof DiscountId;
+    }
+}
diff --git a/src/Core/Domain/Discount/Command/BulkUpdateDiscountsStatusCommand.php b/src/Core/Domain/Discount/Command/BulkUpdateDiscountsStatusCommand.php
new file mode 100644
index 0000000000000..0a0afdf9a9fc8
--- /dev/null
+++ b/src/Core/Domain/Discount/Command/BulkUpdateDiscountsStatusCommand.php
@@ -0,0 +1,102 @@
+<?php
+/**
+ * Copyright since 2007 PrestaShop SA and Contributors
+ * PrestaShop is an International Registered Trademark & Property of PrestaShop SA
+ *
+ * NOTICE OF LICENSE
+ *
+ * This source file is subject to the Open Software License (OSL 3.0)
+ * that is bundled with this package in the file LICENSE.md.
+ * It is also available through the world-wide-web at this URL:
+ * https://opensource.org/licenses/OSL-3.0
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@prestashop.com so we can send you a copy immediately.
+ *
+ * DISCLAIMER
+ *
+ * Do not edit or add to this file if you wish to upgrade PrestaShop to newer
+ * versions in the future. If you wish to customize PrestaShop for your
+ * needs please refer to https://devdocs.prestashop.com/ for more information.
+ *
+ * @author    PrestaShop SA and Contributors <contact@prestashop.com>
+ * @copyright Since 2007 PrestaShop SA and Contributors
+ * @license   https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
+ */
+
+namespace PrestaShop\PrestaShop\Core\Domain\Discount\Command;
+
+use PrestaShop\PrestaShop\Core\Domain\Discount\Exception\DiscountConstraintException;
+use PrestaShop\PrestaShop\Core\Domain\Discount\ValueObject\DiscountId;
+
+/**
+ * Updates provided discounts to new status
+ */
+class BulkUpdateDiscountsStatusCommand
+{
+    /**
+     * @var DiscountId[]
+     */
+    private array $discountIds;
+
+    private bool $newStatus;
+
+    /**
+     * @param int[] $discountIds
+     * @param bool $newStatus
+     *
+     * @throws DiscountConstraintException
+     */
+    public function __construct(array $discountIds, bool $newStatus)
+    {
+        $this
+            ->setDiscountIds($discountIds)
+            ->setNewStatus($newStatus)
+        ;
+    }
+
+    /**
+     * @return DiscountId[]
+     */
+    public function getDiscountIds(): array
+    {
+        return $this->discountIds;
+    }
+
+    public function getNewStatus(): bool
+    {
+        return $this->newStatus;
+    }
+
+    /**
+     * @param int[] $discountIds
+     *
+     * @throws DiscountConstraintException
+     */
+    private function setDiscountIds(array $discountIds): self
+    {
+        if (empty($discountIds)) {
+            throw new DiscountConstraintException('Missing discounts data for status change');
+        }
+
+        foreach ($discountIds as $discountId) {
+            $this->discountIds[] = new DiscountId((int) $discountId);
+        }
+
+        return $this;
+    }
+
+    /**
+     * @throws DiscountConstraintException
+     */
+    private function setNewStatus(bool $newStatus): self
+    {
+        if (!is_bool($newStatus)) {
+            throw new DiscountConstraintException(sprintf('Discount status %s is invalid. Status must be of type "bool".', var_export($newStatus, true)), DiscountConstraintException::INVALID_STATUS);
+        }
+
+        $this->newStatus = $newStatus;
+
+        return $this;
+    }
+}
diff --git a/src/Core/Domain/Discount/CommandHandler/BulkUpdateDiscountsStatusHandlerInterface.php b/src/Core/Domain/Discount/CommandHandler/BulkUpdateDiscountsStatusHandlerInterface.php
new file mode 100644
index 0000000000000..0315fabc3d10e
--- /dev/null
+++ b/src/Core/Domain/Discount/CommandHandler/BulkUpdateDiscountsStatusHandlerInterface.php
@@ -0,0 +1,34 @@
+<?php
+/**
+ * Copyright since 2007 PrestaShop SA and Contributors
+ * PrestaShop is an International Registered Trademark & Property of PrestaShop SA
+ *
+ * NOTICE OF LICENSE
+ *
+ * This source file is subject to the Open Software License (OSL 3.0)
+ * that is bundled with this package in the file LICENSE.md.
+ * It is also available through the world-wide-web at this URL:
+ * https://opensource.org/licenses/OSL-3.0
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@prestashop.com so we can send you a copy immediately.
+ *
+ * DISCLAIMER
+ *
+ * Do not edit or add to this file if you wish to upgrade PrestaShop to newer
+ * versions in the future. If you wish to customize PrestaShop for your
+ * needs please refer to https://devdocs.prestashop.com/ for more information.
+ *
+ * @author    PrestaShop SA and Contributors <contact@prestashop.com>
+ * @copyright Since 2007 PrestaShop SA and Contributors
+ * @license   https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
+ */
+
+namespace PrestaShop\PrestaShop\Core\Domain\Discount\CommandHandler;
+
+use PrestaShop\PrestaShop\Core\Domain\Discount\Command\BulkUpdateDiscountsStatusCommand;
+
+interface BulkUpdateDiscountsStatusHandlerInterface
+{
+    public function handle(BulkUpdateDiscountsStatusCommand $command): void;
+}
diff --git a/src/Core/Domain/Discount/Exception/BulkDiscountException.php b/src/Core/Domain/Discount/Exception/BulkDiscountException.php
new file mode 100644
index 0000000000000..501b96f2d4fba
--- /dev/null
+++ b/src/Core/Domain/Discount/Exception/BulkDiscountException.php
@@ -0,0 +1,66 @@
+<?php
+/**
+ * Copyright since 2007 PrestaShop SA and Contributors
+ * PrestaShop is an International Registered Trademark & Property of PrestaShop SA
+ *
+ * NOTICE OF LICENSE
+ *
+ * This source file is subject to the Open Software License (OSL 3.0)
+ * that is bundled with this package in the file LICENSE.md.
+ * It is also available through the world-wide-web at this URL:
+ * https://opensource.org/licenses/OSL-3.0
+ * If you did not receive a copy of the license and are unable to
+ * obtain it through the world-wide-web, please send an email
+ * to license@prestashop.com so we can send you a copy immediately.
+ *
+ * DISCLAIMER
+ *
+ * Do not edit or add to this file if you wish to upgrade PrestaShop to newer
+ * versions in the future. If you wish to customize PrestaShop for your
+ * needs please refer to https://devdocs.prestashop.com/ for more information.
+ *
+ * @author    PrestaShop SA and Contributors <contact@prestashop.com>
+ * @copyright Since 2007 PrestaShop SA and Contributors
+ * @license   https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
+ */
+declare(strict_types=1);
+
+namespace PrestaShop\PrestaShop\Core\Domain\Discount\Exception;
+
+use PrestaShop\PrestaShop\Core\Domain\Exception\BulkCommandExceptionInterface;
+use Throwable;
+
+class BulkDiscountException extends DiscountException implements BulkCommandExceptionInterface
+{
+    public const FAILED_BULK_DELETE = 1;
+    public const FAILED_BULK_UPDATE_STATUS = 2;
+
+    /**
+     * @var Throwable[]
+     */
+    private array $exceptions;
+
+    /**
+     * @param Throwable[] $exceptions
+     * @param string $message
+     * @param int $code
+     * @param Throwable|null $previous
+     */
+    public function __construct(
+        array $exceptions,
+        string $message = 'Errors occurred during discount bulk action',
+        int $code = 0,
+        ?Throwable $previous = null
+    ) {
+        $this->exceptions = $exceptions;
+        parent::__construct($message, $code, $previous);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function getExceptions(): array
+    {
+        return $this->exceptions;
+    }
+}
diff --git a/src/Core/Grid/Definition/Factory/DiscountGridDefinitionFactory.php b/src/Core/Grid/Definition/Factory/DiscountGridDefinitionFactory.php
index b90435c6d05aa..4672e85e55eba 100644
--- a/src/Core/Grid/Definition/Factory/DiscountGridDefinitionFactory.php
+++ b/src/Core/Grid/Definition/Factory/DiscountGridDefinitionFactory.php
@@ -29,6 +29,8 @@
 namespace PrestaShop\PrestaShop\Core\Grid\Definition\Factory;
 
 use PrestaShop\PrestaShop\Core\Domain\Discount\DiscountSettings;
+use PrestaShop\PrestaShop\Core\Grid\Action\Bulk\BulkActionCollection;
+use PrestaShop\PrestaShop\Core\Grid\Action\Bulk\Type\SubmitBulkAction;
 use PrestaShop\PrestaShop\Core\Grid\Action\Row\RowActionCollection;
 use PrestaShop\PrestaShop\Core\Grid\Action\Row\Type\LinkRowAction;
 use PrestaShop\PrestaShop\Core\Grid\Column\ColumnCollection;
@@ -263,4 +265,27 @@ protected function getFilters(): FilterCollectionInterface
                     ->setAssociatedColumn('actions')
             );
     }
+
+    /**
+     * {@inheritdoc}
+     */
+    protected function getBulkActions()
+    {
+        return (new BulkActionCollection())
+            ->add(
+                (new SubmitBulkAction('enable_selection'))
+                    ->setName($this->trans('Enable selection', [], 'Admin.Actions'))
+                    ->setOptions([
+                        'submit_route' => 'admin_discount_bulk_enable_status',
+                    ])
+            )
+            ->add(
+                (new SubmitBulkAction('disable_selection'))
+                    ->setName($this->trans('Disable selection', [], 'Admin.Actions'))
+                    ->setOptions([
+                        'submit_route' => 'admin_discount_bulk_disable_status',
+                    ])
+            )
+        ;
+    }
 }
diff --git a/src/PrestaShopBundle/Controller/Admin/Sell/Catalog/DiscountController.php b/src/PrestaShopBundle/Controller/Admin/Sell/Catalog/DiscountController.php
index befba60f63272..7859d356879c1 100644
--- a/src/PrestaShopBundle/Controller/Admin/Sell/Catalog/DiscountController.php
+++ b/src/PrestaShopBundle/Controller/Admin/Sell/Catalog/DiscountController.php
@@ -29,7 +29,11 @@
 use Exception;
 use PrestaShop\PrestaShop\Core\Domain\CartRule\Command\ToggleCartRuleStatusCommand;
 use PrestaShop\PrestaShop\Core\Domain\CartRule\Exception\CartRuleException;
+use PrestaShop\PrestaShop\Core\Domain\Discount\Command\BulkUpdateDiscountsStatusCommand;
 use PrestaShop\PrestaShop\Core\Domain\Discount\Command\DeleteDiscountCommand;
+use PrestaShop\PrestaShop\Core\Domain\Discount\Exception\CannotUpdateDiscountException;
+use PrestaShop\PrestaShop\Core\Domain\Discount\Exception\DiscountConstraintException;
+use PrestaShop\PrestaShop\Core\Domain\Discount\Exception\DiscountException;
 use PrestaShop\PrestaShop\Core\Domain\Discount\Exception\DiscountNotFoundException;
 use PrestaShop\PrestaShop\Core\Domain\Discount\Query\GetDiscountForEditing;
 use PrestaShop\PrestaShop\Core\Domain\Discount\QueryResult\DiscountForEditing;
@@ -38,6 +42,7 @@
 use PrestaShop\PrestaShop\Core\Grid\GridFactoryInterface;
 use PrestaShop\PrestaShop\Core\Search\Filters\DiscountFilters;
 use PrestaShopBundle\Controller\Admin\PrestaShopAdminController;
+use PrestaShopBundle\Controller\BulkActionsTrait;
 use PrestaShopBundle\Form\Admin\Sell\Discount\DiscountTypeSelectorType;
 use PrestaShopBundle\Security\Attribute\AdminSecurity;
 use PrestaShopBundle\Security\Attribute\DemoRestricted;
@@ -48,6 +53,8 @@
 
 class DiscountController extends PrestaShopAdminController
 {
+    use BulkActionsTrait;
+
     /**
      * Displays discount listing page.
      *
@@ -235,6 +242,76 @@ public function deleteAction(int $discountId): RedirectResponse
     private function getErrorMessages(Exception $e): array
     {
         return [
+            DiscountNotFoundException::class => $this->trans('The object cannot be loaded (or found).', [], 'Admin.Notifications.Error'),
+            // todo: use more specific message for constraint exceptions
+            DiscountConstraintException::class => $e->getMessage(),
+            CannotUpdateDiscountException::class => $this->trans('An error occurred while updating the discount.', [], 'Admin.Notifications.Error'),
         ];
     }
+
+    /**
+     * @param Request $request
+     *
+     * @return int[]
+     */
+    private function getBulkDiscountsFromRequest(Request $request): array
+    {
+        return array_map('intval', $request->request->all('discount_bulk'));
+    }
+
+    /**
+     * Process bulk action for discount status enabling.
+     *
+     * @param Request $request
+     *
+     * @return RedirectResponse
+     */
+    #[DemoRestricted(redirectRoute: 'admin_discounts_index')]
+    #[AdminSecurity("is_granted('update', request.get('_legacy_controller')) && is_granted('create', request.get('_legacy_controller')) && is_granted('delete', request.get('_legacy_controller'))", redirectRoute: 'admin_discounts_index', message: 'You do not have permission to update this.')]
+    public function bulkEnableStatusAction(Request $request): RedirectResponse
+    {
+        return $this->bulkUpdateStatus($request, true);
+    }
+
+    /**
+     * Process bulk action for discount status disabling.
+     *
+     * @param Request $request
+     *
+     * @return RedirectResponse
+     */
+    #[DemoRestricted(redirectRoute: 'admin_discounts_index')]
+    #[AdminSecurity("is_granted('update', request.get('_legacy_controller')) && is_granted('create', request.get('_legacy_controller')) && is_granted('delete', request.get('_legacy_controller'))", redirectRoute: 'admin_discounts_index', message: 'You do not have permission to update this.')]
+    public function bulkDisableStatusAction(Request $request): RedirectResponse
+    {
+        return $this->bulkUpdateStatus($request, false);
+    }
+
+    /**
+     * Process bulk action for discount status enabling/disabling.
+     *
+     * @param Request $request
+     * @param bool $enable
+     *
+     * @return RedirectResponse
+     */
+    protected function bulkUpdateStatus(Request $request, bool $enable): RedirectResponse
+    {
+        try {
+            $discountIds = $this->getBulkDiscountsFromRequest($request);
+
+            $command = new BulkUpdateDiscountsStatusCommand($discountIds, $enable);
+
+            $this->dispatchCommand($command);
+
+            $this->addFlash(
+                'success',
+                $this->trans('The status has been successfully updated.', [], 'Admin.Notifications.Success')
+            );
+        } catch (DiscountException $e) {
+            $this->addFlash('error', $this->getErrorMessageForException($e, $this->getErrorMessages($e)));
+        }
+
+        return $this->redirectToRoute('admin_discounts_index');
+    }
 }
diff --git a/src/PrestaShopBundle/Resources/config/routing/admin/sell/catalog/discount.yml b/src/PrestaShopBundle/Resources/config/routing/admin/sell/catalog/discount.yml
index c67492ac1a6cd..84621b1719689 100644
--- a/src/PrestaShopBundle/Resources/config/routing/admin/sell/catalog/discount.yml
+++ b/src/PrestaShopBundle/Resources/config/routing/admin/sell/catalog/discount.yml
@@ -50,6 +50,24 @@ admin_discount_toggle_status:
   requirements:
     discountId: \d+
 
+admin_discount_bulk_enable_status:
+  path: /bulk-status-enable
+  methods: [ POST ]
+  defaults:
+    _controller: PrestaShopBundle\Controller\Admin\Sell\Catalog\DiscountController::bulkEnableStatusAction
+    _legacy_controller: AdminCartRules
+    _legacy_feature_flag: discount
+    _legacy_link: AdminCartRules:statuscart_rule
+
+admin_discount_bulk_disable_status:
+  path: /bulk-status-disable
+  methods: [ POST ]
+  defaults:
+    _controller: PrestaShopBundle\Controller\Admin\Sell\Catalog\DiscountController::bulkDisableStatusAction
+    _legacy_controller: AdminCartRules
+    _legacy_feature_flag: discount
+    _legacy_link: AdminCartRules:statuscart_rule
+
 admin_discounts_delete:
   path: /{discountId}/delete
   methods: [ POST, DELETE ]
diff --git a/src/PrestaShopBundle/Resources/config/services/adapter/discount.yml b/src/PrestaShopBundle/Resources/config/services/adapter/discount.yml
index f4eb7305df923..98aaabb022c06 100644
--- a/src/PrestaShopBundle/Resources/config/services/adapter/discount.yml
+++ b/src/PrestaShopBundle/Resources/config/services/adapter/discount.yml
@@ -15,6 +15,9 @@ services:
   PrestaShop\PrestaShop\Adapter\Discount\CommandHandler\UpdateDiscountConditionsHandler:
     autoconfigure: true
 
+  PrestaShop\PrestaShop\Adapter\Discount\CommandHandler\BulkUpdateDiscountsStatusHandler:
+    autoconfigure: true
+
   PrestaShop\PrestaShop\Adapter\Discount\Update\Filler\DiscountFiller:
     autoconfigure: true