Skip to content

Commit

Permalink
Pre-qualification and runtime starting to take shape
Browse files Browse the repository at this point in the history
  • Loading branch information
loevgaard committed Dec 10, 2024
1 parent a0b8d7d commit 69b13f1
Show file tree
Hide file tree
Showing 43 changed files with 786 additions and 483 deletions.
5 changes: 5 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
"doctrine/dbal": "^3.0",
"doctrine/orm": "^2.10",
"knplabs/knp-menu": "^3.0",
"psr/clock": "^1.0",
"psr/clock-implementation": "*",
"setono/composite-compiler-pass": "^1.2",
"sylius/channel": "^1.0",
"sylius/channel-bundle": "^1.0",
"sylius/core": "^1.0",
Expand All @@ -30,6 +33,7 @@
"symfony/config": "^5.4 || ^6.4 || ^7.0",
"symfony/dependency-injection": "^5.4 || ^6.4 || ^7.0",
"symfony/form": "^5.4 || ^6.4 || ^7.0",
"symfony/messenger": "^5.4 || ^6.4 || ^7.0",
"symfony/options-resolver": "^5.4 || ^6.4 || ^7.0",
"symfony/validator": "^5.4 || ^6.4 || ^7.0",
"webmozart/assert": "^1.11"
Expand All @@ -44,6 +48,7 @@
"lexik/jwt-authentication-bundle": "^2.17",
"matthiasnoback/symfony-config-test": "^4.3 || ^5.1",
"matthiasnoback/symfony-dependency-injection-test": "^4.3 || ^5.1",
"phpspec/prophecy-phpunit": "^2.3",
"phpunit/phpunit": "^9.6.20",
"psalm/plugin-phpunit": "^0.18.4",
"psalm/plugin-symfony": "^5.2",
Expand Down
5 changes: 5 additions & 0 deletions psalm.xml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@
<function name="print_r"/>
</forbiddenFunctions>
<issueHandlers>
<DeprecatedInterface>
<errorLevel type="suppress">
<referencedClass name="Sylius\Component\Core\Calculator\ProductVariantPriceCalculatorInterface"/>
</errorLevel>
</DeprecatedInterface>
<MixedArgument>
<errorLevel type="suppress">
<directory name="src/Fixture"/>
Expand Down
82 changes: 82 additions & 0 deletions src/Applicator/RuntimePromotionsApplicator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusCatalogPromotionPlugin\Applicator;

use Setono\SyliusCatalogPromotionPlugin\Checker\Runtime\RuntimeCheckerInterface;
use Setono\SyliusCatalogPromotionPlugin\Model\PromotionInterface;
use Setono\SyliusCatalogPromotionPlugin\Repository\PromotionRepositoryInterface;

final class RuntimePromotionsApplicator implements RuntimePromotionsApplicatorInterface
{
/** @var array<string, float> */
private array $multiplierCache = [];

/** @var array<string, PromotionInterface|null> */
private array $catalogPromotionCache = [];

public function __construct(
private readonly PromotionRepositoryInterface $promotionRepository,
private readonly RuntimeCheckerInterface $runtimeChecker,
) {
}

public function apply(array $catalogPromotions, int $price, bool $manuallyDiscounted): int
{
if ([] === $catalogPromotions) {
return $price;
}

return (int) floor($this->getMultiplier($catalogPromotions, $manuallyDiscounted) * $price);
}

/**
* @param list<string> $catalogPromotions
*/
private function getMultiplier(array $catalogPromotions, bool $manuallyDiscounted): float
{
$cacheKey = sprintf('%s%d', implode($catalogPromotions), (int) $manuallyDiscounted);

if (!isset($this->multiplierCache[$cacheKey])) {
$multiplier = 1.0;

foreach ($this->getEligiblePromotions($catalogPromotions, $manuallyDiscounted) as $promotion) {
$multiplier *= $promotion->getMultiplier();
}

$this->multiplierCache[$cacheKey] = $multiplier;
}

return $this->multiplierCache[$cacheKey];
}

/**
* @param list<string> $catalogPromotions
*
* @return \Generator<array-key, PromotionInterface>
*/
private function getEligiblePromotions(array $catalogPromotions, bool $manuallyDiscounted): \Generator
{
// todo check if any of the promotions are exclusive
foreach ($catalogPromotions as $catalogPromotion) {
if (!array_key_exists($catalogPromotion, $this->catalogPromotionCache)) {
$this->catalogPromotionCache[$catalogPromotion] = $this->promotionRepository->findOneByCode($catalogPromotion);
}

if (null === $this->catalogPromotionCache[$catalogPromotion]) {
continue;
}

if ($manuallyDiscounted && $this->catalogPromotionCache[$catalogPromotion]->isManuallyDiscountedProductsExcluded()) {
continue;
}

if (!$this->runtimeChecker->isEligible($this->catalogPromotionCache[$catalogPromotion])) {
continue;
}

yield $this->catalogPromotionCache[$catalogPromotion];
}
}
}
17 changes: 17 additions & 0 deletions src/Applicator/RuntimePromotionsApplicatorInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusCatalogPromotionPlugin\Applicator;

