feature #15854 Archiving Cart Promotion (Wojdylak)

This PR was merged into the 1.13 branch.

Discussion
----------

| Q               | A                                                            |
|-----------------|--------------------------------------------------------------|
| Branch?         | 1.13
| Bug fix?        | no
| New feature?    | yes
| BC breaks?      | no
| Deprecations?   | no
| Related tickets | #13475
| License         | MIT

Based on #15375, I have split this PR into "Archiving Cart Promotion" and "Archiving Catalog Promotion"
There  are two cases to consider:
- Priority: it cannot be null and is assigned a "gedmo:sortable-position". This means that when we archive cart promotion, it still  influences the position of all promotions.
- API changes: I have added added PATCH operations, which removes all null values from responses in promotions. So we need to decide whether to add `skip_null_values: false` across all operations or not?

Commits
-------
  [Promotion] Add archivedAt to Promotion
  [Promotion] Add promotion archiving
  [Promotion][Behat] Add promotion archiving tests
  [Promotion] Add PromotionArchivalEligibilityChecker class
  [Api][Promotion] Add archivedAt to promotion
  Fix phpstan
  [Behat][Ui] Add a method to check if a promotion exists without opening the index page
  [Behat][Ui] Add missing method
  [Behat] Move archived promotion test to receiving_discount folder
This commit is contained in:
Jan Góralski 2024-02-22 17:58:12 +01:00 committed by GitHub
commit 8e6b5915de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 825 additions and 5 deletions

View file

@ -73,3 +73,14 @@ Feature: Order promotions integrity
And I proceeded with "Free" shipping method and "Offline" payment method And I proceeded with "Free" shipping method and "Offline" payment method
When I confirm my order When I confirm my order
Then I should see the thank you page Then I should see the thank you page
@ui @api
Scenario: Preventing customer from completing checkout with already archived promotion
Given this promotion gives "$10.00" discount to every order
And I added product "PHP T-Shirt" to the cart
And I have specified the billing address as "Ankh Morpork", "Frost Alley", "90210", "United States" for "Jon Snow"
And I proceeded with "Free" shipping method and "Offline" payment method
And the promotion "Christmas sale" is archived
When I try to confirm my order
Then I should be informed that this promotion is no longer applied
And I should not see the thank you page

View file

@ -0,0 +1,41 @@
@managing_promotions
Feature: Archiving promotions
In order to hide no longer available promotions from the list and customers' use
As an Administrator
I want to archive promotions
Background:
Given the store operates on a single channel in "United States"
And there is a promotion "Christmas sale"
And there is also a promotion "New Year sale"
And I am logged in as an administrator
@api @ui
Scenario: Archiving a promotion
When I browse promotions
And I archive the "Christmas sale" promotion
Then I should see a single promotion in the list
And I should see the promotion "New Year sale" in the list
@domain
Scenario: Archiving a promotion does not remove it from the database
When I archive the "Christmas sale" promotion
Then the promotion "Christmas sale" should still exist in the registry
@api @ui
Scenario: Seeing only archived promotions
Given the promotion "Christmas sale" is archived
When I browse promotions
And I filter archival promotions
Then I should see a single promotion in the list
And I should see the promotion "Christmas sale" in the list
And I should not see the promotion "New Year sale" in the list
@api @ui
Scenario: Restoring an archival promotion
Given the promotion "Christmas sale" is archived
When I browse promotions
And I filter archival promotions
And I restore the "Christmas sale" promotion
Then I should be viewing non archival promotions
And I should see 2 promotions on the list

View file

@ -0,0 +1,19 @@
@receiving_discount
Feature: Not applying archived promotions to the cart
In order to not apply promotions that are archived
As a Visitor
I want to not receive discounts from archived promotions
Background:
Given the store operates on a single channel in "United States"
And the store has a product "PHP T-Shirt" priced at "$100.00"
And there is a promotion "Christmas sale"
And this promotion gives "$10.00" discount to every order
@api @ui
Scenario: Archived promotion is not applied to the cart
Given the promotion "Christmas sale" is archived
When I add product "PHP T-Shirt" to the cart
Then I should be on my cart summary page
And I should be notified that the product has been successfully added
And my cart total should be "$100.00"

View file

