feature #14875 [PriceHistory] Add removing price history command feature (TheMilek)

This PR was merged into the 1.13 branch.

Discussion
----------

| Q               | A                                                            |
|-----------------|--------------------------------------------------------------|
| Branch?         | 1.13 <!-- see the comment below -->                  |
| Bug fix?        | no                                                       |
| New feature?    | yes                                                       |
| BC breaks?      | no                                                       |
| Deprecations?   | no <!-- don't forget to update the UPGRADE-*.md file --> |
| License         | MIT                                                          |

<!--
 - Bug fixes must be submitted against the 1.12 branch
 - Features and deprecations must be submitted against the 1.13 branch
 - Make sure that the correct base branch is set

 To be sure you are not breaking any Backward Compatibilities, check the documentation:
 https://docs.sylius.com/en/latest/book/organization/backward-compatibility-promise.html
-->


Commits
-------

11bc4d7117 [PriceHistory] Add removing price history command feature
This commit is contained in:
Grzegorz Sadowski 2023-03-29 10:59:23 +02:00 committed by GitHub
commit 8c2dcb29c3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 584 additions and 3 deletions

View file

@ -14,18 +14,18 @@ Feature: Deleting old channel pricing entry logs
And on "2022-04-20" its original price has been removed
And it is "2022-04-29" now
@todo @domain
@domain
Scenario: Deleting price history older than 90 days
When I delete price history older than 90 days
Then there should be 5 price history entries for this product
@todo @domain
@domain
Scenario: Deleting price history older than 30 days
When I delete price history older than 30 days
Then there should be 2 price history entries for this product
And this product should have no entry with original price changed to "$25.00"
@todo @domain
@domain
Scenario: Deleting price history older than 1 day
When I delete price history older than 1 day
Then this product's price history should be empty

View file

@ -0,0 +1,84 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Paweł Jędrzejewski
*
* 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\Behat\Context\Domain;
use Behat\Behat\Context\Context;
use Sylius\Bundle\CoreBundle\PriceHistory\Remover\ChannelPricingLogEntriesRemoverInterface;
use Sylius\Component\Core\Model\ChannelPricingInterface;
use Sylius\Component\Core\Model\ProductInterface;
use Sylius\Component\Product\Resolver\ProductVariantResolverInterface;
use Sylius\Component\Resource\Repository\RepositoryInterface;
use Webmozart\Assert\Assert;
final class ManagingPriceHistoryContext implements Context
{
public function __construct(
private RepositoryInterface $channelPricingLogEntryRepository,
private ProductVariantResolverInterface $variantResolver,
private ChannelPricingLogEntriesRemoverInterface $channelPricingLogEntriesRemover,
) {
}
/**
* @When I delete price history older than :days day(s)
*/
public function iDeletePriceHistoryOlderThanDays(int $days): void
{
$this->channelPricingLogEntriesRemover->remove($days);
}
/**
* @Then /^there should be (\d+) price history entries for (this product)$/
*/
public function thereShouldBeCountPriceHistoryEntriesForThisProduct(int $count, ProductInterface $product): void
{
$channelPricingLogEntries = $this->channelPricingLogEntryRepository->findBy([
'channelPricing' => $this->getChannelPricingFromProduct($product),
]);
Assert::count($channelPricingLogEntries, $count);
}
/**
* @Then /^(this product) should have no entry with original price changed to ("[^"]+")$/
*/
public function thisProductShouldHaveNoEntryWithOriginalPriceChangedTo(
ProductInterface $product,
int $originalPrice,
): void {
Assert::null($this->channelPricingLogEntryRepository->findOneBy([
'channelPricing' => $this->getChannelPricingFromProduct($product),
'originalPrice' => $originalPrice,
]));
}
/**
* @Then /^(this product)'s price history should be empty$/
*/
public function thisProductsPriceHistoryShouldBeEmpty(ProductInterface $product): void
{
$this->thereShouldBeCountPriceHistoryEntriesForThisProduct(0, $product);
}
private function getChannelPricingFromProduct(ProductInterface $product): ChannelPricingInterface
{
$variant = $this->variantResolver->getVariant($product);
Assert::notNull($variant);
$channelPricing = $variant->getChannelPricings()->first();
Assert::isInstanceOf($channelPricing, ChannelPricingInterface::class);
return $channelPricing;
}
}

View file

