feature #14854 [PriceHistory][API] ChannelPricingLogEntry implementation (Rafikooo)

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?      | yes                                                       |
| Deprecations?   | yes <!-- don't forget to update the UPGRADE-*.md file --> |
| Related tickets | https://github.com/Sylius/Sylius/pull/14852                     |
| 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
-->
![image](https://user-images.githubusercontent.com/40125720/225167570-f37391d2-d589-4382-a141-218193cf5a17.png)



Commits
-------

9044e9f491 [PriceHistory] Add ChannelPricingLogEntry entity
68d613978e [PriceHistory] Add ChannelPricingLogEntry repository
27987feea7 [PriceHistory] Add ChannelPricingLogEntry factory
3fa4ed6c38 [PriceHistory] Add ChannelPricingLogEntry migration
c30530f77a [PriceHistory] Add ChannelPricingLogEntry resource configuration
bac7a9d2b3 [PriceHistory] Tag scenarios with seeing price history enabled
f774da1af8 [PriceHistory] Add missing product and catalog promotion setup
23d616c3c2 [PriceHistory][API] Add behat context
87f4dc81b5 [PriceHistory][API][Admin] Add ChannelPricingLogEnties endpoint
ea5c2e6148 [PriceHistory][Domain] Add fields to Channel and ChannelPricing entites
0a98a1eb80 [PriceHistory][Migration] Add migration for new Channel and ChannelPricing entity fields
bbbebe52a5 [PriceHistory] Add a core implementation of calculating the lowest price before discount
0cdf3cfa5f [PriceHistory][Test] Add contract tests for ChannelPricingLogEntry entity
af8844eb75 [Channel] Add sylius validation group parameter
75b24af569 [PriceHistory] Reflect changes from the plugin
This commit is contained in:
Grzegorz Sadowski 2023-03-17 14:47:49 +01:00 committed by GitHub
commit f74f7f3c48
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
64 changed files with 2456 additions and 18 deletions

View file

@ -1,4 +1,4 @@
@managing_product_variants
@viewing_price_history
Feature: Seeing the correct catalog price history after creating a product variant
In order to be aware of historical product prices
As an Administrator
@ -8,7 +8,7 @@ Feature: Seeing the correct catalog price history after creating a product varia
Given the store operates on a single channel in "United States"
And I am logged in as an administrator
@todo
@api
Scenario: Seeing historical product variant prices after the product variant has been created without any promotion applied
Given the store has a "Wyborowa Vodka" configurable product
When I want to create a new variant of this product
@ -20,7 +20,7 @@ Feature: Seeing the correct catalog price history after creating a product varia
Then I should see a single log entry in the catalog price history
And there should be a log entry with the "$20.00" selling price, "$25.00" original price and datetime of the price change
@todo
@api
Scenario: Seeing historical product variant prices after the product variant has been created without original price and any promotion applied
Given the store has a "Wyborowa Vodka" configurable product
When I want to create a new variant of this product
@ -31,7 +31,7 @@ Feature: Seeing the correct catalog price history after creating a product varia
Then I should see a single log entry in the catalog price history
And there should be a log entry with the "$20.00" selling price, no original price and datetime of the price change
@todo
@api
Scenario: Seeing historical product variant prices after the product variant has been created with catalog promotions applied
Given the store has a "Wyborowa Vodka" configurable product
And there is a catalog promotion "Christmas sale" that reduces price by "50%" and applies on "Wyborowa Vodka" product

View file

@ -1,4 +1,4 @@
@managing_product_variants
@viewing_price_history
Feature: Seeing the correct catalog price history after editing a product variant
In order to be aware of historical product prices
As an Administrator
@ -9,7 +9,7 @@ Feature: Seeing the correct catalog price history after editing a product varian
And the store has a product "Wyborowa Vodka" priced at "$40.00" in "United States" channel
And I am logged in as an administrator
@todo
@api
Scenario: Seeing historical product variant prices after the product variant has been edited
When I want to modify the "Wyborowa Vodka" product variant
And I change its price to "$42.00" for "United States" channel

View file

@ -1,4 +1,4 @@
@managing_catalog_promotions
@viewing_price_history_after_catalog_promotions
Feature: Seeing the price history of a product variant after changes to catalog promotions
In order to be aware of historical variant prices
As an Administrator
@ -14,14 +14,14 @@ Feature: Seeing the price history of a product variant after changes to catalog
And there is an exclusive catalog promotion "Extra sale" with priority 10 that reduces price by "10%" and applies on "Wyborowa Vodka Lemon" variant
And I am logged in as an administrator
@todo
@api
Scenario: Seeing the catalog price history of a variant with many catalog promotions
And I go to the price history of a variant with code "WYBOROWA_VODKA_EXQUISITE"
Then I should see 2 log entries in the catalog price history
And there should be a log entry on the 1st position with the "$5.00" selling price, "$15.00" original price and datetime of the price change
And there should be a log entry on the 2nd position with the "$40.00" selling price, "$15.00" original price and datetime of the price change
@todo
@api
Scenario: Seeing the catalog price history of a variant with one catalog promotion
And I go to the price history of a variant with code "WYBOROWA_VODKA_LEMON"
Then I should see 2 log entries in the catalog price history

View file

@ -1,4 +1,4 @@
@managing_catalog_promotions
@viewing_price_history_after_catalog_promotions
Feature: Seeing the price history of a simple product after changes to catalog promotions
In order to be aware of historical simple product prices
As an Administrator
@ -13,7 +13,7 @@ Feature: Seeing the price history of a simple product after changes to catalog p
And it reduces price by "90%"
And I am logged in as an administrator
@todo
@api
Scenario: Seeing the catalog price history of a simple product
Given the "Winter sale" catalog promotion is enabled
When I disable "Winter sale" catalog promotion
@ -23,7 +23,7 @@ Feature: Seeing the price history of a simple product after changes to catalog p
And there should be a log entry on the 2nd position with the "$10.00" selling price, "$100.00" original price and datetime of the price change
And there should be a log entry on the 3rd position with the "$100.00" selling price, no original price and datetime of the price change
@todo
@api
Scenario: Seeing the catalog price history of a simple product with a price different than an original price
Given "Wyborowa Vodka" variant is originally priced at "$120.00" in "United States" channel
And the "Winter sale" catalog promotion is enabled

View file

@ -228,6 +228,12 @@
<!-- level 8 issues - some fatal errors in PHP -->
<MethodSignatureMismatch errorLevel="info" />
<InvalidStringClass>
<errorLevel type="info">
<file name="src/Sylius/Component/Core/Factory/ChannelPricingLogEntryFactory.php" />
</errorLevel>
</InvalidStringClass>
</issueHandlers>
<stubs>

View file

@ -0,0 +1,90 @@
<?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\Api\Admin;
use Behat\Behat\Context\Context;
use Sylius\Behat\Client\ApiClientInterface;
use Sylius\Behat\Client\ResponseCheckerInterface;
use Sylius\Behat\Context\Api\Resources;
use Sylius\Behat\Service\SharedStorageInterface;
use Sylius\Component\Core\Model\ProductVariantInterface;
use Webmozart\Assert\Assert;
final class ChannelPricingLogEntryContext implements Context
{
public function __construct(
private ApiClientInterface $client,
private ResponseCheckerInterface $responseChecker,
private SharedStorageInterface $sharedStorage,
) {
}
/**
* @When /^I go to the price history of a (variant with code "[^"]+")$/
*/
public function iGoToThePriceHistoryOfAVariant(ProductVariantInterface $productVariant): void
{
$channel = $this->sharedStorage->get('channel');
Assert::notNull($channel);
$this->sharedStorage->set('variant', $productVariant);
$this->client->index(Resources::CHANNEL_PRICING_LOG_ENTRY);
$this->client->addFilter('channelPricing.channelCode', $channel->getCode());
$this->client->addFilter('channelPricing.productVariant.code', $productVariant->getCode());
$this->client->filter();
}
/**
* @Then I should see :count log entries in the catalog price history
* @Then I should see a single log entry in the catalog price history
*/
public function iShouldSeeLogEntriesInTheCatalogPriceHistoryForTheVariant(int $count = 1): void
{
Assert::same($this->responseChecker->countCollectionItems($this->client->getLastResponse()), $count);
}
/**
* @Then /^there should be a log entry on the (\d+)(?:|st|nd|rd|th) position with the ("[^"]+") selling price, (no|"[^"]+") original price and datetime of the price change$/
*/
public function thereShouldBeALogEntryOnThePositionWithTheSellingPriceOriginalPriceAndDatetimeOfThePriceChange(
int $position,
int $price,
int|string $originalPrice,
): void {
if ('no' === $originalPrice) {
$originalPrice = null;
}
$logEntry = $this->responseChecker->getCollection($this->client->getLastResponse())[$position - 1];
Assert::same($logEntry['price'], $price);
Assert::same($logEntry['originalPrice'], $originalPrice);
Assert::keyExists($logEntry, 'loggedAt');
}
/**
* @Then /^there should be a log entry with the ("[^"]+") selling price, (no|"[^"]+") original price and datetime of the price change$/
*/
public function thereShouldBeALogEntryWithTheSellingPriceOriginalPriceAndDatetimeOfThePriceChange(
int $price,
int|string $originalPrice,
): void {
$this->thereShouldBeALogEntryOnThePositionWithTheSellingPriceOriginalPriceAndDatetimeOfThePriceChange(
1,
$price,
$originalPrice,
);
}
}

View file

@ -63,6 +63,19 @@ final class ManagingProductVariantsContext implements Context
]);
}
/**
* @When /^I set its original price to ("[^"]+") for ("[^"]+" channel)$/
*/
public function iSetItsOriginalPriceToForChannel(int $originalPrice, ChannelInterface $channel): void
{
$this->client->addRequestData('channelPricings', [
$channel->getCode() => [
'originalPrice' => $originalPrice,
'channelCode' => $channel->getCode(),
],
]);
}
/**
* @When /^I set its minimum price to ("[^"]+") for ("[^"]+" channel)$/
*/
@ -126,6 +139,27 @@ final class ManagingProductVariantsContext implements Context
$this->createNewVariantWithPrice($name, $price, $product, $channel);
}
/**
* @When I want to modify the :variant product variant
*/
public function iWantToModifyProductVariant(ProductVariantInterface $variant): void
{
$this->client->buildUpdateRequest(Resources::PRODUCT_VARIANTS, $variant->getCode());
}
/**
* @When /^I change its price to ("[^"]+") for ("[^"]+" channel)$/
*/
public function iChangeItsPriceToForChannel(int $originalPrice, ChannelInterface $channel): void
{
$this->client->addRequestData('channelPricings', [
$channel->getCode() => [
'price' => $originalPrice,
'channelCode' => $channel->getCode(),
],
]);
}
/**
* @Then I should be notified that it has been successfully created
*/

View file