@ -34,6 +34,7 @@ use Sylius\Component\Core\Promotion\Checker\Rule\HasTaxonRuleChecker;
use Sylius\Component\Core\Promotion\Checker\Rule\TotalOfItemsFromTaxonRuleChecker; use Sylius\Component\Core\Promotion\Checker\Rule\TotalOfItemsFromTaxonRuleChecker;
use Sylius\Component\Customer\Model\CustomerGroupInterface; use Sylius\Component\Customer\Model\CustomerGroupInterface;
use Sylius\Component\Promotion\Checker\Rule\ItemTotalRuleChecker; use Sylius\Component\Promotion\Checker\Rule\ItemTotalRuleChecker;
use Symfony\Component\HttpFoundation\Request;
use Webmozart\Assert\Assert; use Webmozart\Assert\Assert;
final class ManagingPromotionsContext implements Context final class ManagingPromotionsContext implements Context
@ -73,6 +74,23 @@ final class ManagingPromotionsContext implements Context
$this->client->buildUpdateRequest(Resources::PROMOTIONS, $promotion->getCode()); $this->client->buildUpdateRequest(Resources::PROMOTIONS, $promotion->getCode());
} }
/**
* @When I archive the :promotion promotion
*/
public function iArchiveThePromotion(PromotionInterface $promotion): void
{
$this->client->customItemAction(Resources::PROMOTIONS, $promotion->getCode(), Request::METHOD_PATCH, 'archive');
$this->client->index(Resources::PROMOTIONS);
}
/**
* @When I restore the :promotion promotion
*/
public function iRestoreThePromotion(PromotionInterface $promotion): void
{
$this->client->customItemAction(Resources::PROMOTIONS, $promotion->getCode(), Request::METHOD_PATCH, 'restore');
}
/** /**
* @When I specify its :field as :value * @When I specify its :field as :value
* @When I do not specify its :field * @When I do not specify its :field
@ -410,6 +428,15 @@ final class ManagingPromotionsContext implements Context
$this->client->filter(); $this->client->filter();
} }
/**
* @When I filter archival promotions
*/
public function iFilterArchivalPromotions(): void
{
$this->client->addFilter('exists[archivedAt]', true);
$this->client->filter();
}
/** /**
* @When I add it * @When I add it
* @When I try to add it * @When I try to add it
@ -456,6 +483,17 @@ final class ManagingPromotionsContext implements Context
); );
} }
/**
* @Then I should not see the promotion :promotionName in the list
*/
public function iShouldNotSeeThePromotionInTheList(string $promotionName): void
{
Assert::false(
$this->responseChecker->hasItemWithValue($this->client->getLastResponse(), 'name', $promotionName),
sprintf('Promotion with name %s does not exist', $promotionName),
);
}
/** /**
* @Then /^(this promotion) should be coupon based$/ * @Then /^(this promotion) should be coupon based$/
*/ */
@ -847,6 +885,14 @@ final class ManagingPromotionsContext implements Context
); );
} }
/**
* @Then I should be viewing non archival promotions
*/
public function iShouldBeViewingNonArchivalPromotions(): void
{
$this->client->index(Resources::PROMOTIONS);
}
private function addToRequestAction(string $type, array $configuration): void private function addToRequestAction(string $type, array $configuration): void
{ {
$data['actions'][] = [ $data['actions'][] = [

View file

@ -15,6 +15,7 @@ namespace Sylius\Behat\Context\Domain;
use Behat\Behat\Context\Context; use Behat\Behat\Context\Context;
use Doctrine\DBAL\Exception\ForeignKeyConstraintViolationException; use Doctrine\DBAL\Exception\ForeignKeyConstraintViolationException;
use Doctrine\Persistence\ObjectManager;
use Sylius\Behat\Service\SharedStorageInterface; use Sylius\Behat\Service\SharedStorageInterface;
use Sylius\Component\Promotion\Model\PromotionInterface; use Sylius\Component\Promotion\Model\PromotionInterface;
use Sylius\Component\Promotion\Repository\PromotionRepositoryInterface; use Sylius\Component\Promotion\Repository\PromotionRepositoryInterface;
@ -25,6 +26,7 @@ final class ManagingPromotionsContext implements Context
public function __construct( public function __construct(
private SharedStorageInterface $sharedStorage, private SharedStorageInterface $sharedStorage,
private PromotionRepositoryInterface $promotionRepository, private PromotionRepositoryInterface $promotionRepository,
private ObjectManager $promotionManager,
) { ) {
} }
@ -48,6 +50,16 @@ final class ManagingPromotionsContext implements Context
} }
} }
/**
* @When I archive the :promotion promotion
*/
public function iArchiveThePromotion(PromotionInterface $promotion): void
{
$promotion->setArchivedAt(new \DateTime());
$this->promotionManager->flush();
}
/** /**
* @Then /^(this promotion) should no longer exist in the promotion registry$/ * @Then /^(this promotion) should no longer exist in the promotion registry$/
*/ */
@ -71,4 +83,12 @@ final class ManagingPromotionsContext implements Context
{ {
Assert::isInstanceOf($this->sharedStorage->get('last_exception'), ForeignKeyConstraintViolationException::class); Assert::isInstanceOf($this->sharedStorage->get('last_exception'), ForeignKeyConstraintViolationException::class);
} }
/**
* @Then the promotion :promotion should still exist in the registry
*/
public function thePromotionShouldStillExistInTheRegistry(PromotionInterface $promotion): void
{
Assert::notNull($this->promotionRepository->find($promotion));
}
} }

View file

@ -281,6 +281,16 @@ final class PromotionContext implements Context
$this->objectManager->flush(); $this->objectManager->flush();
} }
/**
* @Given the promotion :promotion is archived
*/
public function thisPromotionIsArchived(PromotionInterface $promotion): void
{
$promotion->setArchivedAt(new \DateTime());
$this->objectManager->flush();
}
/** /**
* @Given /^(this coupon) has already expired$/ * @Given /^(this coupon) has already expired$/
*/ */

View file