@ -33,6 +33,12 @@
<argument type="service" id="sylius.repository.payment"/>
</service>
<service id="Sylius\Behat\Context\Domain\ManagingPriceHistoryContext">
<argument type="service" id="sylius.repository.channel_pricing_log_entry" />
<argument type="service" id="sylius.product_variant_resolver.default" />
<argument type="service" id="Sylius\Bundle\CoreBundle\PriceHistory\Remover\ChannelPricingLogEntriesRemoverInterface" />
</service>
<service id="sylius.behat.context.domain.managing_products" class="Sylius\Behat\Context\Domain\ManagingProductsContext">
<argument type="service" id="sylius.behat.shared_storage" />
<argument type="service" id="sylius.repository.product" />

View file

@ -134,6 +134,12 @@
</service>
<service id="Sylius\Behat\Context\Setup\PriceHistoryContext">
<argument type="service" id="Sylius\Calendar\Tests\Behat\Context\Setup\CalendarContext" />
<argument type="service" id="sylius.manager.channel_pricing" />
<argument type="service" id="sylius.product_variant_resolver.default" />
</service>
<service id="sylius.behat.context.setup.product" class="Sylius\Behat\Context\Setup\ProductContext">
<argument type="service" id="sylius.behat.shared_storage" />
<argument type="service" id="sylius.repository.product" />

View file

@ -68,6 +68,7 @@ imports:
- suites/domain/cart/shopping_cart.yml
- suites/domain/order/managing_orders.yml
- suites/domain/product/managing_price_history.yml
- suites/domain/product/managing_product_variants.yml
- suites/domain/product/managing_products.yml
- suites/domain/promotion/managing_promotion_coupons.yml

View file

@ -0,0 +1,22 @@
# This file is part of the Sylius package.
# (c) Paweł Jędrzejewski
default:
suites:
domain_managing_price_history:
contexts:
- sylius.behat.context.hook.doctrine_orm
- sylius.behat.context.transform.channel
- sylius.behat.context.transform.lexical
- sylius.behat.context.transform.product
- sylius.behat.context.transform.shared_storage
- sylius.behat.context.setup.channel
- sylius.behat.context.setup.product
- Sylius\Behat\Context\Setup\PriceHistoryContext
- Sylius\Calendar\Tests\Behat\Context\Setup\CalendarContext
- Sylius\Behat\Context\Domain\ManagingPriceHistoryContext
filters:
tags: "@managing_price_history&&@domain"

View file