@ -23,6 +23,8 @@ final class Resources
public const CATALOG_PROMOTIONS = 'catalog-promotions';
public const CHANNEL_PRICING_LOG_ENTRY = 'channel-pricing-log-entries';
public const CHANNELS = 'channels';
public const CONTACT_REQUESTS = 'contact-requests';

View file

@ -773,6 +773,84 @@ final class CatalogPromotionContext implements Context
$this->entityManager->flush();
}
/**
* @Given the :catalogPromotion catalog promotion is enabled
*/
public function theCatalogPromotionIsEnabled(CatalogPromotionInterface $catalogPromotion): void
{
$catalogPromotion->setEnabled(true);
$this->entityManager->flush();
$this->eventBus->dispatch(new CatalogPromotionUpdated($catalogPromotion->getCode()));
}
/**
* @Given there is disabled catalog promotion named :name
*/
public function thereIsCatalogPromotionsNamed(string $name): void
{
$this->createCatalogPromotion(name: $name, enabled: false);
$this->entityManager->flush();
}
/**
* @Given /^there is a catalog promotion "([^"]+)" with priority ([^"]+) that reduces price by ("[^"]+") and applies on ("[^"]+" product)$/
*/
public function thereIsACatalogPromotionWithPriorityThatReducesPriceByAndAppliesOnProduct(
string $name,
int $priority,
float $discount,
ProductInterface $product,
): void {
$catalogPromotion = $this->createCatalogPromotion(
name: $name,
scopes: [[
'type' => InForProductScopeVariantChecker::TYPE,
'configuration' => ['products' => [$product->getCode()]],
]],
actions: [[
'type' => PercentageDiscountPriceCalculator::TYPE,
'configuration' => ['amount' => $discount],
]],
priority: $priority,
);
$this->entityManager->flush();
$this->eventBus->dispatch(new CatalogPromotionUpdated($catalogPromotion->getCode()));
}
/**
* @Given /^there is disabled catalog promotion "([^"]+)" with priority ([^"]+) that reduces price by fixed ("[^"]+") in the ("[^"]+" channel) and applies on ("[^"]+" product)$/
*/
public function thereIsDisabledCatalogPromotionWithPriorityThatReducesPriceByFixedInTheChannelAndAppliesOnProduct(
string $name,
int $priority,
int $discount,
ChannelInterface $channel,
ProductInterface $product,
): void {
$catalogPromotion = $this->createCatalogPromotion(
name: $name,
channels: [$channel],
scopes: [[
'type' => InForProductScopeVariantChecker::TYPE,
'configuration' => ['products' => [$product->getCode()]],
]],
actions: [[
'type' => FixedDiscountPriceCalculator::TYPE,
'configuration' => [$channel->getCode() => ['amount' => $discount / 100]],
]],
priority: $priority,
enabled: false,
);
$this->entityManager->flush();
$this->eventBus->dispatch(new CatalogPromotionUpdated($catalogPromotion->getCode()));
}
private function createCatalogPromotion(
string $name,
?string $code = null,

View file

@ -18,6 +18,7 @@ use Behat\Gherkin\Node\TableNode;
use Behat\Mink\Element\NodeElement;
use Doctrine\Persistence\ObjectManager;
use Sylius\Behat\Service\SharedStorageInterface;
use Sylius\Component\Core\Event\ProductUpdated;
use Sylius\Component\Core\Formatter\StringInflector;
use Sylius\Component\Core\Model\ChannelInterface;
use Sylius\Component\Core\Model\ChannelPricingInterface;
@ -39,6 +40,7 @@ use Sylius\Component\Resource\Factory\FactoryInterface;
use Sylius\Component\Shipping\Model\ShippingCategoryInterface;
use Sylius\Component\Taxation\Model\TaxCategoryInterface;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\Messenger\MessageBusInterface;
use Webmozart\Assert\Assert;
final class ProductContext implements Context
@ -60,6 +62,7 @@ final class ProductContext implements Context
private ImageUploaderInterface $imageUploader,
private SlugGeneratorInterface $slugGenerator,
private \ArrayAccess $minkParameters,
private MessageBusInterface $eventBus,
) {
}
@ -1076,6 +1079,162 @@ final class ProductContext implements Context
$this->saveProduct($product);
}
/**
* @Given /^(this product) has all possible variants priced at ("[^"]+") with indexed names$/
*/
public function thisProductHasAllPossibleVariantsPricedAtWithIndexedNames(
ProductInterface $product,
int $price,
): void {
try {
foreach ($product->getVariants() as $productVariant) {
$product->removeVariant($productVariant);
}
$this->productVariantGenerator->generate($product);
} catch (\InvalidArgumentException) {
/** @var ProductVariantInterface $productVariant */
$productVariant = $this->productVariantFactory->createNew();
$product->addVariant($productVariant);
}
$i = 0;
/** @var ProductVariantInterface $productVariant */
foreach ($product->getVariants() as $productVariant) {
$productVariant->setCode(sprintf('%s-variant-%d', $product->getCode(), $i));
$productVariant->setName(sprintf('%s variant %d', $product->getName(), $i));
foreach ($product->getChannels() as $channel) {
$productVariant->addChannelPricing($this->createChannelPricingForChannel($price, $channel));
}
++$i;
}
$this->objectManager->flush();
}
/**
* @Given /^the ("[^"]+" product) is now priced at ("[^"]+") and originally priced at ("[^"]+")$/
*/
public function theProductIsPricedAtAndOriginallyPricedAt(
ProductInterface $product,
int $price,
int $originalPrice,
): void {
$channelPricing = $this->getChannelPricingFromProduct($product);
$channelPricing->setPrice($price);
$channelPricing->setOriginalPrice($originalPrice);
$this->saveProduct($product);
}
/**
* @Given /^the (product "[^"]+") has a "([^"]+)" variant priced at ("[^"]+") and originally priced at ("[^"]+")$/
*/
public function theProductHasVariantPricedAtAndOriginallyPricedAt(
ProductInterface $product,
string $productVariantName,
int $price,
int $originalPrice,
): void {
/** @var ChannelPricingInterface $channelPricing */
$channelPricing = $this->channelPricingFactory->createNew();
$channelPricing->setPrice($price);
$channelPricing->setOriginalPrice($originalPrice);
$channelPricing->setChannelCode($this->sharedStorage->get('channel')->getCode());
/** @var ProductVariantInterface $variant */
$variant = $this->productVariantFactory->createNew();
$variant->setName($productVariantName);
$variant->setCode(StringInflector::nameToUppercaseCode($productVariantName));
$variant->setProduct($product);
$variant->setOnHand(0);
$variant->addChannelPricing($channelPricing);
$variant->setShippingRequired(true);
$product->setVariantSelectionMethod(ProductInterface::VARIANT_SELECTION_CHOICE);
$product->addVariant($variant);
$this->saveProduct($product);
}
/**
* @Given /^(this product)'s price changed to ("[^"]+")$/
*/
public function thisProductsPriceChangedTo(ProductInterface $product, int $price): void
{
$channelPricing = $this->getChannelPricingFromProduct($product);
$channelPricing->setPrice($price);
$this->saveProduct($product);
}
/**
* @Given /^(this product)'s price changed to ("[^"]+") and original price changed to ("[^"]+")$/
*/
public function thisProductsPriceChangedToAndOriginalPriceChangedTo(
ProductInterface $product,
int $price,
int $originalPrice,
): void {
$channelPricing = $this->getChannelPricingFromProduct($product);
$channelPricing->setPrice($price);
$channelPricing->setOriginalPrice($originalPrice);
$this->saveProduct($product);
}
/**
* @Given /^(this variant)'s price changed to ("[^"]+") and original price changed to ("[^"]+")$/
*/
public function thisVariantsPriceChangedToAndOriginalPriceChangedTo(
ProductVariantInterface $productVariant,
int $price,
int $originalPrice,
): void {
$channelPricing = $this->getChannelPricingFromVariant($productVariant);
$channelPricing->setPrice($price);
$channelPricing->setOriginalPrice($originalPrice);
/** @var ProductInterface $product */
$product = $productVariant->getProduct();
$this->saveProduct($product);
}
/**
* @Given /^(this product)'s price changed to ("[^"]+") and original price was removed$/
*/
public function thisProductsPriceChangedToAndOriginalPriceWasRemoved(ProductInterface $product, $price): void
{
$channelPricing = $this->getChannelPricingFromProduct($product);
$channelPricing->setPrice($price);
$channelPricing->setOriginalPrice(null);
$this->saveProduct($product);
}
private function getChannelPricingFromProduct(ProductInterface $product): ChannelPricingInterface
{
$variant = $this->defaultVariantResolver->getVariant($product);
Assert::notNull($variant);
return $this->getChannelPricingFromVariant($variant);
}
private function getChannelPricingFromVariant(ProductVariantInterface $productVariant): ChannelPricingInterface
{
$channelPricing = $productVariant->getChannelPricings()->first();
Assert::isInstanceOf($channelPricing, ChannelPricingInterface::class);
return $channelPricing;
}
private function getPriceFromString(string $price): int
{
return (int) round((float) str_replace(['€', '£', '$'], '', $price) * 100, 2);
@ -1147,6 +1306,8 @@ final class ProductContext implements Context
private function saveProduct(ProductInterface $product)
{
$this->productRepository->add($product);
$this->eventBus->dispatch(new ProductUpdated($product->getCode()));
$this->sharedStorage->set('variant', $product->getVariants()->first());
$this->sharedStorage->set('product', $product);
}
@ -1238,10 +1399,7 @@ final class ProductContext implements Context
$productVariant->addTranslation($translation);
}
/**
* @return ChannelPricingInterface
*/
private function createChannelPricingForChannel(int $price, ChannelInterface $channel = null)
private function createChannelPricingForChannel(int $price, ChannelInterface $channel = null): ChannelPricingInterface
{
/** @var ChannelPricingInterface $channelPricing */
$channelPricing = $this->channelPricingFactory->createNew();

View file

@ -194,5 +194,11 @@
<argument type="service" id="Sylius\Behat\Client\ResponseCheckerInterface" />
<argument>%sylius.security.new_api_route%</argument>
</service>
<service id="Sylius\Behat\Context\Api\Admin\ChannelPricingLogEntryContext" public="true">
<argument type="service" id="sylius.behat.api_platform_client.admin" />
<argument type="service" id="Sylius\Behat\Client\ResponseCheckerInterface" />
<argument type="service" id="sylius.behat.shared_storage" />
</service>
</services>
</container>

View file

@ -151,6 +151,7 @@
<argument type="service" id="sylius.image_uploader" />
<argument type="service" id="sylius.generator.slug" />
<argument type="service" id="behat.mink.parameters" />
<argument type="service" id="sylius.event_bus" />
</service>
<service id="sylius.behat.context.setup.product_association" class="Sylius\Behat\Context\Setup\ProductAssociationContext">

View file

@ -36,9 +36,11 @@ imports:
- suites/api/product/managing_product_variants.yml
- suites/api/product/managing_product_variants_ajax.yml
- suites/api/product/managing_products.yml
- suites/api/product/viewing_products.yml
- suites/api/product/viewing_price_history.yml
- suites/api/product/viewing_price_history_after_catalog_promotions.yml
- suites/api/product/viewing_product_reviews.yml
- suites/api/product/viewing_product_variants.yml
- suites/api/product/viewing_products.yml
- suites/api/promotion/applying_catalog_promotions.yml
- suites/api/promotion/applying_promotion_coupon.yml
- suites/api/promotion/applying_promotion_rules.yml

View file

@ -32,8 +32,10 @@ default:
- sylius.behat.context.setup.product
- sylius.behat.context.setup.admin_api_security
- sylius.behat.context.setup.taxonomy
- Sylius\Behat\Context\Setup\CatalogPromotionContext
- sylius.behat.context.api.admin.managing_product_variants
- Sylius\Behat\Context\Api\Admin\ChannelPricingLogEntryContext
filters:
tags: "@managing_product_variants&&@api"
javascript: false

View file

@ -0,0 +1,27 @@
# This file is part of the Sylius package.
# (c) Paweł Jędrzejewski
default:
suites:
api_viewing_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.product_variant
- sylius.behat.context.transform.shared_storage
- sylius.behat.context.setup.admin_api_security
- sylius.behat.context.setup.admin_user
- sylius.behat.context.setup.channel
- sylius.behat.context.setup.product
- Sylius\Behat\Context\Setup\CatalogPromotionContext
- sylius.behat.context.api.admin.managing_product_variants
- sylius.behat.context.api.admin.save
- Sylius\Behat\Context\Api\Admin\ChannelPricingLogEntryContext
filters:
tags: "@viewing_price_history&&@api"
javascript: false

View file

@ -0,0 +1,28 @@
# This file is part of the Sylius package.
# (c) Paweł Jędrzejewski
default:
suites:
api_viewing_price_history_after_catalog_promotions:
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.product_variant
- sylius.behat.context.transform.shared_storage
- Sylius\Behat\Context\Transform\CatalogPromotionContext
- sylius.behat.context.setup.admin_api_security
- sylius.behat.context.setup.admin_user
- sylius.behat.context.setup.channel
- sylius.behat.context.setup.product
- Sylius\Behat\Context\Setup\CatalogPromotionContext
- sylius.behat.context.api.admin.managing_catalog_promotions
- Sylius\Behat\Context\Api\Admin\ChannelPricingLogEntryContext
filters:
tags: "@viewing_price_history_after_catalog_promotions&&@api"
javascript: false

View file

@ -0,0 +1,52 @@
<?xml version="1.0" ?>
<!--
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.
-->
<resources xmlns="https://api-platform.com/schema/metadata"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://api-platform.com/schema/metadata https://api-platform.com/schema/metadata/metadata-2.0.xsd"
>
<resource class="%sylius.model.channel_pricing_log_entry.class%" shortName="ChannelPricingLogEntry">
<collectionOperations>
<collectionOperation name="admin_get">
<attribute name="method">GET</attribute>
<attribute name="path">/admin/channel-pricing-log-entries</attribute>
<attribute name="normalization_context">
<attribute name="groups">admin:channel_pricing_log_entry:read</attribute>
</attribute>
<attribute name="filters">
<attribute>sylius.api.channel_pricing_channel_filter</attribute>
<attribute>sylius.api.channel_pricing_product_variant_filter</attribute>
</attribute>
<attribute name="order">
<attribute name="id">DESC</attribute>
</attribute>
</collectionOperation>
</collectionOperations>
<itemOperations>
<itemOperation name="admin_get">
<attribute name="method">GET</attribute>
<attribute name="path">/admin/channel-pricing-log-entries/{id}</attribute>
<attribute name="normalization_context">
<attribute name="groups">admin:channel_pricing_log_entry:read</attribute>
</attribute>
</itemOperation>
</itemOperations>
<property name="id" identifier="true" writable="false" />
<property name="channelPricing" readable="true" writable="false" />
<property name="price" readable="true" writable="false" />
<property name="originalPrice" readable="true" writable="false" />
<property name="loggedAt" readable="true" writable="false" />
</resource>
</resources>

View file

@ -0,0 +1,32 @@
<?xml version="1.0" ?>
<!--
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.
-->
<serializer xmlns="http://symfony.com/schema/dic/serializer-mapping"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/dic/serializer-mapping https://symfony.com/schema/dic/serializer-mapping/serializer-mapping-1.0.xsd"
>
<class name="Sylius\Component\Core\Model\ChannelPricingLogEntry">
<attribute name="channelPricing">
<group>admin:channel_pricing_log_entry:read</group>
</attribute>
<attribute name="price">
<group>admin:channel_pricing_log_entry:read</group>
</attribute>
<attribute name="originalPrice">
<group>admin:channel_pricing_log_entry:read</group>
</attribute>
<attribute name="loggedAt">
<group>admin:channel_pricing_log_entry:read</group>
</attribute>
</class>
</serializer>

View file

@ -196,5 +196,19 @@
</argument>
<tag name="api_platform.filter" />
</service>
<service id="sylius.api.channel_pricing_channel_filter" parent="api_platform.doctrine.orm.search_filter" public="true">
<argument type="collection">
<argument key="channelPricing.channelCode">exact</argument>
</argument>
<tag name="api_platform.filter" />
</service>
<service id="sylius.api.channel_pricing_product_variant_filter" parent="api_platform.doctrine.orm.search_filter" public="true">
<argument type="collection">
<argument key="channelPricing.productVariant.code">exact</argument>
</argument>
<tag name="api_platform.filter" />
</service>
</services>
</container>

View file

@ -13,6 +13,10 @@
<container xmlns="http://symfony.com/schema/dic/services" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
<parameters>
<parameter key="sylius.channel.validation_groups" type="collection">
<parameter>sylius</parameter>
</parameter>
<parameter key="sylius.form.type.channel.validation_groups" type="collection">
<parameter>sylius</parameter>
</parameter>

View file

@ -14,14 +14,19 @@ declare(strict_types=1);
namespace Sylius\Bundle\CoreBundle\DependencyInjection;
use Sylius\Bundle\CoreBundle\Controller\ProductTaxonController;
use Sylius\Bundle\CoreBundle\Doctrine\ORM\ChannelPricingLogEntryRepository;
use Sylius\Bundle\CoreBundle\Form\Type\Product\ChannelPricingType;
use Sylius\Bundle\CoreBundle\Form\Type\ShopBillingDataType;
use Sylius\Bundle\ResourceBundle\Controller\ResourceController;
use Sylius\Bundle\ResourceBundle\Form\Type\DefaultResourceType;
use Sylius\Bundle\ResourceBundle\SyliusResourceBundle;
use Sylius\Component\Core\Factory\ChannelPricingLogEntryFactory;
use Sylius\Component\Core\Model\AvatarImage;
use Sylius\Component\Core\Model\AvatarImageInterface;
use Sylius\Component\Core\Model\ChannelPricing;
use Sylius\Component\Core\Model\ChannelPricingInterface;
use Sylius\Component\Core\Model\ChannelPricingLogEntry;
use Sylius\Component\Core\Model\ChannelPricingLogEntryInterface;
use Sylius\Component\Core\Model\ProductImage;
use Sylius\Component\Core\Model\ProductImageInterface;
use Sylius\Component\Core\Model\ProductTaxon;
@ -175,6 +180,23 @@ final class Configuration implements ConfigurationInterface
->end()
->end()
->end()
->arrayNode('channel_pricing_log_entry')
->addDefaultsIfNotSet()
->children()
->variableNode('options')->end()
->arrayNode('classes')
->addDefaultsIfNotSet()
->children()
->scalarNode('model')->defaultValue(ChannelPricingLogEntry::class)->cannotBeEmpty()->end()
->scalarNode('interface')->defaultValue(ChannelPricingLogEntryInterface::class)->cannotBeEmpty()->end()
->scalarNode('controller')->defaultValue(ResourceController::class)->cannotBeEmpty()->end()
->scalarNode('repository')->defaultValue(ChannelPricingLogEntryRepository::class)->cannotBeEmpty()->end()
->scalarNode('factory')->defaultValue(ChannelPricingLogEntryFactory::class)->end()
->scalarNode('form')->defaultValue(DefaultResourceType::class)->cannotBeEmpty()->end()
->end()
->end()
->end()
->end()
->arrayNode('shop_billing_data')
->addDefaultsIfNotSet()
->children()

View file

@ -0,0 +1,128 @@
<?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\Doctrine\ORM;
use Doctrine\ORM\QueryBuilder;
use Sylius\Bundle\ResourceBundle\Doctrine\ORM\EntityRepository;
use Sylius\Component\Core\Model\ChannelPricingInterface;
use Sylius\Component\Core\Model\ChannelPricingLogEntryInterface;
use Sylius\Component\Core\Repository\ChannelPricingLogEntryRepositoryInterface;
use Webmozart\Assert\Assert;
class ChannelPricingLogEntryRepository extends EntityRepository implements ChannelPricingLogEntryRepositoryInterface
{
public function createByChannelPricingIdListQueryBuilder(mixed $channelPricingId): QueryBuilder
{
return $this->createQueryBuilder('o')
->innerJoin('o.channelPricing', 'channelPricing')
->andWhere('channelPricing = :channelPricingId')
->orderBy('o.id', 'DESC')
->setParameter('channelPricingId', $channelPricingId)
;
}
public function findOlderThan(\DateTimeInterface $date, ?int $limit = null): array
{
$queryBuilder = $this->createQueryBuilder('o')
->andWhere('o.loggedAt < :date')
->setParameter('date', $date)
;
if (null !== $limit) {
Assert::positiveInteger($limit);
$queryBuilder->setMaxResults($limit);
}
return $queryBuilder->getQuery()->getResult();
}
public function findLatestOneByChannelPricing(ChannelPricingInterface $channelPricing): ?ChannelPricingLogEntryInterface
{
/** @var ChannelPricingLogEntryInterface|null $channelPricingLogEntry */
$channelPricingLogEntry = $this->createQueryBuilder('o')
->andWhere('o.channelPricing = :channelPricing')
->setParameter('channelPricing', $channelPricing)
->orderBy('o.id', 'DESC')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult()
;
return $channelPricingLogEntry;
}
public function findLowestPriceInPeriod(
int $latestChannelPricingLogEntryId,
ChannelPricingInterface $channelPricing,
\DateTimeInterface $startDate,
): ?int {
$lowestPriceSetInPeriod = $this->findLowestPriceSetInPeriod($latestChannelPricingLogEntryId, $channelPricing, $startDate);
$latestPriceSetBeyondPeriod = $this->findLatestPriceSetBeyondPeriod($channelPricing, $startDate);
if (null === $lowestPriceSetInPeriod) {
return $latestPriceSetBeyondPeriod;
}
if (null === $latestPriceSetBeyondPeriod) {
return $lowestPriceSetInPeriod;
}
if ($latestPriceSetBeyondPeriod < $lowestPriceSetInPeriod) {
return $latestPriceSetBeyondPeriod;
}
return $lowestPriceSetInPeriod;
}
private function findLowestPriceSetInPeriod(
int $latestChannelPricingLogEntryId,
ChannelPricingInterface $channelPricing,
\DateTimeInterface $startDate,
): ?int {
/** @var ChannelPricingLogEntryInterface|null $channelPricingLogEntry */
$channelPricingLogEntry = $this->createQueryBuilder('o')
->andWhere('o.loggedAt >= :startDate')
->andWhere('o.id != :channelPricingLogEntryId')
->andWhere('o.channelPricing = :channelPricing')
->setParameter('startDate', $startDate)
->setParameter('channelPricing', $channelPricing)
->setParameter('channelPricingLogEntryId', $latestChannelPricingLogEntryId)
->orderBy('o.price', 'ASC')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult()
;
return $channelPricingLogEntry?->getPrice();
}
private function findLatestPriceSetBeyondPeriod(
ChannelPricingInterface $channelPricing,
\DateTimeInterface $startDate,
): ?int {
/** @var ChannelPricingLogEntryInterface|null $channelPricingLogEntry */
$channelPricingLogEntry = $this->createQueryBuilder('o')
->andWhere('o.loggedAt < :startDate')
->andWhere('o.channelPricing = :channelPricing')
->setParameter('startDate', $startDate)
->setParameter('channelPricing', $channelPricing)
->orderBy('o.id', 'DESC')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult()
;
return $channelPricingLogEntry?->getPrice();
}
}

View file

@ -0,0 +1,40 @@
<?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\Migrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20230310160512 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add ChannelPricingLogEntry';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE sylius_channel_pricing_log_entry (id INT AUTO_INCREMENT NOT NULL, channel_pricing_id INT NOT NULL, price INT NOT NULL, original_price INT DEFAULT NULL, logged_at DATETIME NOT NULL, INDEX IDX_77181A53EADFFE5 (channel_pricing_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE sylius_channel_pricing_log_entry ADD CONSTRAINT FK_77181A53EADFFE5 FOREIGN KEY (channel_pricing_id) REFERENCES sylius_channel_pricing (id) ON DELETE CASCADE');
/** Create an initial log state based on the price of products at the time of migration processing */
$this->addSql('INSERT INTO `sylius_channel_pricing_log_entry` (`channel_pricing_id`, `price`, `original_price`, `logged_at`) SELECT `id`, `price`, `original_price`, NOW() FROM `sylius_channel_pricing`');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE sylius_channel_pricing_log_entry DROP FOREIGN KEY FK_77181A53EADFFE5');
$this->addSql('DROP TABLE sylius_channel_pricing_log_entry');
}
}