@ -88,7 +88,6 @@ final class ManagingPromotionsContext implements Context
} }
/** /**
* @Then I should see the promotion :promotionName in the list
* @Then the :promotionName promotion should appear in the registry * @Then the :promotionName promotion should appear in the registry
* @Then the :promotionName promotion should exist in the registry * @Then the :promotionName promotion should exist in the registry
* @Then this promotion should still be named :promotionName * @Then this promotion should still be named :promotionName
@ -278,6 +277,33 @@ final class ManagingPromotionsContext implements Context
$this->indexPage->bulkDelete(); $this->indexPage->bulkDelete();
} }
/**
* @When I archive the :promotionName promotion
*/
public function iArchiveThePromotion(string $promotionName): void
{
$actions = $this->indexPage->getActionsForResource(['name' => $promotionName]);
$actions->pressButton('Archive');
}
/**
* @When I restore the :promotionName promotion
*/
public function iRestoreThePromotion(string $promotionName): void
{
$actions = $this->indexPage->getActionsForResource(['name' => $promotionName]);
$actions->pressButton('Restore');
}
/**
* @When I filter archival promotions
*/
public function iFilterArchivalPromotions(): void
{
$this->indexPage->chooseArchival('Yes');
$this->indexPage->filter();
}
/** /**
* @Then I should see a single promotion in the list * @Then I should see a single promotion in the list
* @Then there should be :amount promotions * @Then there should be :amount promotions
@ -829,6 +855,30 @@ final class ManagingPromotionsContext implements Context
); );
} }
/**
* @Then I should see the promotion :promotionName in the list
*/
public function iShouldSeeThePromotionInTheList(string $promotionName): void
{
Assert::true($this->indexPage->isSingleResourceOnPage(['name' => $promotionName]));
}
/**
* @Then I should not see the promotion :promotionName in the list
*/
public function iShouldNotSeeThePromotionInTheList(string $promotionName): void
{
Assert::false($this->indexPage->isSingleResourceOnPage(['name' => $promotionName]));
}
/**
* @Then I should be viewing non archival promotions
*/
public function iShouldBeViewingNonArchivalPromotions(): void
{
Assert::false($this->indexPage->isArchivalFilterEnabled());
}
private function assertFieldValidationMessage(string $element, string $expectedMessage) private function assertFieldValidationMessage(string $element, string $expectedMessage)
{ {
/** @var CreatePageInterface|UpdatePageInterface $currentPage */ /** @var CreatePageInterface|UpdatePageInterface $currentPage */

View file

@ -50,6 +50,23 @@ class IndexPage extends BaseIndexPage implements IndexPageInterface
$this->getDocument()->fillField(sprintf('criteria_%s_value', $field), $value); $this->getDocument()->fillField(sprintf('criteria_%s_value', $field), $value);
} }
public function chooseArchival(string $isArchival): void
{
$this->getElement('filter_archival')->selectOption($isArchival);
}
public function isArchivalFilterEnabled(): bool
{
return '1' === $this->getElement('filter_archival')->getValue();
}
protected function getDefinedElements(): array
{
return array_merge(parent::getDefinedElements(), [
'filter_archival' => '#criteria_archival',
]);
}
private function getPromotionFieldsWithHeader(PromotionInterface $promotion, string $header): NodeElement private function getPromotionFieldsWithHeader(PromotionInterface $promotion, string $header): NodeElement
{ {
$tableAccessor = $this->getTableAccessor(); $tableAccessor = $this->getTableAccessor();

View file

@ -23,4 +23,8 @@ interface IndexPageInterface extends BaseIndexPageInterface
public function isAbleToManageCouponsFor(PromotionInterface $promotion): bool; public function isAbleToManageCouponsFor(PromotionInterface $promotion): bool;
public function isCouponBasedFor(PromotionInterface $promotion): bool; public function isCouponBasedFor(PromotionInterface $promotion): bool;
public function chooseArchival(string $isArchival): void;
public function isArchivalFilterEnabled(): bool;
} }

View file

@ -49,6 +49,7 @@
<service id="sylius.behat.context.domain.managing_promotions" class="Sylius\Behat\Context\Domain\ManagingPromotionsContext"> <service id="sylius.behat.context.domain.managing_promotions" class="Sylius\Behat\Context\Domain\ManagingPromotionsContext">
<argument type="service" id="sylius.behat.shared_storage" /> <argument type="service" id="sylius.behat.shared_storage" />
<argument type="service" id="sylius.repository.promotion" /> <argument type="service" id="sylius.repository.promotion" />
<argument type="service" id="sylius.manager.promotion" />
</service> </service>
<service id="sylius.behat.context.domain.managing_promotion_coupons" class="Sylius\Behat\Context\Domain\ManagingPromotionCouponsContext"> <service id="sylius.behat.context.domain.managing_promotion_coupons" class="Sylius\Behat\Context\Domain\ManagingPromotionCouponsContext">

View file

@ -18,6 +18,7 @@ default:
- sylius.behat.context.transform.product - sylius.behat.context.transform.product
- sylius.behat.context.transform.product_option - sylius.behat.context.transform.product_option
- sylius.behat.context.transform.product_variant - sylius.behat.context.transform.product_variant
- sylius.behat.context.transform.promotion
- sylius.behat.context.transform.province - sylius.behat.context.transform.province
- sylius.behat.context.transform.shared_storage - sylius.behat.context.transform.shared_storage
- sylius.behat.context.transform.shipping_category - sylius.behat.context.transform.shipping_category

View file

@ -15,6 +15,7 @@ default:
- sylius.behat.context.transform.payment - sylius.behat.context.transform.payment
- sylius.behat.context.transform.product - sylius.behat.context.transform.product
- sylius.behat.context.transform.product_variant - sylius.behat.context.transform.product_variant
- sylius.behat.context.transform.promotion
- sylius.behat.context.transform.shared_storage - sylius.behat.context.transform.shared_storage
- sylius.behat.context.transform.taxon - sylius.behat.context.transform.taxon
- sylius.behat.context.transform.shipping_method - sylius.behat.context.transform.shipping_method

View file

@ -21,6 +21,7 @@ default:
- sylius.behat.context.transform.product - sylius.behat.context.transform.product
- sylius.behat.context.transform.product_option - sylius.behat.context.transform.product_option
- sylius.behat.context.transform.product_variant - sylius.behat.context.transform.product_variant
- sylius.behat.context.transform.promotion
- sylius.behat.context.transform.province - sylius.behat.context.transform.province
- sylius.behat.context.transform.shared_storage - sylius.behat.context.transform.shared_storage
- sylius.behat.context.transform.shipping_category - sylius.behat.context.transform.shipping_category

View file

@ -13,6 +13,7 @@ default:
- sylius.behat.context.transform.payment - sylius.behat.context.transform.payment
- sylius.behat.context.transform.product - sylius.behat.context.transform.product
- sylius.behat.context.transform.product_variant - sylius.behat.context.transform.product_variant
- sylius.behat.context.transform.promotion
- sylius.behat.context.transform.shared_storage - sylius.behat.context.transform.shared_storage
- sylius.behat.context.transform.taxon - sylius.behat.context.transform.taxon

View file