@ -0,0 +1,71 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Paweł Jędrzejewski
*
* 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\CoreBundle\PriceHistory\Command;
use Sylius\Bundle\CoreBundle\PriceHistory\Remover\ChannelPricingLogEntriesRemoverInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'sylius:price-history:clear',
description: 'Clears the price history up to a given number of days ago',
)]
final class ClearPriceHistoryCommand extends Command
{
private SymfonyStyle $io;
public function __construct(private ChannelPricingLogEntriesRemoverInterface $channelPricingLogEntriesRemover)
{
parent::__construct();
}
protected function configure(): void
{
$this->addArgument('days', InputArgument::REQUIRED, 'Number of days ago');
}
protected function initialize(InputInterface $input, OutputInterface $output): void
{
$this->io = new SymfonyStyle($input, $output);
}
protected function execute(InputInterface $input, OutputInterface $output)
{
$days = filter_var($input->getArgument('days'), \FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
if (false === $days) {
$this->io->error('Number of days must be an integer greater than 0');
return Command::FAILURE;
}
if ($input->isInteractive()) {
$confirmation = $this->io->confirm(sprintf(
'Are you sure you want to clear the price history from before %s days ago?',
$days,
), false);
if (false === $confirmation) {
return Command::INVALID;
}
}
$this->channelPricingLogEntriesRemover->remove($days);
return Command::SUCCESS;
}
}

View file

@ -0,0 +1,21 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Paweł Jędrzejewski
*
* 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\CoreBundle\PriceHistory\Event;
interface OldChannelPricingLogEntriesEvents
{
public const PRE_REMOVE = 'sylius.old_channel_pricing_log_entries.pre_remove';
public const POST_REMOVE = 'sylius.old_channel_pricing_log_entries.post_remove';
}

View file

@ -0,0 +1,68 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Paweł Jędrzejewski
*
* 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\CoreBundle\PriceHistory\Remover;
use Doctrine\Persistence\ObjectManager;
use Sylius\Bundle\CoreBundle\PriceHistory\Event\OldChannelPricingLogEntriesEvents;
use Sylius\Calendar\Provider\DateTimeProviderInterface;
use Sylius\Component\Core\Repository\ChannelPricingLogEntryRepositoryInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\EventDispatcher\GenericEvent;
use Webmozart\Assert\Assert;
final class ChannelPricingLogEntriesRemover implements ChannelPricingLogEntriesRemoverInterface
{
public function __construct(
private ChannelPricingLogEntryRepositoryInterface $channelPricingLogEntriesRepository,
private ObjectManager $channelPricingLogEntriesManager,
private DateTimeProviderInterface $dateTimeProvider,
private EventDispatcherInterface $eventDispatcher,
private int $batchSize = 100,
) {
}
public function remove(int $fromDays): void
{
$fromDate = $this->getFromDate($fromDays);
while ([] !== $oldChannelPricingLogEntries = $this->getBatch($fromDate)) {
foreach ($oldChannelPricingLogEntries as $oldChannelPricingLogEntry) {
$this->channelPricingLogEntriesManager->remove($oldChannelPricingLogEntry);
}
$this->processDeletion($oldChannelPricingLogEntries);
}
}
private function getBatch(\DateTimeInterface $fromDate): array
{
return $this->channelPricingLogEntriesRepository->findOlderThan($fromDate, $this->batchSize);
}
private function processDeletion(array $deletedChannelPricingLogEntries): void
{
$this->eventDispatcher->dispatch(new GenericEvent($deletedChannelPricingLogEntries), OldChannelPricingLogEntriesEvents::PRE_REMOVE);
$this->channelPricingLogEntriesManager->flush();
$this->eventDispatcher->dispatch(new GenericEvent($deletedChannelPricingLogEntries), OldChannelPricingLogEntriesEvents::POST_REMOVE);
$this->channelPricingLogEntriesManager->clear();
}
private function getFromDate(int $fromDays): \DateTimeInterface
{
$now = $this->dateTimeProvider->now();
Assert::methodExists($now, 'modify');
/** @psalm-suppress UndefinedInterfaceMethod */
return $now->modify(sprintf('-%d days', $fromDays));
}
}

View file

@ -0,0 +1,19 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Paweł Jędrzejewski
*
* 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\CoreBundle\PriceHistory\Remover;
interface ChannelPricingLogEntriesRemoverInterface
{
public function remove(int $fromDays): void;
}

View file

@ -45,6 +45,7 @@
<parameter key="sylius.order_item_quantity_modifier.limit">9999</parameter>
<parameter key="env(SYLIUS_UNSECURED_URLS)">yes</parameter>
<parameter key="sylius.unsecured_urls">%env(bool:SYLIUS_UNSECURED_URLS)%</parameter>
<parameter key="sylius.channel_pricing_log_entry.old_logs_removal_batch_size">100</parameter>
</parameters>
<services>
@ -229,6 +230,14 @@
</service>
<service id="Sylius\Bundle\CoreBundle\Remover\ReviewerReviewsRemoverInterface" alias="sylius.reviewer_reviews_remover" />
<service id="Sylius\Bundle\CoreBundle\PriceHistory\Remover\ChannelPricingLogEntriesRemoverInterface" class="Sylius\Bundle\CoreBundle\PriceHistory\Remover\ChannelPricingLogEntriesRemover">
<argument type="service" id="sylius.repository.channel_pricing_log_entry" />
<argument type="service" id="doctrine.orm.entity_manager" />
<argument type="service" id="Sylius\Calendar\Provider\DateTimeProviderInterface" />
<argument type="service" id="event_dispatcher" />
<argument>%sylius.channel_pricing_log_entry.old_logs_removal_batch_size%</argument>
</service>
<service id="sylius.unpaid_orders_state_updater" class="Sylius\Component\Core\Updater\UnpaidOrdersStateUpdater">
<argument type="service" id="sylius.repository.order" />
<argument type="service" id="sm.factory" />

View file

@ -23,6 +23,11 @@
<tag name="console.command" />
</service>
<service id="Sylius\Bundle\CoreBundle\PriceHistory\Command\ClearPriceHistoryCommand">
<argument type="service" id="Sylius\Bundle\CoreBundle\PriceHistory\Remover\ChannelPricingLogEntriesRemoverInterface" />
<tag name="console.command"/>
</service>
<service id="Sylius\Bundle\CoreBundle\Command\InstallAssetsCommand">
<tag name="console.command" />
</service>

View file

@ -0,0 +1,124 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Paweł Jędrzejewski
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Tests\Sylius\Bundle\CoreBundle\PriceHistory\Command;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Sylius\Bundle\CoreBundle\PriceHistory\Command\ClearPriceHistoryCommand;
use Sylius\Bundle\CoreBundle\PriceHistory\Remover\ChannelPricingLogEntriesRemoverInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;
final class ClearPriceHistoryCommandTest extends TestCase
{
private CommandTester $commandTester;
private MockObject $remover;
protected function setUp(): void
{
parent::setUp();
$this->remover = $this->createMock(ChannelPricingLogEntriesRemoverInterface::class);
$this->commandTester = new CommandTester(new ClearPriceHistoryCommand($this->remover));
}
/**
* @test
*
* @dataProvider getInvalidDays
*/
public function it_does_not_clear_pricing_history_when_number_of_days_is_invalid(mixed $days): void
{
$this->remover->expects($this->never())->method('remove');
$this->commandTester->execute(['days' => $days]);
$this->assertSame(Command::FAILURE, $this->commandTester->getStatusCode());
$this->assertStringContainsString(
'Number of days must be an integer greater than 0',
$this->commandTester->getDisplay(),
);
}
/**
* @test
*
* @dataProvider getValidDays
*/
public function it_clears_pricing_history_when_non_interactive(int|string $days): void
{
$this->remover->expects($this->once())->method('remove');
$this->commandTester->execute(['days' => $days], ['interactive' => false]);
$this->commandTester->assertCommandIsSuccessful();
}
/** @test */
public function it_asks_for_confirmation_when_interactive(): void
{
$this->remover->expects($this->once())->method('remove');
$this->commandTester->setInputs(['yes']);
$this->commandTester->execute(['days' => 30], ['interactive' => true]);
$this->assertStringContainsString(
'Are you sure you want to clear the price history from before 30 days ago?',
$this->commandTester->getDisplay(),
);
$this->assertSame(Command::SUCCESS, $this->commandTester->getStatusCode());
}
/** @test */
public function it_does_nothing_when_user_does_not_confirm(): void
{
$this->remover->expects($this->never())->method('remove');
$this->commandTester->setInputs(['no']);
$this->commandTester->execute(['days' => 30], ['interactive' => true]);
$this->assertStringContainsString(
'Are you sure you want to clear the price history from before 30 days ago?',
$this->commandTester->getDisplay(),
);
$this->assertSame(Command::INVALID, $this->commandTester->getStatusCode());
}
public function getInvalidDays(): iterable
{
yield [0];
yield ['0'];
yield [0.0];
yield ['0.0'];
yield [-1];
yield ['-1'];
yield [-1.0];
yield ['-1.0'];
yield [1.1];
yield ['1.1'];
yield ['a'];
}
public function getValidDays(): iterable
{
yield [1];
yield ['1'];
yield [30];
yield ['30'];
yield [60];
yield ['60'];
}
}

View file

@ -0,0 +1,145 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Paweł Jędrzejewski
*
* 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\CoreBundle\PriceHistory\Remover;
use Doctrine\Persistence\ObjectManager;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
use Sylius\Bundle\CoreBundle\PriceHistory\Event\OldChannelPricingLogEntriesEvents;
use Sylius\Bundle\CoreBundle\PriceHistory\Remover\ChannelPricingLogEntriesRemover;
use Sylius\Calendar\Provider\DateTimeProviderInterface;
use Sylius\Component\Core\Model\ChannelPricingLogEntryInterface;
use Sylius\Component\Core\Repository\ChannelPricingLogEntryRepositoryInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\EventDispatcher\GenericEvent;
final class ChannelPricingLogEntriesRemoverSpec extends ObjectBehavior
{
private const BATCH_SIZE = 1;
function let(
ChannelPricingLogEntryRepositoryInterface $channelPricingLogEntriesRepository,
ObjectManager $manager,
DateTimeProviderInterface $dateTimeProvider,
EventDispatcherInterface $eventDispatcher,
): void {
$this->beConstructedWith(
$channelPricingLogEntriesRepository,
$manager,
$dateTimeProvider,
$eventDispatcher,
self::BATCH_SIZE,
);
}
function it_implements_channel_pricing_log_entries_remover_interface(): void
{
$this->shouldImplement(ChannelPricingLogEntriesRemover::class);
}
function it_does_nothing_when_no_log_entries_were_found(
ChannelPricingLogEntryRepositoryInterface $channelPricingLogEntriesRepository,
ObjectManager $manager,
DateTimeProviderInterface $dateTimeProvider,
EventDispatcherInterface $eventDispatcher,
): void {
$date = new \DateTimeImmutable();
$dateTimeProvider->now()->willReturn($date);
$channelPricingLogEntriesRepository->findOlderThan($date->modify('-1 days'), self::BATCH_SIZE)->willReturn([]);
$manager->remove(Argument::any())->shouldNotBeCalled();
$manager->flush()->shouldNotBeCalled();
$manager->clear()->shouldNotBeCalled();
$eventDispatcher->dispatch(Argument::cetera())->shouldNotBeCalled();
$this->remove(1);
}
function it_removes_a_single_batch_of_channel_pricing_log_entries_when_there_is_no_more(
ChannelPricingLogEntryRepositoryInterface $channelPricingLogEntriesRepository,
ObjectManager $manager,
DateTimeProviderInterface $dateTimeProvider,
EventDispatcherInterface $eventDispatcher,
ChannelPricingLogEntryInterface $channelPricingLogEntry,
): void {
$date = new \DateTimeImmutable();
$dateTimeProvider->now()->willReturn($date);
$channelPricingLogEntriesRepository
->findOlderThan($date->modify('-1 days'), self::BATCH_SIZE)
->willReturn([$channelPricingLogEntry], [])
;
$manager->remove($channelPricingLogEntry)->shouldBeCalled();
$eventDispatcher->dispatch(
new GenericEvent([$channelPricingLogEntry->getWrappedObject()]),
OldChannelPricingLogEntriesEvents::PRE_REMOVE,
)->shouldBeCalled();
$manager->flush()->shouldBeCalled();
$eventDispatcher->dispatch(
new GenericEvent([$channelPricingLogEntry->getWrappedObject()]),
OldChannelPricingLogEntriesEvents::POST_REMOVE,
)->shouldBeCalled();
$manager->clear()->shouldBeCalled();
$this->remove(1);
}
function it_removes_multiple_batches_of_channel_pricing_log_entries(
ChannelPricingLogEntryRepositoryInterface $channelPricingLogEntriesRepository,
ObjectManager $manager,
DateTimeProviderInterface $dateTimeProvider,
EventDispatcherInterface $eventDispatcher,
ChannelPricingLogEntryInterface $firstChannelPricingLogEntry,
ChannelPricingLogEntryInterface $secondChannelPricingLogEntry,
): void {
$date = new \DateTimeImmutable();
$dateTimeProvider->now()->willReturn($date);
$channelPricingLogEntriesRepository
->findOlderThan($date->modify('-1 days'), self::BATCH_SIZE)
->willReturn([$firstChannelPricingLogEntry], [$secondChannelPricingLogEntry], [])
;
$eventDispatcher->dispatch(
new GenericEvent([$firstChannelPricingLogEntry->getWrappedObject()]),
OldChannelPricingLogEntriesEvents::PRE_REMOVE,
)->shouldBeCalledTimes(1);
$eventDispatcher->dispatch(
new GenericEvent([$secondChannelPricingLogEntry->getWrappedObject()]),
OldChannelPricingLogEntriesEvents::PRE_REMOVE,
)->shouldBeCalledTimes(1);
$manager->remove($firstChannelPricingLogEntry)->shouldBeCalledTimes(1);
$manager->remove($secondChannelPricingLogEntry)->shouldBeCalledTimes(1);
$eventDispatcher->dispatch(
new GenericEvent([$firstChannelPricingLogEntry->getWrappedObject()]),
OldChannelPricingLogEntriesEvents::POST_REMOVE,
)->shouldBeCalledTimes(1);
$eventDispatcher->dispatch(
new GenericEvent([$secondChannelPricingLogEntry->getWrappedObject()]),
OldChannelPricingLogEntriesEvents::POST_REMOVE,
)->shouldBeCalledTimes(1);
$manager->flush()->shouldBeCalledTimes(2);
$manager->clear()->shouldBeCalledTimes(2);
$this->remove(1);
}
}