View file

@ -0,0 +1,35 @@
<?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\Migrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20230314073130 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add lowest_price_before_discount column to sylius_channel_pricing table';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE sylius_channel_pricing ADD lowest_price_before_discount INT DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE sylius_channel_pricing DROP lowest_price_before_discount');
}
}

View file

@ -0,0 +1,35 @@
<?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\Migrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20230314073215 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add lowest_price_for_discounted_products_checking_period column to sylius_channel table';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE sylius_channel ADD lowest_price_for_discounted_products_checking_period TINYINT(1) DEFAULT 30 NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE sylius_channel DROP lowest_price_for_discounted_products_checking_period');
}
}

View file

@ -0,0 +1,42 @@
<?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\EntityObserver;
use Sylius\Bundle\CoreBundle\PriceHistory\Logger\PriceChangeLoggerInterface;
use Sylius\Component\Core\Model\ChannelPricingInterface;
use Webmozart\Assert\Assert;
final class CreateLogEntryOnPriceChangeObserver implements EntityObserverInterface
{
public function __construct(private PriceChangeLoggerInterface $priceChangeLogger)
{
}
public function onChange(object $entity): void
{
Assert::isInstanceOf($entity, ChannelPricingInterface::class);
$this->priceChangeLogger->log($entity);
}
public function supports(object $entity): bool
{
return $entity instanceof ChannelPricingInterface && null !== $entity->getPrice();
}
public function observedFields(): array
{
return ['price', 'originalPrice'];
}
}