@ -51,6 +51,12 @@ sylius_grid:
label: sylius.ui.coupon label: sylius.ui.coupon
options: options:
fields: [coupons.code] fields: [coupons.code]
archival:
type: exists
label: sylius.ui.archival
options:
field: archivedAt
default_value: false
actions: actions:
main: main:
create: create:
@ -85,6 +91,8 @@ sylius_grid:
type: update type: update
delete: delete:
type: delete type: delete
archive:
type: archive
bulk: bulk:
delete: delete:
type: delete type: delete

View file

@ -19,3 +19,18 @@ sylius_admin_promotion:
form: "@SyliusAdmin/Promotion/_form.html.twig" form: "@SyliusAdmin/Promotion/_form.html.twig"
toolbar: "@SyliusAdmin/Promotion/_toolbar.html.twig" toolbar: "@SyliusAdmin/Promotion/_toolbar.html.twig"
type: sylius.resource type: sylius.resource
sylius_admin_promotion_archive:
path: /promotions/{id}/archive
methods: [PATCH]
defaults:
_controller: sylius.controller.promotion::updateAction
_sylius:
section: admin
permission: true
template: '@SyliusUi/Grid/Action/archive.html.twig'
form:
type: Sylius\Bundle\ResourceBundle\Form\Type\ArchivableType
redirect:
route: sylius_admin_promotion_index
parameters: {}

View file

@ -0,0 +1,39 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Bundle\ApiBundle\Applicator;
use Sylius\Calendar\Provider\DateTimeProviderInterface;
use Sylius\Component\Core\Model\PromotionInterface;
/** @experimental */
final class ArchivingPromotionApplicator implements ArchivingPromotionApplicatorInterface
{
public function __construct(private DateTimeProviderInterface $calendar)
{
}
public function archive(PromotionInterface $data): PromotionInterface
{
$data->setArchivedAt($this->calendar->now());
return $data;
}
public function restore(PromotionInterface $data): PromotionInterface
{
$data->setArchivedAt(null);
return $data;
}
}

View file

@ -0,0 +1,24 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Bundle\ApiBundle\Applicator;
use Sylius\Component\Core\Model\PromotionInterface;
/** @experimental */
interface ArchivingPromotionApplicatorInterface
{
public function archive(PromotionInterface $data): PromotionInterface;
public function restore(PromotionInterface $data): PromotionInterface;
}

View file

@ -0,0 +1,47 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Bundle\ApiBundle\Doctrine\QueryCollectionExtension;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\ContextAwareQueryCollectionExtensionInterface;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use Doctrine\ORM\QueryBuilder;
/** @experimental */
final class HideArchivedPromotionExtension implements ContextAwareQueryCollectionExtensionInterface
{
public function __construct(private string $promotionClass)
{
}
/** @param array<array-key, mixed> $context */
public function applyToCollection(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
string $operationName = null,
array $context = [],
): void {
if ($this->promotionClass !== $resourceClass) {
return;
}
if (isset($context['filters']['exists']['archivedAt'])) {
return;
}
$rootAlias = $queryBuilder->getRootAliases()[0];
$queryBuilder->andWhere($queryBuilder->expr()->isNull(sprintf('%s.archivedAt', $rootAlias)));
}
}

View file

@ -41,6 +41,8 @@
<attribute>admin:promotion:show</attribute> <attribute>admin:promotion:show</attribute>
<attribute>sylius:admin:promotion:show</attribute> <attribute>sylius:admin:promotion:show</attribute>
</attribute> </attribute>
<!-- It auto-turned on when adding PATCH itemOperations: https://github.com/api-platform/core/issues/3600 -->
<attribute name="skip_null_values">false</attribute>
</attribute> </attribute>
<attribute name="openapi_context"> <attribute name="openapi_context">
<attribute name="description"> <attribute name="description">
@ -81,10 +83,13 @@ Example configuration for `order_fixed_discount` action type:
<attribute>admin:promotion:index</attribute> <attribute>admin:promotion:index</attribute>
<attribute>sylius:admin:promotion:index</attribute> <attribute>sylius:admin:promotion:index</attribute>
</attribute> </attribute>
<!-- It auto-turned on when adding PATCH itemOperations: https://github.com/api-platform/core/issues/3600 -->
<attribute name="skip_null_values">false</attribute>
</attribute> </attribute>
<attribute name="filters"> <attribute name="filters">
<attribute>sylius.api.promotion_coupon_search_filter</attribute> <attribute>sylius.api.promotion_coupon_search_filter</attribute>
<attribute>sylius.api.promotion_order_filter</attribute> <attribute>sylius.api.promotion_order_filter</attribute>
<attribute>sylius.api.exists_filter.archived_at</attribute>
</attribute> </attribute>
</collectionOperation> </collectionOperation>
</collectionOperations> </collectionOperations>
@ -97,6 +102,8 @@ Example configuration for `order_fixed_discount` action type:
<attribute>admin:promotion:show</attribute> <attribute>admin:promotion:show</attribute>
<attribute>sylius:admin:promotion:show</attribute> <attribute>sylius:admin:promotion:show</attribute>
</attribute> </attribute>
<!-- It auto-turned on when adding PATCH itemOperations: https://github.com/api-platform/core/issues/3600 -->
<attribute name="skip_null_values">false</attribute>
</attribute> </attribute>
</itemOperation> </itemOperation>
@ -113,6 +120,8 @@ Example configuration for `order_fixed_discount` action type:
<attribute>admin:promotion:show</attribute> <attribute>admin:promotion:show</attribute>
<attribute>sylius:admin:promotion:show</attribute> <attribute>sylius:admin:promotion:show</attribute>
</attribute> </attribute>
<!-- It auto-turned on when adding PATCH itemOperations: https://github.com/api-platform/core/issues/3600 -->
<attribute name="skip_null_values">false</attribute>
</attribute> </attribute>
<attribute name="openapi_context"> <attribute name="openapi_context">
<attribute name="description"> <attribute name="description">
@ -146,6 +155,37 @@ Example configuration for `order_fixed_discount` action type:
</attribute> </attribute>
</itemOperation> </itemOperation>
<itemOperation name="admin_archive">
<attribute name="method">PATCH</attribute>
<attribute name="path">/promotions/{code}/archive</attribute>
<attribute name="input">false</attribute>
<attribute name="controller">Sylius\Bundle\ApiBundle\Applicator\ArchivingPromotionApplicatorInterface::archive</attribute>
<attribute name="openapi_context">
<attribute name="summary">Archives Promotion</attribute>
</attribute>
<attribute name="normalization_context">
<attribute name="groups">
<attribute>admin:promotion:read</attribute>
<attribute>sylius:admin:promotion:read</attribute>
</attribute>
</attribute>
</itemOperation>
<itemOperation name="admin_restore">
<attribute name="method">PATCH</attribute>
<attribute name="path">/promotions/{code}/restore</attribute>
<attribute name="input">false</attribute>
<attribute name="controller">Sylius\Bundle\ApiBundle\Applicator\ArchivingPromotionApplicatorInterface::restore</attribute>
<attribute name="openapi_context">
<attribute name="summary">Restores Archived Promotion</attribute>
</attribute>
<attribute name="normalization_context">
<attribute name="groups">
<attribute>admin:promotion:read</attribute>
<attribute>sylius:admin:promotion:read</attribute>
</attribute>
</attribute>
</itemOperation>
<itemOperation name="admin_delete"> <itemOperation name="admin_delete">
<attribute name="method">DELETE</attribute> <attribute name="method">DELETE</attribute>
</itemOperation> </itemOperation>