interface RuntimePromotionsApplicatorInterface
{
/**
* @param list<string> $catalogPromotions The codes of the catalog promotions to apply
* @param int $price The price before the catalog promotions have been applied
* @param bool $manuallyDiscounted Whether the price has been manually discounted
*
* @return int The price after the catalog promotions have been applied
*/
public function apply(array $catalogPromotions, int $price, bool $manuallyDiscounted): int;
}
101 changes: 101 additions & 0 deletions src/Calculator/ProductVariantPricesCalculator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusCatalogPromotionPlugin\Calculator;

use Setono\SyliusCatalogPromotionPlugin\Applicator\RuntimePromotionsApplicatorInterface;
use Setono\SyliusCatalogPromotionPlugin\Model\ProductInterface;
use Sylius\Component\Core\Calculator\ProductVariantPricesCalculatorInterface;
use Sylius\Component\Core\Exception\MissingChannelConfigurationException;
use Sylius\Component\Core\Model\ChannelInterface;
use Sylius\Component\Core\Model\ChannelPricingInterface;
use Sylius\Component\Core\Model\ProductVariantInterface;
use Webmozart\Assert\Assert;

final class ProductVariantPricesCalculator implements ProductVariantPricesCalculatorInterface
{
/** @var array<string, array{price: int, originalPrice: int, minimumPrice: int}> */
private array $persistedPricesCache = [];

/** @var array<string, int> */
private array $computedPriceCache = [];

public function __construct(private readonly RuntimePromotionsApplicatorInterface $runtimePromotionsApplicator)
{
}

public function calculate(ProductVariantInterface $productVariant, array $context): int
{
$hash = spl_object_hash($productVariant);
if (!isset($this->computedPriceCache[$hash])) {
$this->computedPriceCache[$hash] = $this->getPrice($productVariant, $context);
}

return $this->computedPriceCache[$hash];
}

public function calculateOriginal(ProductVariantInterface $productVariant, array $context): int
{
return $this->getPersistedPrices($productVariant, $context)['originalPrice'];
}

private function getPrice(ProductVariantInterface $productVariant, array $context): int
{
$prices = $this->getPersistedPrices($productVariant, $context);

$product = $productVariant->getProduct();
if (!$product instanceof ProductInterface || !$product->hasPreQualifiedCatalogPromotions()) {
return $prices['price'];
}

return max($prices['minimumPrice'], $this->runtimePromotionsApplicator->apply(
$product->getPreQualifiedCatalogPromotions(),
$prices['price'],
$prices['price'] < $prices['originalPrice'],
));
}

/**
* @psalm-assert ChannelInterface $context['channel']
*
* @return array{price: int, originalPrice: int, minimumPrice: int}
*/
private function getPersistedPrices(ProductVariantInterface $productVariant, array $context): array
{
$hash = spl_object_hash($productVariant);

if (!isset($this->persistedPricesCache[$hash])) {
Assert::keyExists($context, 'channel');
Assert::isInstanceOf($context['channel'], ChannelInterface::class);

$channelPricing = $productVariant->getChannelPricingForChannel($context['channel']);

if (null === $channelPricing) {
throw MissingChannelConfigurationException::createForProductVariantChannelPricing($productVariant, $context['channel']);

Check failure on line 75 in src/Calculator/ProductVariantPricesCalculator.php

View workflow job for this annotation

GitHub Actions / Static Code Analysis (PHP8.1 | Deps: lowest | SF~5.4.0)

UndefinedMethod

src/Calculator/ProductVariantPricesCalculator.php:75:23: UndefinedMethod: Method Sylius\Component\Core\Exception\MissingChannelConfigurationException::createforproductvariantchannelpricing does not exist (see https://psalm.dev/022)
}

$price = $channelPricing->getPrice();
if (null === $price) {
throw MissingChannelConfigurationException::createForProductVariantChannelPricing($productVariant, $context['channel']);

Check failure on line 80 in src/Calculator/ProductVariantPricesCalculator.php

View workflow job for this annotation

GitHub Actions / Static Code Analysis (PHP8.1 | Deps: lowest | SF~5.4.0)

UndefinedMethod

src/Calculator/ProductVariantPricesCalculator.php:80:23: UndefinedMethod: Method Sylius\Component\Core\Exception\MissingChannelConfigurationException::createforproductvariantchannelpricing does not exist (see https://psalm.dev/022)
}

$this->persistedPricesCache[$hash] = [
'price' => $price,
'originalPrice' => $channelPricing->getOriginalPrice() ?? $price,
'minimumPrice' => self::getMinimumPrice($channelPricing),
];
}

return $this->persistedPricesCache[$hash];
}

private static function getMinimumPrice(ChannelPricingInterface $channelPricing): int

Check failure on line 93 in src/Calculator/ProductVariantPricesCalculator.php

View workflow job for this annotation

GitHub Actions / Static Code Analysis (PHP8.1 | Deps: lowest | SF~5.4.0)

MixedInferredReturnType

src/Calculator/ProductVariantPricesCalculator.php:93:87: MixedInferredReturnType: Could not verify return type 'int' for Setono\SyliusCatalogPromotionPlugin\Calculator\ProductVariantPricesCalculator::getMinimumPrice (see https://psalm.dev/047)
{
if (method_exists($channelPricing, 'getMinimumPrice')) {
return $channelPricing->getMinimumPrice();

Check failure on line 96 in src/Calculator/ProductVariantPricesCalculator.php

View workflow job for this annotation

GitHub Actions / Static Code Analysis (PHP8.1 | Deps: lowest | SF~5.4.0)

MixedReturnStatement

src/Calculator/ProductVariantPricesCalculator.php:96:20: MixedReturnStatement: Could not infer a return type (see https://psalm.dev/138)
}

return 0;
}
}
16 changes: 16 additions & 0 deletions src/Checker/PreQualification/PreQualificationCheckerInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusCatalogPromotionPlugin\Checker\PreQualification;

use Setono\SyliusCatalogPromotionPlugin\Model\ProductInterface;
use Setono\SyliusCatalogPromotionPlugin\Model\PromotionInterface;

interface PreQualificationCheckerInterface
{
/**
* Checks the pre-qualification criteria for the product and returns true if the product is pre-qualified for the promotion
*/
public function isPreQualified(ProductInterface $product, PromotionInterface $catalogPromotion): bool;
}
12 changes: 12 additions & 0 deletions src/Checker/PreQualification/Rule/RuleCheckerInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);

namespace Setono\SyliusCatalogPromotionPlugin\Checker\PreQualification\Rule;


use Sylius\Component\Core\Model\ProductInterface;

interface RuleCheckerInterface
{
public function isEligible(ProductInterface $product, array $configuration): bool;
}
20 changes: 20 additions & 0 deletions src/Checker/Runtime/ChannelContextRuntimeChecker.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusCatalogPromotionPlugin\Checker\Runtime;

use Setono\SyliusCatalogPromotionPlugin\Model\PromotionInterface;
use Sylius\Component\Channel\Context\ChannelContextInterface;

final class ChannelContextRuntimeChecker implements RuntimeCheckerInterface
{
public function __construct(private readonly ChannelContextInterface $channelContext)
{
}

public function isEligible(PromotionInterface $catalogPromotion): bool
{
return $catalogPromotion->getChannels()->contains($this->channelContext->getChannel());
}
}
25 changes: 25 additions & 0 deletions src/Checker/Runtime/CompositeRuntimeChecker.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusCatalogPromotionPlugin\Checker\Runtime;

use Setono\CompositeCompilerPass\CompositeService;
use Setono\SyliusCatalogPromotionPlugin\Model\PromotionInterface;

/**
* @extends CompositeService<RuntimeCheckerInterface>
*/
final class CompositeRuntimeChecker extends CompositeService implements RuntimeCheckerInterface
{
public function isEligible(PromotionInterface $catalogPromotion): bool
{
foreach ($this->services as $service) {
if (!$service->isEligible($catalogPromotion)) {
return false;
}
}

return true;
}
}
32 changes: 32 additions & 0 deletions src/Checker/Runtime/DateRuntimeChecker.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusCatalogPromotionPlugin\Checker\Runtime;

use Psr\Clock\ClockInterface;
use Setono\SyliusCatalogPromotionPlugin\Model\PromotionInterface;

final class DateRuntimeChecker implements RuntimeCheckerInterface
{
public function __construct(private readonly ClockInterface $clock)
{
}

public function isEligible(PromotionInterface $catalogPromotion): bool
{
$now = $this->clock->now();

$startsAt = $catalogPromotion->getStartsAt();
if (null !== $startsAt && $startsAt > $now) {
return false;
}

$endsAt = $catalogPromotion->getEndsAt();
if (null !== $endsAt && $endsAt < $now) {
return false;
}

return true;
}
}
15 changes: 15 additions & 0 deletions src/Checker/Runtime/EnabledRuntimeChecker.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusCatalogPromotionPlugin\Checker\Runtime;

use Setono\SyliusCatalogPromotionPlugin\Model\PromotionInterface;

final class EnabledRuntimeChecker implements RuntimeCheckerInterface
{
public function isEligible(PromotionInterface $catalogPromotion): bool
{
return $catalogPromotion->isEnabled();
}
}
17 changes: 17 additions & 0 deletions src/Checker/Runtime/RuntimeCheckerInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusCatalogPromotionPlugin\Checker\Runtime;

use Setono\SyliusCatalogPromotionPlugin\Model\PromotionInterface;

interface RuntimeCheckerInterface
{
/**
* Returns true if runtime checks are eligible for the given catalog promotion.
* This is meant to be run each time a price is calculated on a product.
* This implies that the runtime checks should be fast and not perform any heavy operations.
*/
public function isEligible(PromotionInterface $catalogPromotion): bool;
}
Loading

0 comments on commit 69b13f1

Please sign in to comment.