View file

@ -0,0 +1,26 @@
<?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\EntityObserver;
interface EntityObserverInterface
{
public function onChange(object $entity): void;
public function supports(object $entity): bool;
/**
* @return string[]
*/
public function observedFields(): array;
}

View file

@ -0,0 +1,64 @@
<?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\EntityObserver;
use Sylius\Bundle\CoreBundle\PriceHistory\Processor\ProductLowestPriceBeforeDiscountProcessorInterface;
use Sylius\Component\Core\Model\ChannelInterface;
use Sylius\Component\Core\Model\ChannelPricingInterface;
use Sylius\Component\Resource\Repository\RepositoryInterface;
use Webmozart\Assert\Assert;
final class ProcessLowestPriceOnCheckingPeriodChangeObserver implements EntityObserverInterface
{
public function __construct(
private ProductLowestPriceBeforeDiscountProcessorInterface $productLowestPriceBeforeDiscountProcessor,
private RepositoryInterface $channelPricingRepository,
private int $batchSize,
) {
}
public function onChange(object $entity): void
{
Assert::isInstanceOf($entity, ChannelInterface::class);
$limit = $this->batchSize;
$offset = 0;
do {
/** @var ChannelPricingInterface[] $channelPricings */
$channelPricings = $this->channelPricingRepository->findBy(
['channelCode' => $entity->getCode()],
['id' => 'ASC'],
$limit,
$offset,
);
foreach ($channelPricings as $channelPricing) {
$this->productLowestPriceBeforeDiscountProcessor->process($channelPricing);
}
$offset += $limit;
} while ([] !== $channelPricings);
}
public function supports(object $entity): bool
{
return $entity instanceof ChannelInterface;
}
public function observedFields(): array
{
return ['lowestPriceForDiscountedProductsCheckingPeriod'];
}
}

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\EventListener;
use ApiPlatform\Symfony\Validator\Exception\ValidationException;
use Sylius\Component\Channel\Factory\ChannelFactoryInterface;
use Sylius\Component\Channel\Model\ChannelInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\Validator\Validator\ValidatorInterface;
final class BeforeDenormalizationChannelValidationListener
{
public function __construct(
private ValidatorInterface $validator,
private ChannelFactoryInterface $channelFactory,
private array $validationGroups = [],
) {
}
public function onKernelRequest(RequestEvent $event): void
{
$request = $event->getRequest();
$parameters = $request->attributes->all();
if (
!is_a($parameters['_api_resource_class'] ?? null, ChannelInterface::class, true) ||
$request->isMethodSafe() ||
$request->isMethod(Request::METHOD_DELETE)
) {
return;
}
/** @var string $requestContent */
$requestContent = $request->getContent(false);
$content = (array) json_decode($requestContent, true, 512, \JSON_THROW_ON_ERROR);
$channelModel = $this->channelFactory->createNew();
$violations = $this->validator->startContext()->getViolations();
foreach ($content as $key => $value) {
$propertyViolations = $this->validator->validatePropertyValue(
$channelModel,
$key,
$value,
$this->validationGroups,
);
if ($propertyViolations->count() > 0) {
$violations->addAll($propertyViolations);
}
}
if ($violations->count() > 0) {
throw new ValidationException($violations);
}
}
}

View file

@ -0,0 +1,66 @@
<?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\EventListener;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\UnitOfWork;
use Sylius\Bundle\CoreBundle\PriceHistory\EntityObserver\EntityObserverInterface;
use Webmozart\Assert\Assert;
final class OnFlushEntityObserverListener
{
public function __construct(private iterable $entityObservers)
{
Assert::allImplementsInterface($this->entityObservers, EntityObserverInterface::class);
}
public function onFlush(OnFlushEventArgs $eventArgs): void
{
$entityManager = $eventArgs->getObjectManager();
$unitOfWork = $entityManager->getUnitOfWork();
$scheduledEntities = array_merge(
$unitOfWork->getScheduledEntityInsertions(),
$unitOfWork->getScheduledEntityUpdates(),
);
$atLeastOneEntityChanged = false;
foreach ($scheduledEntities as $entity) {
/** @var EntityObserverInterface $entityObserver */
foreach ($this->entityObservers as $entityObserver) {
if (
!$entityObserver->supports($entity) ||
!$this->isEntityChanged($unitOfWork, $entity, $entityObserver->observedFields())
) {
continue;
}
$atLeastOneEntityChanged = true;
$entityObserver->onChange($entity);
}
}
if ($atLeastOneEntityChanged) {
$unitOfWork->computeChangeSets();
}
}
private function isEntityChanged(UnitOfWork $unitOfWork, object $entity, array $supportedFields): bool
{
$changedFields = array_keys($unitOfWork->getEntityChangeSet($entity));
return [] !== array_intersect($changedFields, $supportedFields);
}
}

View file

@ -0,0 +1,45 @@
<?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\Logger;
use Doctrine\Persistence\ObjectManager;
use Sylius\Calendar\Provider\DateTimeProviderInterface;
use Sylius\Component\Core\Factory\ChannelPricingLogEntryFactoryInterface;
use Sylius\Component\Core\Model\ChannelPricingInterface;
use Webmozart\Assert\Assert;
final class PriceChangeLogger implements PriceChangeLoggerInterface
{
public function __construct(
private ChannelPricingLogEntryFactoryInterface $logEntryFactory,
private ObjectManager $logEntryManager,
private DateTimeProviderInterface $dateTimeProvider,
) {
}
public function log(ChannelPricingInterface $channelPricing): void
{
Assert::notNull($channelPricing->getPrice());
/** @psalm-suppress PossiblyNullArgument */
$logEntry = $this->logEntryFactory->create(
$channelPricing,
$this->dateTimeProvider->now(),
$channelPricing->getPrice(),
$channelPricing->getOriginalPrice(),
);
$this->logEntryManager->persist($logEntry);
}
}

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\Logger;
use Sylius\Component\Core\Model\ChannelPricingInterface;
interface PriceChangeLoggerInterface
{
public function log(ChannelPricingInterface $channelPricing): void;
}

View file