View file

@ -198,5 +198,10 @@
<group>admin:promotion:update</group> <group>admin:promotion:update</group>
<group>sylius:admin:promotion:update</group> <group>sylius:admin:promotion:update</group>
</attribute> </attribute>
<attribute name="archivedAt">
<group>admin:promotion:read</group>
<group>sylius:admin:promotion:read</group>
</attribute>
</class> </class>
</serializer> </serializer>

View file

@ -33,5 +33,10 @@
<service id="Sylius\Bundle\ApiBundle\Applicator\ProductReviewStateMachineTransitionApplicatorInterface" class="Sylius\Bundle\ApiBundle\Applicator\ProductReviewStateMachineTransitionApplicator"> <service id="Sylius\Bundle\ApiBundle\Applicator\ProductReviewStateMachineTransitionApplicatorInterface" class="Sylius\Bundle\ApiBundle\Applicator\ProductReviewStateMachineTransitionApplicator">
<argument id="sm.factory" type="service" /> <argument id="sm.factory" type="service" />
</service> </service>
<service id="sylius.api.applicator.archiving_promotion" class="Sylius\Bundle\ApiBundle\Applicator\ArchivingPromotionApplicator">
<argument type="service" id="Sylius\Calendar\Provider\DateTimeProviderInterface" />
</service>
<service id="Sylius\Bundle\ApiBundle\Applicator\ArchivingPromotionApplicatorInterface" alias="sylius.api.applicator.archiving_promotion" />
</services> </services>
</container> </container>

View file

@ -140,5 +140,10 @@
<argument type="service" id="Sylius\Bundle\ApiBundle\Context\UserContextInterface" /> <argument type="service" id="Sylius\Bundle\ApiBundle\Context\UserContextInterface" />
<tag name="api_platform.doctrine.orm.query_extension.item" /> <tag name="api_platform.doctrine.orm.query_extension.item" />
</service> </service>
<service id="Sylius\Bundle\ApiBundle\Doctrine\QueryCollectionExtension\HideArchivedPromotionExtension">
<argument>%sylius.model.promotion.class%</argument>
<tag name="api_platform.doctrine.orm.query_extension.collection" />
</service>
</services> </services>
</container> </container>

View file

@ -403,5 +403,12 @@
</argument> </argument>
<tag name="api_platform.filter" /> <tag name="api_platform.filter" />
</service> </service>
<service id="sylius.api.exists_filter.archived_at" parent="api_platform.doctrine.orm.exists_filter" public="true">
<argument type="collection">
<argument key="archivedAt" />
</argument>
<tag name="api_platform.filter" />
</service>
</services> </services>
</container> </container>

View file

@ -0,0 +1,46 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace spec\Sylius\Bundle\ApiBundle\Applicator;
use PhpSpec\ObjectBehavior;
use Sylius\Calendar\Provider\DateTimeProviderInterface;
use Sylius\Component\Core\Model\PromotionInterface;
final class ArchivingPromotionApplicatorSpec extends ObjectBehavior
{
function let(DateTimeProviderInterface $calendar)
{
$this->beConstructedWith($calendar);
}
function it_archives_promotion(
DateTimeProviderInterface $calendar,
PromotionInterface $promotion,
): void {
$now = new \DateTime();
$calendar->now()->willReturn($now);
$promotion->setArchivedAt($now)->shouldBeCalledOnce();
$this->archive($promotion)->shouldReturn($promotion);
}
function it_restores_promotion(
PromotionInterface $promotion,
): void {
$promotion->setArchivedAt(null)->shouldBeCalledOnce();
$this->restore($promotion)->shouldReturn($promotion);
}
}

View file