@ -0,0 +1,87 @@
<?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\Processor;
use Sylius\Component\Channel\Repository\ChannelRepositoryInterface;
use Sylius\Component\Core\Model\ChannelInterface;
use Sylius\Component\Core\Model\ChannelPricingInterface;
use Sylius\Component\Core\Model\ChannelPricingLogEntryInterface;
use Sylius\Component\Core\Repository\ChannelPricingLogEntryRepositoryInterface;
use Webmozart\Assert\Assert;
final class ProductLowestPriceBeforeDiscountProcessor implements ProductLowestPriceBeforeDiscountProcessorInterface
{
public function __construct(
private ChannelPricingLogEntryRepositoryInterface $channelPricingLogEntryRepository,
private ChannelRepositoryInterface $channelRepository,
) {
}
public function process(ChannelPricingInterface $channelPricing): void
{
if (!$this->isPromotionApplied($channelPricing)) {
$channelPricing->setLowestPriceBeforeDiscount(null);
return;
}
$latestLogEntry = $this->channelPricingLogEntryRepository->findLatestOneByChannelPricing($channelPricing);
if ($latestLogEntry === null) {
$channelPricing->setLowestPriceBeforeDiscount(null);
return;
}
$channelCode = $channelPricing->getChannelCode();
Assert::string($channelCode);
/** @var ChannelInterface $channel */
$channel = $this->channelRepository->findOneByCode($channelCode);
if (!$channel instanceof ChannelInterface) {
return;
}
$lowestPriceInPeriod = $this->findLowestPriceInPeriod(
$latestLogEntry,
$channel->getLowestPriceForDiscountedProductsCheckingPeriod(),
);
$channelPricing->setLowestPriceBeforeDiscount($lowestPriceInPeriod);
}
private function isPromotionApplied(ChannelPricingInterface $channelPricing): bool
{
return
$channelPricing->getOriginalPrice() !== null &&
$channelPricing->getPrice() < $channelPricing->getOriginalPrice()
;
}
private function findLowestPriceInPeriod(
ChannelPricingLogEntryInterface $latestLogEntry,
int $lowestPriceForDiscountedProductsCheckingPeriod,
): ?int {
$loggedAt = new \DateTimeImmutable($latestLogEntry->getLoggedAt()->format('Y-m-d H:i:s'));
/** @var \DateTimeInterface $startDate */
$startDate = $loggedAt->sub(new \DateInterval(sprintf('P%sD', $lowestPriceForDiscountedProductsCheckingPeriod)));
return $this
->channelPricingLogEntryRepository
->findLowestPriceInPeriod($latestLogEntry->getId(), $latestLogEntry->getChannelPricing(), $startDate)
;
}
}

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\Processor;
use Sylius\Component\Core\Model\ChannelPricingInterface;
interface ProductLowestPriceBeforeDiscountProcessorInterface
{
public function process(ChannelPricingInterface $channelPricing): void;
}

View file

@ -29,6 +29,15 @@
<option name="default">0</option>
</options>
</field>
<field
name="lowestPriceForDiscountedProductsCheckingPeriod"
column="lowest_price_for_discounted_products_checking_period"
type="boolean"
>
<options>
<option name="default">30</option>
</options>
</field>
<many-to-one field="defaultLocale" target-entity="Sylius\Component\Locale\Model\LocaleInterface" fetch="EAGER">
<join-column name="default_locale_id" referenced-column-name="id" nullable="false" />

View file

@ -32,6 +32,7 @@
<option name="default">0</option>
</options>
</field>
<field name="lowestPriceBeforeDiscount" column="lowest_price_before_discount" type="integer" nullable="true" />
<field name="channelCode" column="channel_code" type="string" />
<many-to-many field="appliedPromotions" target-entity="Sylius\Component\Promotion\Model\CatalogPromotionInterface">
<order-by>
@ -50,6 +51,9 @@
<many-to-one field="productVariant" target-entity="Sylius\Component\Product\Model\ProductVariantInterface" inversed-by="channelPricings">
<join-column name="product_variant_id" referenced-column-name="id" nullable="false" on-delete="CASCADE" />
<cascade>
<cascade-persist />
</cascade>
</many-to-one>
</mapped-superclass>

View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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.
-->
<doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping http://doctrine-project.org/schemas/orm/doctrine-mapping.xsd"
>
<mapped-superclass name="Sylius\Component\Core\Model\ChannelPricingLogEntry" table="sylius_channel_pricing_log_entry">
<id name="id" column="id" type="integer">
<generator />
</id>
<field name="price" column="price" type="integer" />
<field name="originalPrice" column="original_price" type="integer" nullable="true" />
<field name="loggedAt" column="logged_at" type="datetime" />
<many-to-one field="channelPricing" target-entity="Sylius\Component\Core\Model\ChannelPricingInterface">
<join-column name="channel_pricing_id" nullable="false" on-delete="CASCADE" />
<cascade>
<cascade-persist />
</cascade>
</many-to-one>
</mapped-superclass>
</doctrine-mapping>

View file

@ -23,12 +23,13 @@
<import resource="services/emails.xml" />
<import resource="services/factories.xml" />
<import resource="services/fixtures.xml" />
<import resource="services/fixtures_listeners.xml" />
<import resource="services/fixtures_factories.xml" />
<import resource="services/fixtures_listeners.xml" />
<import resource="services/form.xml" />
<import resource="services/installer.xml" />
<import resource="services/installer_requirements.xml" />
<import resource="services/listeners.xml" />
<import resource="services/logger.xml" />
<import resource="services/message_handlers.xml" />
<import resource="services/order_processing.xml" />
<import resource="services/payment.xml" />

View file

@ -95,5 +95,22 @@
<tag name="kernel.event_listener" event="sylius.taxon.post_delete" method="removeTaxonFromPromotionRules" />
<tag name="kernel.event_listener" event="sylius.taxon.pre_delete" method="handleRemovingRootTaxonAtPositionZero" />
</service>
<service id="Sylius\Bundle\CoreBundle\PriceHistory\EntityObserver\CreateLogEntryOnPriceChangeObserver">
<argument type="service" id="Sylius\Bundle\CoreBundle\PriceHistory\Logger\PriceChangeLoggerInterface" />
<tag name="sylius.entity_observer" />
</service>
<service id="sylius.listener.channel_pricing_change" class="Sylius\Bundle\CoreBundle\PriceHistory\EventListener\OnFlushEntityObserverListener">
<argument type="tagged_iterator" tag="sylius.entity_observer" />
<tag name="doctrine.event_listener" event="onFlush" lazy="true" />
</service>
<service id="Sylius\Bundle\CoreBundle\PriceHistory\EventListener\BeforeDenormalizationChannelValidationListener">
<argument type="service" id="validator" />
<argument type="service" id="sylius.custom_factory.channel" />
<argument>%sylius.channel.validation_groups%</argument>
<tag name="kernel.event_listener" event="kernel.request" method="onKernelRequest" priority="3" />
</service>
</services>
</container>

View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
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.
-->
<container xmlns="http://symfony.com/schema/dic/services" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
<services>
<defaults public="true" />
<service
id="Sylius\Bundle\CoreBundle\PriceHistory\Logger\PriceChangeLoggerInterface"
class="Sylius\Bundle\CoreBundle\PriceHistory\Logger\PriceChangeLogger"
>
<argument type="service" id="sylius.factory.channel_pricing_log_entry" />
<argument type="service" id="sylius.manager.channel_pricing_log_entry" />
<argument type="service" id="Sylius\Calendar\Provider\DateTimeProviderInterface" />
</service>
</services>
</container>

View file

@ -0,0 +1,69 @@
<?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\EntityObserver;
use PhpSpec\ObjectBehavior;
use Sylius\Bundle\CoreBundle\PriceHistory\EntityObserver\EntityObserverInterface;
use Sylius\Bundle\CoreBundle\PriceHistory\Logger\PriceChangeLoggerInterface;
use Sylius\Component\Core\Model\ChannelInterface;
use Sylius\Component\Core\Model\ChannelPricingInterface;
use Sylius\Component\Core\Model\OrderInterface;
final class CreateLogEntryOnPriceChangeObserverSpec extends ObjectBehavior
{
function let(PriceChangeLoggerInterface $priceChangeLogger): void
{
$this->beConstructedWith($priceChangeLogger);
}
function it_implements_on_entity_change_interface(): void
{
$this->shouldImplement(EntityObserverInterface::class);
}
function it_supports_channel_pricing_with_price_specified_only(
ChannelPricingInterface $channelPricingWithPrice,
ChannelPricingInterface $channelPricingWithoutPrice,
OrderInterface $order,
): void {
$channelPricingWithPrice->getPrice()->willReturn(1000);
$this->supports($channelPricingWithPrice)->shouldReturn(true);
$this->supports($channelPricingWithoutPrice)->shouldReturn(false);
$this->supports($order)->shouldReturn(false);
}
function it_supports_price_and_original_price_fields(): void
{
$this->observedFields()->shouldReturn(['price', 'originalPrice']);
}
function it_logs_price_change(
PriceChangeLoggerInterface $priceChangeLogger,
ChannelPricingInterface $channelPricing,
): void {
$priceChangeLogger->log($channelPricing)->shouldBeCalled();
$this->onChange($channelPricing);
}
function it_throws_an_error_if_entity_is_not_channel_pricing(
PriceChangeLoggerInterface $priceChangeLogger,
ChannelInterface $channel,
): void {
$priceChangeLogger->log($channel)->shouldNotBeCalled();
$this->shouldThrow(\InvalidArgumentException::class)->during('onChange', [$channel]);
}
}

View file

@ -0,0 +1,110 @@
<?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\EntityObserver;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
use Sylius\Bundle\CoreBundle\PriceHistory\EntityObserver\EntityObserverInterface;
use Sylius\Bundle\CoreBundle\PriceHistory\Processor\ProductLowestPriceBeforeDiscountProcessorInterface;
use Sylius\Component\Core\Model\ChannelInterface;
use Sylius\Component\Core\Model\ChannelPricingInterface;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Resource\Repository\RepositoryInterface;
final class ProcessLowestPriceOnCheckingPeriodChangeObserverSpec extends ObjectBehavior
{
function let(
ProductLowestPriceBeforeDiscountProcessorInterface $productLowestPriceBeforeDiscountProcessor,
RepositoryInterface $channelPricingRepository,
): void {
$this->beConstructedWith($productLowestPriceBeforeDiscountProcessor, $channelPricingRepository, 2);
}
function it_implements_on_entity_change_interface(): void
{
$this->shouldImplement(EntityObserverInterface::class);
}
function it_supports_channel_pricing_interface_only(
ChannelInterface $channel,
OrderInterface $order,
): void {
$this->supports($channel)->shouldReturn(true);
$this->supports($order)->shouldReturn(false);
}
function it_supports_lowest_price_for_discounted_products_checking_period_field(): void
{
$this->observedFields()->shouldReturn(['lowestPriceForDiscountedProductsCheckingPeriod']);
}
function it_processes_product_lowest_price_for_each_channel_pricing_within_channel(
ProductLowestPriceBeforeDiscountProcessorInterface $productLowestPriceBeforeDiscountProcessor,
RepositoryInterface $channelPricingRepository,
ChannelInterface $channel,
ChannelPricingInterface $firstChannelPricing,
ChannelPricingInterface $secondChannelPricing,
ChannelPricingInterface $thirdChannelPricing,
ChannelPricingInterface $fourthChannelPricing,
ChannelPricingInterface $fifthChannelPricing,
): void {
$channel->getCode()->willReturn('WEB');
$batches = [
[
$firstChannelPricing->getWrappedObject(),
$secondChannelPricing->getWrappedObject(),
],
[
$thirdChannelPricing->getWrappedObject(),
$fourthChannelPricing->getWrappedObject(),
],
[
$fifthChannelPricing->getWrappedObject(),
],
[],
];
$batchSize = 2;
foreach ($batches as $key => $batch) {
$channelPricingRepository
->findBy(['channelCode' => 'WEB'], ['id' => 'ASC'], 2, $key * $batchSize)
->willReturn($batch)
->shouldBeCalled()
;
}
$productLowestPriceBeforeDiscountProcessor->process($firstChannelPricing)->shouldBeCalled();
$productLowestPriceBeforeDiscountProcessor->process($secondChannelPricing)->shouldBeCalled();
$productLowestPriceBeforeDiscountProcessor->process($thirdChannelPricing)->shouldBeCalled();
$productLowestPriceBeforeDiscountProcessor->process($fourthChannelPricing)->shouldBeCalled();
$productLowestPriceBeforeDiscountProcessor->process($fifthChannelPricing)->shouldBeCalled();
$this->onChange($channel);
}
function it_throws_an_exception_if_entity_is_not_channel(
ProductLowestPriceBeforeDiscountProcessorInterface $productLowestPriceBeforeDiscountProcessor,
RepositoryInterface $channelPricingRepository,
OrderInterface $order,
): void {
$channelPricingRepository->findBy(Argument::any())->shouldNotBeCalled();
$productLowestPriceBeforeDiscountProcessor->process(Argument::any())->shouldNotBeCalled();
$productLowestPriceBeforeDiscountProcessor->process(Argument::any())->shouldNotBeCalled();
$this->shouldThrow(\InvalidArgumentException::class)->during('onChange', [$order]);
}
}

View file

@ -0,0 +1,81 @@
<?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\Logger;
use Doctrine\Persistence\ObjectManager;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
use Sylius\Bundle\CoreBundle\PriceHistory\Logger\PriceChangeLoggerInterface;
use Sylius\Calendar\Provider\DateTimeProviderInterface;
use Sylius\Component\Core\Factory\ChannelPricingLogEntryFactoryInterface;
use Sylius\Component\Core\Model\ChannelPricingInterface;
use Sylius\Component\Core\Model\ChannelPricingLogEntryInterface;
final class PriceChangeLoggerSpec extends ObjectBehavior
{
function let(
ChannelPricingLogEntryFactoryInterface $logEntryFactory,
ObjectManager $logEntryManager,
DateTimeProviderInterface $dateTimeProvider,
): void {
$this->beConstructedWith($logEntryFactory, $logEntryManager, $dateTimeProvider);
}
function it_implements_price_change_logger_interface(): void
{
$this->shouldImplement(PriceChangeLoggerInterface::class);
}
function it_throws_exception_when_there_is_no_price(
ChannelPricingLogEntryFactoryInterface $logEntryFactory,
ObjectManager $logEntryManager,
DateTimeProviderInterface $dateTimeProvider,
ChannelPricingInterface $channelPricing,
): void {
$channelPricing->getPrice()->willReturn(null);
$dateTimeProvider->now()->shouldNotBeCalled();
$logEntryFactory->create(Argument::cetera())->shouldNotBeCalled();
$logEntryManager->persist(Argument::any())->shouldNotBeCalled();
$this->shouldThrow(\InvalidArgumentException::class)->during('log', [$channelPricing]);
}
function it_logs_price_change(
ChannelPricingLogEntryFactoryInterface $logEntryFactory,
ObjectManager $logEntryManager,
DateTimeProviderInterface $dateTimeProvider,
ChannelPricingInterface $channelPricing,
ChannelPricingLogEntryInterface $logEntry,
): void {
$date = new \DateTimeImmutable();
$price = 1000;
$originalPrice = 1200;
$channelPricing->getPrice()->willReturn($price);
$channelPricing->getOriginalPrice()->willReturn($originalPrice);
$dateTimeProvider->now()->willReturn($date);
$logEntryFactory
->create($channelPricing, $date, $price, $originalPrice)
->shouldBeCalled()
->willReturn($logEntry)
;
$logEntryManager->persist($logEntry)->shouldBeCalled();
$this->log($channelPricing);
}
}

View file

@ -0,0 +1,146 @@
<?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\Processor;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
use Sylius\Bundle\CoreBundle\PriceHistory\Processor\ProductLowestPriceBeforeDiscountProcessorInterface;
use Sylius\Component\Channel\Repository\ChannelRepositoryInterface;
use Sylius\Component\Core\Model\ChannelInterface;
use Sylius\Component\Core\Model\ChannelPricingInterface;
use Sylius\Component\Core\Model\ChannelPricingLogEntry;
use Sylius\Component\Core\Repository\ChannelPricingLogEntryRepositoryInterface;
final class ProductLowestPriceBeforeDiscountProcessorSpec extends ObjectBehavior
{
function let(
ChannelPricingLogEntryRepositoryInterface $channelPricingLogEntryRepository,
ChannelRepositoryInterface $channelRepository,
): void {
$this->beConstructedWith($channelPricingLogEntryRepository, $channelRepository);
}
function it_implements_product_lowest_price_processor_interface(): void
{
$this->shouldImplement(ProductLowestPriceBeforeDiscountProcessorInterface::class);
}
function it_sets_lowest_price_before_discount_to_null_if_original_price_is_null(
ChannelPricingLogEntryRepositoryInterface $channelPricingLogEntryRepository,
ChannelRepositoryInterface $channelRepository,
ChannelPricingInterface $channelPricing,
): void {
$channelPricing->getOriginalPrice()->willReturn(null);
$channelPricing->getPrice()->willReturn(2100);
$channelRepository->findOneByCode(Argument::any())->shouldNotBeCalled();
$channelPricingLogEntryRepository->findLatestOneByChannelPricing($channelPricing)->shouldNotBeCalled();
$channelPricingLogEntryRepository->findLowestPriceInPeriod(Argument::cetera())->shouldNotBeCalled();
$channelPricing->getChannelCode()->shouldNotBeCalled();
$channelPricing->setLowestPriceBeforeDiscount(null)->shouldBeCalled();
$this->process($channelPricing);
}
function it_sets_lowest_price_before_discount_to_null_if_price_is_equal_original_price(
ChannelPricingLogEntryRepositoryInterface $channelPricingLogEntryRepository,
ChannelRepositoryInterface $channelRepository,
ChannelPricingInterface $channelPricing,
): void {
$channelPricing->getOriginalPrice()->willReturn(2100);
$channelPricing->getPrice()->willReturn(2100);
$channelRepository->findOneByCode(Argument::any())->shouldNotBeCalled();
$channelPricingLogEntryRepository->findLatestOneByChannelPricing($channelPricing)->shouldNotBeCalled();
$channelPricingLogEntryRepository->findLowestPriceInPeriod(Argument::cetera())->shouldNotBeCalled();
$channelPricing->getChannelCode()->shouldNotBeCalled();
$channelPricing->setLowestPriceBeforeDiscount(null)->shouldBeCalled();
$this->process($channelPricing);
}
function it_sets_lowest_price_before_discount_to_null_if_price_is_greater_than_original_price(
ChannelPricingLogEntryRepositoryInterface $channelPricingLogEntryRepository,
ChannelRepositoryInterface $channelRepository,
ChannelPricingInterface $channelPricing,
): void {
$channelPricing->getOriginalPrice()->willReturn(2100);
$channelPricing->getPrice()->willReturn(3700);
$channelRepository->findOneByCode(Argument::any())->shouldNotBeCalled();
$channelPricingLogEntryRepository->findLatestOneByChannelPricing($channelPricing)->shouldNotBeCalled();
$channelPricingLogEntryRepository->findLowestPriceInPeriod(Argument::cetera())->shouldNotBeCalled();
$channelPricing->getChannelCode()->shouldNotBeCalled();
$channelPricing->setLowestPriceBeforeDiscount(null)->shouldBeCalled();
$this->process($channelPricing);
}
function it_sets_lowest_price_before_discount_to_null_if_there_is_no_log_entries(
ChannelPricingLogEntryRepositoryInterface $channelPricingLogEntryRepository,
ChannelRepositoryInterface $channelRepository,
ChannelInterface $channel,
ChannelPricingInterface $channelPricing,
): void {
$channelPricing->getOriginalPrice()->willReturn(3700);
$channelPricing->getPrice()->willReturn(2100);
$channelPricing->getChannelCode()->willReturn('WEB');
$channel->getLowestPriceForDiscountedProductsCheckingPeriod()->shouldNotBeCalled();
$channelRepository->findOneByCode('WEB')->willReturn($channel);
$channelPricingLogEntryRepository->findLatestOneByChannelPricing($channelPricing)->willReturn(null);
$channelPricingLogEntryRepository->findLowestPriceInPeriod(Argument::cetera())->shouldNotBeCalled();
$channelPricing->setLowestPriceBeforeDiscount(null)->shouldBeCalled();
$this->process($channelPricing);
}
function it_sets_lowest_price_before_discount_to_lowest_price_found_in_the_given_period_if_price_is_less_than_original_price(
ChannelPricingLogEntryRepositoryInterface $channelPricingLogEntryRepository,
ChannelRepositoryInterface $channelRepository,
ChannelInterface $channel,
ChannelPricingInterface $channelPricing,
ChannelPricingLogEntry $latestLogEntry,
): void {
$channelPricing->getOriginalPrice()->willReturn(3700);
$channelPricing->getPrice()->willReturn(2100);
$channelPricing->getChannelCode()->willReturn('WEB');
$channelRepository->findOneByCode('WEB')->willReturn($channel);
$channel->getLowestPriceForDiscountedProductsCheckingPeriod()->willReturn(30);
$unformattedDate = new \DateTimeImmutable();
$latestLogEntry->getLoggedAt()->willReturn($unformattedDate);
$loggedAt = new \DateTimeImmutable($unformattedDate->format('Y-m-d H:i:s'));
$startDate = $loggedAt->sub(new \DateInterval(sprintf('P%dD', 30)));
$latestLogEntry->getChannelPricing()->willReturn($channelPricing);
$latestLogEntry->getLoggedAt()->willReturn($loggedAt);
$latestLogEntry->getId()->willReturn(1234);
$channelPricingLogEntryRepository->findLatestOneByChannelPricing($channelPricing)->willReturn($latestLogEntry);
$channelPricingLogEntryRepository
->findLowestPriceInPeriod(1234, $channelPricing, $startDate)
->willReturn(6900)
;
$channelPricing->setLowestPriceBeforeDiscount(6900)->shouldBeCalled();
$this->process($channelPricing);
}
}