@ -0,0 +1,61 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace spec\Sylius\Bundle\ApiBundle\Doctrine\QueryCollectionExtension;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use Doctrine\ORM\Query\Expr;
use Doctrine\ORM\QueryBuilder;
use PhpSpec\ObjectBehavior;
final class HideArchivedPromotionExtensionSpec extends ObjectBehavior
{
function let()
{
$this->beConstructedWith('promotionClass');
}
function it_does_nothing_if_current_resource_is_not_a_promotion(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
): void {
$queryBuilder->getRootAliases()->shouldNotBeCalled();
$queryBuilder->andWhere()->shouldNotBeCalled();
$this->applyToCollection($queryBuilder, $queryNameGenerator, 'taxonClass', 'get', []);
}
function it_does_nothing_if_archived_at_filter_is_already_applied(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
): void {
$queryBuilder->getRootAliases()->shouldNotBeCalled();
$queryBuilder->andWhere()->shouldNotBeCalled();
$this->applyToCollection($queryBuilder, $queryNameGenerator, 'promotionClass', 'get', ['filters' => ['exists' => ['archivedAt' => 'true']]]);
}
function it_filters_archived_promotions(
QueryBuilder $queryBuilder,
Expr $expr,
QueryNameGeneratorInterface $queryNameGenerator,
): void {
$queryBuilder->getRootAliases()->willReturn(['o']);
$expr->isNull('o.archivedAt')->willReturn('o.archivedAt IS NULL');
$queryBuilder->expr()->willReturn($expr);
$queryBuilder->andWhere('o.archivedAt IS NULL')->shouldBeCalled();
$this->applyToCollection($queryBuilder, $queryNameGenerator, 'promotionClass', 'get', []);
}
}

View file

@ -93,6 +93,10 @@ class PromotionExampleFactory extends AbstractExampleFactory implements ExampleF
$promotion->setEndsAt(new \DateTime($options['ends_at'])); $promotion->setEndsAt(new \DateTime($options['ends_at']));
} }
if (isset($options['archived_at'])) {
$promotion->setArchivedAt(new \DateTime($options['archived_at']));
}
foreach ($options['channels'] as $channel) { foreach ($options['channels'] as $channel) {
$promotion->addChannel($channel); $promotion->addChannel($channel);
} }
@ -139,6 +143,8 @@ class PromotionExampleFactory extends AbstractExampleFactory implements ExampleF
->setAllowedTypes('starts_at', ['null', 'string']) ->setAllowedTypes('starts_at', ['null', 'string'])
->setDefault('ends_at', null) ->setDefault('ends_at', null)
->setAllowedTypes('ends_at', ['null', 'string']) ->setAllowedTypes('ends_at', ['null', 'string'])
->setDefault('archived_at', null)
->setAllowedTypes('archived_at', ['null', 'string'])
->setDefault('channels', LazyOption::all($this->channelRepository)) ->setDefault('channels', LazyOption::all($this->channelRepository))
->setAllowedTypes('channels', 'array') ->setAllowedTypes('channels', 'array')
->setNormalizer('channels', LazyOption::findBy($this->channelRepository, 'code')) ->setNormalizer('channels', LazyOption::findBy($this->channelRepository, 'code'))

View file

@ -40,6 +40,7 @@ class PromotionFixture extends AbstractResourceFixture
->end() ->end()
->scalarNode('starts_at')->cannotBeEmpty()->end() ->scalarNode('starts_at')->cannotBeEmpty()->end()
->scalarNode('ends_at')->cannotBeEmpty()->end() ->scalarNode('ends_at')->cannotBeEmpty()->end()
->scalarNode('archived_at')->defaultNull()->end()
->arrayNode('rules') ->arrayNode('rules')
->requiresAtLeastOneElement() ->requiresAtLeastOneElement()
->arrayPrototype() ->arrayPrototype()

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Sylius\Bundle\CoreBundle\Migrations;
use Doctrine\DBAL\Schema\Schema;
use Sylius\Bundle\CoreBundle\Doctrine\Migrations\AbstractMigration;
final class Version20240216110238 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add archived_at field to promotion table.';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE sylius_promotion ADD archived_at DATETIME DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE sylius_promotion DROP archived_at');
}
}

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Sylius\Bundle\CoreBundle\Migrations;
use Doctrine\DBAL\Schema\Schema;
use Sylius\Bundle\CoreBundle\Doctrine\Migrations\AbstractPostgreSQLMigration;
final class Version20240216110239 extends AbstractPostgreSQLMigration
{
public function getDescription(): string
{
return 'Add archived_at field to promotion table.';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE sylius_promotion ADD archived_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE sylius_promotion DROP archived_at');
}
}

View file

@ -40,6 +40,23 @@ sylius_fixtures:
configuration: configuration:
FASHION_WEB: FASHION_WEB:
amount: 10.00 amount: 10.00
back_to_school:
code: 'back_to_school'
name: 'Back to school'
channels:
- 'FASHION_WEB'
starts_at: 'September 1 last year'
ends_at: 'September 30 last year'
archived_at: 'October 1 last year'
rules:
- type: 'item_total'
configuration:
FASHION_WEB:
amount: 100.00
actions:
- type: 'order_percentage_discount'
configuration:
percentage: 10
catalog_promotion: catalog_promotion:
options: options:

View file

@ -34,6 +34,7 @@
<option name="default">1</option> <option name="default">1</option>
</options> </options>
</field> </field>
<field name="archivedAt" column="archived_at" type="datetime" nullable="true" />
<one-to-many field="coupons" target-entity="Sylius\Component\Promotion\Model\PromotionCouponInterface" mapped-by="promotion"> <one-to-many field="coupons" target-entity="Sylius\Component\Promotion\Model\PromotionCouponInterface" mapped-by="promotion">
<cascade> <cascade>

View file

@ -55,5 +55,10 @@
class="Sylius\Component\Promotion\Checker\Eligibility\CompositePromotionEligibilityChecker"> class="Sylius\Component\Promotion\Checker\Eligibility\CompositePromotionEligibilityChecker">
<argument type="collection" /> <argument type="collection" />
</service> </service>
<service id="sylius.promotion_archival_eligibility_checker"
class="Sylius\Component\Promotion\Checker\Eligibility\PromotionArchivalEligibilityChecker"
public="false">
<tag name="sylius.promotion_eligibility_checker" />
</service>
</services> </services>
</container> </container>

View file