View file

@ -0,0 +1,56 @@
<?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\Component\Core\Factory;
use Sylius\Component\Core\Model\ChannelPricingInterface;
use Sylius\Component\Core\Model\ChannelPricingLogEntryInterface;
use Sylius\Component\Resource\Exception\UnsupportedMethodException;
final class ChannelPricingLogEntryFactory implements ChannelPricingLogEntryFactoryInterface
{
public function __construct(private string $className)
{
if (!is_a($className, ChannelPricingLogEntryInterface::class, true)) {
throw new \DomainException(sprintf(
'This factory requires %s or its descend to be used as resource',
ChannelPricingLogEntryInterface::class,
));
}
}
/**
* @throws UnsupportedMethodException
*/
public function createNew(): object
{
throw new UnsupportedMethodException('createNew');
}
public function create(
ChannelPricingInterface $channelPricing,
\DateTimeInterface $loggedAt,
int $price,
?int $originalPrice = null,
): ChannelPricingLogEntryInterface {
/** @var ChannelPricingLogEntryInterface $channelPricingLogEntry */
$channelPricingLogEntry = new $this->className(
$channelPricing,
$loggedAt,
$price,
$originalPrice,
);
return $channelPricingLogEntry;
}
}

View file

@ -0,0 +1,28 @@
<?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\Component\Core\Factory;
use Sylius\Component\Core\Model\ChannelPricingInterface;
use Sylius\Component\Core\Model\ChannelPricingLogEntryInterface;
use Sylius\Component\Resource\Factory\FactoryInterface;
interface ChannelPricingLogEntryFactoryInterface extends FactoryInterface
{
public function create(
ChannelPricingInterface $channelPricing,
\DateTimeInterface $loggedAt,
int $price,
?int $originalPrice = null,
): ChannelPricingLogEntryInterface;
}

View file

@ -82,6 +82,8 @@ class Channel extends BaseChannel implements ChannelInterface
/** @var TaxonInterface|null */
protected $menuTaxon;
protected int $lowestPriceForDiscountedProductsCheckingPeriod = 30;
public function __construct()
{
parent::__construct();
@ -295,4 +297,14 @@ class Channel extends BaseChannel implements ChannelInterface
{
$this->menuTaxon = $menuTaxon;
}
public function getLowestPriceForDiscountedProductsCheckingPeriod(): int
{
return $this->lowestPriceForDiscountedProductsCheckingPeriod;
}
public function setLowestPriceForDiscountedProductsCheckingPeriod(int $periodInDays): void
{
$this->lowestPriceForDiscountedProductsCheckingPeriod = $periodInDays;
}
}

View file

@ -91,4 +91,8 @@ interface ChannelInterface extends
public function removeCountry(CountryInterface $country): void;
public function hasCountry(CountryInterface $country): bool;
public function getLowestPriceForDiscountedProductsCheckingPeriod(): int;
public function setLowestPriceForDiscountedProductsCheckingPeriod(int $periodInDays): void;
}

View file

@ -36,6 +36,9 @@ class ChannelPricing implements ChannelPricingInterface, \Stringable
/** @var int */
protected $minimumPrice = 0;
/** @var int|null */
protected $lowestPriceBeforeDiscount;
/**
* @var ArrayCollection
*
@ -98,6 +101,16 @@ class ChannelPricing implements ChannelPricingInterface, \Stringable
$this->originalPrice = $originalPrice;
}
public function getLowestPriceBeforeDiscount(): ?int
{
return $this->lowestPriceBeforeDiscount;
}
public function setLowestPriceBeforeDiscount(?int $lowestPriceBeforeDiscount): void
{
$this->lowestPriceBeforeDiscount = $lowestPriceBeforeDiscount;
}
public function isPriceReduced(): bool
{
return $this->originalPrice > $this->price;

View file

@ -37,6 +37,10 @@ interface ChannelPricingInterface extends ResourceInterface
public function setOriginalPrice(?int $originalPrice): void;
public function getLowestPriceBeforeDiscount(): ?int;
public function setLowestPriceBeforeDiscount(?int $lowestPriceBeforeDiscount): void;
public function getMinimumPrice(): int;
public function setMinimumPrice(int $minimumPrice): void;

View file

@ -0,0 +1,56 @@
<?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\Component\Core\Model;
class ChannelPricingLogEntry implements ChannelPricingLogEntryInterface
{
/** @var mixed|null */
protected $id;
public function __construct(
protected ChannelPricingInterface $channelPricing,
protected \DateTimeInterface $loggedAt,
protected int $price,
protected ?int $originalPrice,
) {
}
/**
* @psalm-suppress MissingReturnType
*/
public function getId()
{
return $this->id;
}
public function getChannelPricing(): ChannelPricingInterface
{
return $this->channelPricing;
}
public function getPrice(): int
{
return $this->price;
}
public function getOriginalPrice(): ?int
{
return $this->originalPrice;
}
public function getLoggedAt(): \DateTimeInterface
{
return $this->loggedAt;
}
}

View file

@ -0,0 +1,27 @@
<?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\Component\Core\Model;
use Sylius\Component\Resource\Model\ResourceInterface;
interface ChannelPricingLogEntryInterface extends ResourceInterface
{
public function getChannelPricing(): ChannelPricingInterface;
public function getPrice(): int;
public function getOriginalPrice(): ?int;
public function getLoggedAt(): \DateTimeInterface;
}

View file

@ -0,0 +1,37 @@
<?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\Component\Core\Repository;
use Doctrine\ORM\QueryBuilder;
use Sylius\Component\Core\Model\ChannelPricingInterface;
use Sylius\Component\Core\Model\ChannelPricingLogEntryInterface;
use Sylius\Component\Resource\Repository\RepositoryInterface;
interface ChannelPricingLogEntryRepositoryInterface extends RepositoryInterface
{
public function createByChannelPricingIdListQueryBuilder(mixed $channelPricingId): QueryBuilder;
public function findLatestOneByChannelPricing(ChannelPricingInterface $channelPricing): ?ChannelPricingLogEntryInterface;
public function findLowestPriceInPeriod(
int $latestChannelPricingLogEntryId,
ChannelPricingInterface $channelPricing,
\DateTimeInterface $startDate,
): ?int;
/**
* @return array|ChannelPricingLogEntryInterface[]
*/
public function findOlderThan(\DateTimeInterface $date, ?int $limit = null): array;
}

View file

@ -0,0 +1,57 @@
<?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\Component\Core\Factory;
use PhpSpec\ObjectBehavior;
use Sylius\Component\Core\Model\ChannelPricingInterface;
use Sylius\Component\Core\Model\ChannelPricingLogEntry;
use Sylius\Component\Resource\Exception\UnsupportedMethodException;
use Sylius\Component\Resource\Model\ResourceInterface;
final class ChannelPricingLogEntryFactorySpec extends ObjectBehavior
{
function let(): void
{
$this->beConstructedWith(ChannelPricingLogEntry::class);
}
function it_throws_an_exception_when_invalid_class_name_is_passed(): void
{
$this->beConstructedWith(ResourceInterface::class);
$this->shouldThrow(\DomainException::class)->duringInstantiation();
}
function it_throws_an_exception_when_create_new_is_called(): void
{
$this->shouldThrow(UnsupportedMethodException::class)->during('createNew');
}
function it_creates_a_channel_pricing_log_entry(ChannelPricingInterface $channelPricing): void
{
$date = new \DateTimeImmutable();
$price = 1000;
$originalPrice = 2000;
$this
->create($channelPricing, $date, $price, $originalPrice)
->shouldBeLike(new ChannelPricingLogEntry(
$channelPricing->getWrappedObject(),
$date,
$price,
$originalPrice,
))
;
}
}

View file

@ -0,0 +1,57 @@
<?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\Component\Core\Model;
use PhpSpec\ObjectBehavior;
use Sylius\Component\Core\Model\ChannelPricingInterface;
use Sylius\Component\Core\Model\ChannelPricingLogEntryInterface;
final class ChannelPricingLogEntrySpec extends ObjectBehavior
{
function let(ChannelPricingInterface $channelPricing): void
{
$this->beConstructedWith($channelPricing, new \DateTime(), 1000, 2000);
}
function it_implements_channel_pricing_log_entry_interface(): void
{
$this->shouldImplement(ChannelPricingLogEntryInterface::class);
}
function it_initialize_with_no_original_price(ChannelPricingInterface $channelPricing): void
{
$this->beConstructedWith($channelPricing, new \DateTime(), 1000, null);
$this->getOriginalPrice()->shouldReturn(null);
}
function it_gets_a_channel_pricing(): void
{
$this->getChannelPricing()->shouldReturnAnInstanceOf(ChannelPricingInterface::class);
}
function it_gets_a_price(): void
{
$this->getPrice()->shouldReturn(1000);
}
function it_gets_an_original_price(): void
{
$this->getOriginalPrice()->shouldReturn(2000);
}
function it_gets_a_logged_at(): void
{
$this->getLoggedAt()->shouldReturnAnInstanceOf(\DateTimeInterface::class);
}
}

View file