@ -0,0 +1,25 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Component\Promotion\Checker\Eligibility;
use Sylius\Component\Promotion\Model\PromotionInterface;
use Sylius\Component\Promotion\Model\PromotionSubjectInterface;
final class PromotionArchivalEligibilityChecker implements PromotionEligibilityCheckerInterface
{
public function isEligible(PromotionSubjectInterface $promotionSubject, PromotionInterface $promotion): bool
{
return null === $promotion->getArchivedAt();
}
}

View file

@ -15,13 +15,14 @@ namespace Sylius\Component\Promotion\Model;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Sylius\Component\Resource\Model\ArchivableTrait;
use Sylius\Component\Resource\Model\TimestampableTrait; use Sylius\Component\Resource\Model\TimestampableTrait;
use Sylius\Component\Resource\Model\TranslatableTrait; use Sylius\Component\Resource\Model\TranslatableTrait;
use Sylius\Component\Resource\Model\TranslationInterface; use Sylius\Component\Resource\Model\TranslationInterface;
class Promotion implements PromotionInterface class Promotion implements PromotionInterface
{ {
use TimestampableTrait, TranslatableTrait { use ArchivableTrait, TimestampableTrait, TranslatableTrait {
__construct as private initializeTranslationsCollection; __construct as private initializeTranslationsCollection;
getTranslation as private doGetTranslation; getTranslation as private doGetTranslation;
} }

View file

@ -14,12 +14,13 @@ declare(strict_types=1);
namespace Sylius\Component\Promotion\Model; namespace Sylius\Component\Promotion\Model;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Sylius\Component\Resource\Model\ArchivableInterface;
use Sylius\Component\Resource\Model\CodeAwareInterface; use Sylius\Component\Resource\Model\CodeAwareInterface;
use Sylius\Component\Resource\Model\ResourceInterface; use Sylius\Component\Resource\Model\ResourceInterface;
use Sylius\Component\Resource\Model\TimestampableInterface; use Sylius\Component\Resource\Model\TimestampableInterface;
use Sylius\Component\Resource\Model\TranslatableInterface; use Sylius\Component\Resource\Model\TranslatableInterface;
interface PromotionInterface extends CodeAwareInterface, TimestampableInterface, TranslatableInterface, ResourceInterface interface PromotionInterface extends ArchivableInterface, CodeAwareInterface, TimestampableInterface, TranslatableInterface, ResourceInterface
{ {
public function getName(): ?string; public function getName(): ?string;

View file

@ -0,0 +1,45 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace spec\Sylius\Component\Promotion\Checker\Eligibility;
use PhpSpec\ObjectBehavior;
use Sylius\Component\Promotion\Checker\Eligibility\PromotionEligibilityCheckerInterface;
use Sylius\Component\Promotion\Model\PromotionInterface;
use Sylius\Component\Promotion\Model\PromotionSubjectInterface;
final class PromotionArchivalEligibilityCheckerSpec extends ObjectBehavior
{
function it_is_a_promotion_eligibility_checker(): void
{
$this->shouldImplement(PromotionEligibilityCheckerInterface::class);
}
function it_is_eligible_when_archived_at_is_null(
PromotionSubjectInterface $promotionSubject,
PromotionInterface $promotion,
): void {
$promotion->getArchivedAt()->willReturn(null);
$this->isEligible($promotionSubject, $promotion)->shouldReturn(true);
}
function it_is_not_eligible_when_archived_at_is_not_null(
PromotionSubjectInterface $promotionSubject,
PromotionInterface $promotion,
): void {
$promotion->getArchivedAt()->willReturn(new \DateTime());
$this->isEligible($promotionSubject, $promotion)->shouldReturn(false);
}
}

View file

@ -576,6 +576,56 @@ final class PromotionsTest extends JsonApiTestCase
); );
} }
/** @test */
public function it_archives_a_promotion(): void
{
$fixtures = $this->loadFixturesFromFiles([
'authentication/api_administrator.yaml',
'channel.yaml',
'promotion/promotion.yaml'
]);
/** @var PromotionInterface $promotion */
$promotion = $fixtures['promotion_50_off'];
$this->client->request(
method: 'PATCH',
uri: sprintf('/api/v2/admin/promotions/%s/archive', $promotion->getCode()),
server: $this->headerBuilder()->withJsonLdAccept()->withAdminUserAuthorization('api@example.com')->build(),
);
$this->assertResponse(
$this->client->getResponse(),
'admin/promotion/archive_promotion',
Response::HTTP_OK,
);
}
/** @test */
public function it_restores_a_promotion(): void
{
$fixtures = $this->loadFixturesFromFiles([
'authentication/api_administrator.yaml',
'channel.yaml',
'promotion/promotion.yaml'
]);
/** @var PromotionInterface $promotion */
$promotion = $fixtures['promotion_back_to_school'];
$this->client->request(
method: 'PATCH',
uri: sprintf('/api/v2/admin/promotions/%s/restore', $promotion->getCode()),
server: $this->headerBuilder()->withJsonLdAccept()->withAdminUserAuthorization('api@example.com')->build(),
);
$this->assertResponse(
$this->client->getResponse(),
'admin/promotion/restore_promotion',
Response::HTTP_OK,
);
}
/** @test */ /** @test */
public function it_deletes_a_promotion(): void public function it_deletes_a_promotion(): void
{ {

View file

@ -23,6 +23,18 @@ Sylius\Component\Core\Model\Promotion:
couponBased: true couponBased: true
translations: translations:
- '@promotion_1_off_en' - '@promotion_1_off_en'
promotion_back_to_school:
code: 'back_to_school'
name: 'Back to school'
description: 'Get 10% off on all school supplies'
channels: ['@channel_web', '@channel_mobile']
priority: 3
exclusive: false
appliesToDiscounted: true
couponBased: false
archivedAt: <dateTimeBetween('-1 month', 'now')>
translations:
- '@promotion_back_to_school_en'
Sylius\Component\Promotion\Model\PromotionTranslation: Sylius\Component\Promotion\Model\PromotionTranslation:
promotion_50_off_en: promotion_50_off_en:
@ -33,6 +45,10 @@ Sylius\Component\Promotion\Model\PromotionTranslation:
locale: 'en_US' locale: 'en_US'
label: '1$ off every item!' label: '1$ off every item!'
translatable: '@promotion_1_off' translatable: '@promotion_1_off'
promotion_back_to_school_en:
locale: 'en_US'
label: 'Back to school sale!'
translatable: '@promotion_back_to_school'
Sylius\Component\Core\Model\PromotionCoupon: Sylius\Component\Core\Model\PromotionCoupon:
promotion_1_off_coupon_1: promotion_1_off_coupon_1:

View file

@ -0,0 +1,32 @@
{
"@context": "\/api\/v2\/contexts\/Promotion",
"@id": "\/api\/v2\/admin\/promotions\/50_off",
"@type": "Promotion",
"channels": [
"\/api\/v2\/admin\/channels\/MOBILE"
],
"id": @integer@,
"code": "50_off",
"name": "50% Off on your first order",
"description": "Get 50% off of your first purchase",
"priority": 1,
"exclusive": true,
"usageLimit": 1,
"used": 0,
"couponBased": false,
"coupons": [],
"rules": [],
"actions": [],
"appliesToDiscounted": false,
"archivedAt": @date@,
"createdAt": @date@,
"updatedAt": @date@,
"translations": {
"en_US": {
"@id": "\/api\/v2\/admin\/promotion-translations\/@integer@",
"@type": "PromotionTranslation",
"id": @integer@,
"label": "-50% on first order!"
}
}
}

View file

@ -20,6 +20,7 @@
"coupons": [], "coupons": [],
"rules": [], "rules": [],
"actions": [], "actions": [],
"archivedAt": null,
"createdAt": @date@, "createdAt": @date@,
"updatedAt": @date@, "updatedAt": @date@,
"translations": { "translations": {

View file

@ -28,6 +28,7 @@
], ],
"rules": [], "rules": [],
"actions": [], "actions": [],
"archivedAt": null,
"createdAt": @date@, "createdAt": @date@,
"updatedAt": @date@, "updatedAt": @date@,
"translations": { "translations": {
@ -60,6 +61,7 @@
"coupons": [], "coupons": [],
"rules": [], "rules": [],
"actions": [], "actions": [],
"archivedAt": null,
"createdAt": @date@, "createdAt": @date@,
"updatedAt": @date@, "updatedAt": @date@,
"translations": { "translations": {
@ -75,7 +77,7 @@
"hydra:totalItems": 2, "hydra:totalItems": 2,
"hydra:search": { "hydra:search": {
"@type": "hydra:IriTemplate", "@type": "hydra:IriTemplate",
"hydra:template": "\/api\/v2\/admin\/promotions{?coupons.code,coupons.code[],order[priority]}", "hydra:template": "\/api\/v2\/admin\/promotions{?coupons.code,coupons.code[],order[priority],exists[archivedAt]}",
"hydra:variableRepresentation": "BasicRepresentation", "hydra:variableRepresentation": "BasicRepresentation",
"hydra:mapping": [ "hydra:mapping": [
{ {
@ -95,6 +97,12 @@
"variable": "order[priority]", "variable": "order[priority]",
"property": "priority", "property": "priority",
"required": false "required": false
},
{
"@type": "IriTemplateMapping",
"variable": "exists[archivedAt]",
"property": "archivedAt",
"required": false
} }
] ]
} }

View file

@ -13,6 +13,7 @@
"exclusive": true, "exclusive": true,
"usageLimit": 3, "usageLimit": 3,
"used": 0, "used": 0,
"archivedAt": null,
"startsAt": "2023-10-04 12:30:00", "startsAt": "2023-10-04 12:30:00",
"endsAt": "2023-11-04 12:30:00", "endsAt": "2023-11-04 12:30:00",
"couponBased": true, "couponBased": true,

View file

@ -13,6 +13,7 @@
"exclusive": true, "exclusive": true,
"usageLimit": 11, "usageLimit": 11,
"used": 0, "used": 0,
"archivedAt": null,
"startsAt": null, "startsAt": null,
"endsAt": null, "endsAt": null,
"couponBased": false, "couponBased": false,

View file

@ -9,7 +9,7 @@
"code": "50_off", "code": "50_off",
"name": "50% Off on your first order", "name": "50% Off on your first order",
"description": "Get 50% off of your first purchase", "description": "Get 50% off of your first purchase",
"priority": 2, "priority": 3,
"exclusive": true, "exclusive": true,
"usageLimit": 1, "usageLimit": 1,
"used": 0, "used": 0,
@ -20,6 +20,7 @@
"rules": [], "rules": [],
"actions": [], "actions": [],
"appliesToDiscounted": false, "appliesToDiscounted": false,
"archivedAt": null,
"createdAt": @date@, "createdAt": @date@,
"updatedAt": @date@, "updatedAt": @date@,
"translations": { "translations": {

View file

@ -0,0 +1,31 @@
{
"@context": "\/api\/v2\/contexts\/Promotion",
"@id": "\/api\/v2\/admin\/promotions\/back_to_school",
"@type": "Promotion",
"channels": [
"\/api\/v2\/admin\/channels\/WEB",
"\/api\/v2\/admin\/channels\/MOBILE"
],
"id": @integer@,
"code": "back_to_school",
"name": "Back to school",
"description": "Get 10% off on all school supplies",
"priority": 3,
"exclusive": false,
"used": 0,
"couponBased": false,
"coupons": [],
"rules": [],
"actions": [],
"appliesToDiscounted": true,
"createdAt": @date@,
"updatedAt": @date@,
"translations": {
"en_US": {
"@id": "\/api\/v2\/admin\/promotion-translations\/@integer@",
"@type": "PromotionTranslation",
"id": @integer@,
"label": "Back to school sale!"
}
}
}