@ -55,6 +55,12 @@ final class ChannelPricingSpec extends ObjectBehavior
$this->getOriginalPrice()->shouldReturn(2000);
}
function its_lowest_price_before_discount_is_mutable(): void
{
$this->setLowestPriceBeforeDiscount(2000);
$this->getLowestPriceBeforeDiscount()->shouldReturn(2000);
}
function its_price_can_be_reduced(): void
{
$this->setPrice(1000);

View file

@ -0,0 +1,104 @@
<?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\Tests\Api\Admin;
use Sylius\Component\Core\Model\ChannelPricingInterface;
use Sylius\Component\Core\Model\ChannelPricingLogEntryInterface;
use Sylius\Component\Resource\Repository\RepositoryInterface;
use Sylius\Tests\Api\JsonApiTestCase;
use Symfony\Component\HttpFoundation\Response;
final class ChannelPricingLogEntryTest extends JsonApiTestCase
{
/** @test */
public function it_denies_access_to_a_channel_pricing_log_entries_for_not_authenticated_user(): void
{
$this->loadFixturesFromFiles(['product/product_variant_with_lowest_price.yaml']);
$this->client->request(
method: 'GET',
uri: '/api/v2/admin/channel-pricing-log-entries',
server: $this->getUnloggedHeader(),
);
$response = $this->client->getResponse();
$this->assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode());
}
/** @test */
public function it_gets_single_channel_pricing_log_entry(): void
{
$fixtures = $this->loadFixturesFromFiles(['authentication/api_administrator.yaml', 'product/product_variant_with_lowest_price.yaml']);
/** @var ChannelPricingInterface $channelPricing */
$channelPricing = $fixtures['channel_pricing_product_variant_mug_blue_home'];
/** @var RepositoryInterface $channelPricingLogEntryRepository */
$channelPricingLogEntryRepository = $this->getContainer()->get('sylius.repository.channel_pricing_log_entry');
/** @var ChannelPricingLogEntryInterface $firstLogEntry */
$channelPricingLogEntry = $channelPricingLogEntryRepository->findOneBy(['channelPricing' => $channelPricing]);
$this->client->request(
method: 'GET',
uri: sprintf('/api/v2/admin/channel-pricing-log-entries/%d', $channelPricingLogEntry->getId()),
server: $this->getLoggedHeader(),
);
$this->assertResponse(
$this->client->getResponse(),
'admin/get_channel_pricing_log_entry_response',
Response::HTTP_OK
);
}
/** @test */
public function it_gets_all_channel_pricing_log_entries(): void
{
$this->loadFixturesFromFiles(['authentication/api_administrator.yaml', 'product/product_variant_with_lowest_price.yaml']);
$this->client->request(
method: 'GET',
uri: '/api/v2/admin/channel-pricing-log-entries',
server: $this->getLoggedHeader(),
);
$this->assertResponse(
$this->client->getResponse(),
'admin/get_channel_pricing_log_entries_response',
Response::HTTP_OK
);
}
/** @test */
public function it_gets_filtered_channel_pricing_log_entries(): void
{
$fixtures = $this->loadFixturesFromFiles(['authentication/api_administrator.yaml', 'product/product_variant_with_lowest_price.yaml']);
$uri = '/api/v2/admin/channel-pricing-log-entries';
$uri .= '?channelPricing.channelCode=' . $fixtures['channel_home']->getCode();
$uri .= '&channelPricing.productVariant.code=' . $fixtures['product_variant_mug_blue']->getCode();
$this->client->request(
method: 'GET',
uri: $uri,
server: $this->getLoggedHeader(),
);
$this->assertResponse(
$this->client->getResponse(),
'admin/get_filtered_channel_pricing_log_entries_response',
Response::HTTP_OK
);
}
}

View file

@ -0,0 +1,98 @@
Sylius\Component\Core\Model\Channel:
channel_home:
code: 'HOME'
name: 'Home Channel'
hostname: 'home.localhost'
description: 'Lorem ipsum'
baseCurrency: '@currency_usd'
defaultLocale: '@locale_en'
locales: ['@locale_en', '@locale_pl']
color: 'black'
enabled: true
taxCalculationStrategy: 'order_items_based'
channel_fashion:
code: 'FASHION'
name: 'Fashion Channel'
hostname: 'fashion.localhost'
description: 'Lorem ipsum'
baseCurrency: '@currency_usd'
defaultLocale: '@locale_en'
locales: ['@locale_en', '@locale_pl']
color: 'black'
enabled: true
taxCalculationStrategy: 'order_items_based'
Sylius\Component\Currency\Model\Currency:
currency_usd:
code: 'USD'
Sylius\Component\Locale\Model\Locale:
locale_en:
code: 'en_US'
locale_pl:
code: 'pl_PL'
locale_de:
code: 'de_DE'
Sylius\Component\Core\Model\Product:
product_mug:
code: 'MUG'
channels: ['@channel_home', '@channel_fashion']
currentLocale: 'en_US'
translations:
en_US: '@product_translation_mug'
Sylius\Component\Core\Model\ProductTranslation:
product_translation_mug:
slug: 'mug'
locale: 'en_US'
name: 'Mug'
description: '<paragraph(2)>'
translatable: '@product_mug'
Sylius\Component\Core\Model\ProductVariant:
product_variant_mug_blue:
code: 'MUG_BLUE'
product: '@product_mug'
currentLocale: 'en_US'
translations:
en_US: '@product_variant_translation_mug_blue'
channelPricings:
HOME: '@channel_pricing_product_variant_mug_blue_home'
FASHION: '@channel_pricing_product_variant_mug_blue_fashion'
product_variant_mug_red:
code: 'MUG_RED'
product: '@product_mug'
currentLocale: 'en_US'
translations:
en_US: '@product_variant_translation_mug_red'
channelPricings:
HOME: '@channel_pricing_product_variant_mug_red_home'
FASHION: '@channel_pricing_product_variant_mug_red_fashion'
Sylius\Component\Product\Model\ProductVariantTranslation:
product_variant_translation_mug_blue:
locale: 'en_US'
name: 'Blue Mug'
translatable: '@product_variant_mug_blue'
product_variant_translation_mug_red:
locale: 'en_US'
name: 'Red Mug'
translatable: '@product_variant_mug_red'
Sylius\Component\Core\Model\ChannelPricing:
channel_pricing_product_variant_mug_blue_home:
channelCode: 'HOME'
price: 1000
originalPrice: 2000
lowestPriceBeforeDiscount: 2100
channel_pricing_product_variant_mug_blue_fashion:
channelCode: 'FASHION'
price: 2000
channel_pricing_product_variant_mug_red_home:
channelCode: 'HOME'
price: 3000
channel_pricing_product_variant_mug_red_fashion:
channelCode: 'FASHION'
price: 3000

View file

@ -14,9 +14,12 @@ declare(strict_types=1);
namespace Sylius\Tests\Api;
use ApiTestCase\JsonApiTestCase as BaseJsonApiTestCase;
use Sylius\Tests\Api\Utils\AdminUserLoginTrait;
abstract class JsonApiTestCase extends BaseJsonApiTestCase
{
use AdminUserLoginTrait;
public const CONTENT_TYPE_HEADER = ['CONTENT_TYPE' => 'application/ld+json', 'HTTP_ACCEPT' => 'application/ld+json'];
public const PATCH_CONTENT_TYPE_HEADER = ['CONTENT_TYPE' => 'application/merge-patch+json', 'HTTP_ACCEPT' => 'application/ld+json'];
@ -37,4 +40,14 @@ abstract class JsonApiTestCase extends BaseJsonApiTestCase
return parent::get($id);
}
protected function getUnloggedHeader(): array
{
return self::CONTENT_TYPE_HEADER;
}
protected function getLoggedHeader(): array
{
return array_merge($this->logInAdminUser('api@example.com'), self::CONTENT_TYPE_HEADER);
}
}

View file

@ -0,0 +1,71 @@
{
"@context": "\/api\/v2\/contexts\/ChannelPricingLogEntry",
"@id": "\/api\/v2\/admin\/channel-pricing-log-entries",
"@type": "hydra:Collection",
"hydra:member": [
{
"@id": "\/api\/v2\/admin\/channel-pricing-log-entries\/@integer@",
"@type": "ChannelPricingLogEntry",
"channelPricing": "\/api\/v2\/admin\/channel-pricings\/@integer@",
"price": 3000,
"originalPrice": null,
"loggedAt": "@date@"
},
{
"@id": "\/api\/v2\/admin\/channel-pricing-log-entries\/@integer@",
"@type": "ChannelPricingLogEntry",
"channelPricing": "\/api\/v2\/admin\/channel-pricings\/@integer@",
"price": 1000,
"originalPrice": 2000,
"loggedAt": "@date@"
},
{
"@id": "\/api\/v2\/admin\/channel-pricing-log-entries\/@integer@",
"@type": "ChannelPricingLogEntry",
"channelPricing": "\/api\/v2\/admin\/channel-pricings\/@integer@",
"price": 3000,
"originalPrice": null,
"loggedAt": "@date@"
},
{
"@id": "\/api\/v2\/admin\/channel-pricing-log-entries\/@integer@",
"@type": "ChannelPricingLogEntry",
"channelPricing": "\/api\/v2\/admin\/channel-pricings\/@integer@",
"price": 2000,
"originalPrice": null,
"loggedAt": "@date@"
}
],
"hydra:totalItems": 4,
"hydra:search": {
"@type": "hydra:IriTemplate",
"hydra:template": "\/api\/v2\/admin\/channel-pricing-log-entries{?channelPricing.channelCode,channelPricing.channelCode[],channelPricing.productVariant.code,channelPricing.productVariant.code[]}",
"hydra:variableRepresentation": "BasicRepresentation",
"hydra:mapping": [
{
"@type": "IriTemplateMapping",
"variable": "channelPricing.channelCode",
"property": "channelPricing.channelCode",
"required": false
},
{
"@type": "IriTemplateMapping",
"variable": "channelPricing.channelCode[]",
"property": "channelPricing.channelCode",
"required": false
},
{
"@type": "IriTemplateMapping",
"variable": "channelPricing.productVariant.code",
"property": "channelPricing.productVariant.code",
"required": false
},
{
"@type": "IriTemplateMapping",
"variable": "channelPricing.productVariant.code[]",
"property": "channelPricing.productVariant.code",
"required": false
}
]
}
}

View file

@ -0,0 +1,9 @@
{
"@context": "\/api\/v2\/contexts\/ChannelPricingLogEntry",
"@id": "\/api\/v2\/admin\/channel-pricing-log-entries/@integer@",
"@type": "ChannelPricingLogEntry",
"channelPricing": "\/api\/v2\/admin\/channel-pricings\/@integer@",
"price": 1000,
"originalPrice": 2000,
"loggedAt": "@date@"
}

View file

@ -0,0 +1,51 @@
{
"@context": "\/api\/v2\/contexts\/ChannelPricingLogEntry",
"@id": "\/api\/v2\/admin\/channel-pricing-log-entries",
"@type": "hydra:Collection",
"hydra:member": [
{
"@id": "\/api\/v2\/admin\/channel-pricing-log-entries\/@integer@",
"@type": "ChannelPricingLogEntry",
"channelPricing": "\/api\/v2\/admin\/channel-pricings\/@integer@",
"price": 1000,
"originalPrice": 2000,
"loggedAt": "@date@"
}
],
"hydra:totalItems": 1,
"hydra:view": {
"@id": @string@,
"@type": "hydra:PartialCollectionView"
},
"hydra:search": {
"@type": "hydra:IriTemplate",
"hydra:template": "\/api\/v2\/admin\/channel-pricing-log-entries{?channelPricing.channelCode,channelPricing.channelCode[],channelPricing.productVariant.code,channelPricing.productVariant.code[]}",
"hydra:variableRepresentation": "BasicRepresentation",
"hydra:mapping": [
{
"@type": "IriTemplateMapping",
"variable": "channelPricing.channelCode",
"property": "channelPricing.channelCode",
"required": false
},
{
"@type": "IriTemplateMapping",
"variable": "channelPricing.channelCode[]",
"property": "channelPricing.channelCode",
"required": false
},
{
"@type": "IriTemplateMapping",
"variable": "channelPricing.productVariant.code",
"property": "channelPricing.productVariant.code",
"required": false
},
{
"@type": "IriTemplateMapping",
"variable": "channelPricing.productVariant.code[]",
"property": "channelPricing.productVariant.code",
"required": false
}
]
}
}