444 lines
14 KiB
PHP
444 lines
14 KiB
PHP
<?php
|
|
namespace Sodino\Services;
|
|
|
|
use Sodino\Repositories\RuleRepository;
|
|
use Sodino\Services\TrackingService;
|
|
use Sodino\Core\Settings;
|
|
use Sodino\Core\Cache;
|
|
|
|
class PricingService {
|
|
private $ruleRepository;
|
|
private $trackingService;
|
|
private $settings;
|
|
private $cache;
|
|
private $trackedApplications = [];
|
|
|
|
public function __construct(RuleRepository $ruleRepository, TrackingService $trackingService) {
|
|
$this->ruleRepository = $ruleRepository;
|
|
$this->trackingService = $trackingService;
|
|
$this->settings = Settings::getInstance();
|
|
$this->cache = Cache::getInstance();
|
|
}
|
|
|
|
public function applyDynamicPricing($price, $product) {
|
|
if (!$this->settings->isPricingEnabled()) {
|
|
return $price;
|
|
}
|
|
|
|
if (!$price || !is_numeric($price)) {
|
|
return $price;
|
|
}
|
|
|
|
$price = $this->normalizePrice($price);
|
|
|
|
if (!$this->settings->get('cart_pricing_enabled') && is_cart()) {
|
|
return $price;
|
|
}
|
|
|
|
$originalPrice = $price;
|
|
$rules = $this->getApplicableRules($product);
|
|
|
|
if (empty($rules)) {
|
|
return $price;
|
|
}
|
|
|
|
if (!$this->settings->get('allow_multiple_rules')) {
|
|
$chosenRule = $this->chooseRule($rules, $price);
|
|
$rules = $chosenRule ? [$chosenRule] : [];
|
|
}
|
|
|
|
foreach ($rules as $rule) {
|
|
$oldPrice = $price;
|
|
$price = $this->applyRuleActions($rule, $price);
|
|
|
|
$this->trackDiscountOnce($product, $oldPrice, $price, $rule->id);
|
|
}
|
|
|
|
$price = $this->enforceLimits($originalPrice, $price);
|
|
|
|
return max(0, $price);
|
|
}
|
|
|
|
private function getApplicableRules($product) {
|
|
$rules = $this->ruleRepository->getEnabled();
|
|
$applicable = [];
|
|
|
|
foreach ($rules as $rule) {
|
|
if ($this->ruleMatches($rule, $product)) {
|
|
$applicable[] = $rule;
|
|
}
|
|
}
|
|
|
|
return $applicable;
|
|
}
|
|
|
|
private function chooseRule(array $rules, $price) {
|
|
$strategy = $this->settings->get('strategy', 'priority');
|
|
|
|
if ($strategy === 'highest_discount') {
|
|
usort($rules, function ($a, $b) use ($price) {
|
|
return $this->estimateRuleDiscount($b, $price) <=> $this->estimateRuleDiscount($a, $price);
|
|
});
|
|
return $rules[0] ?? null;
|
|
}
|
|
|
|
if ($strategy === 'first_valid') {
|
|
return $rules[0] ?? null;
|
|
}
|
|
|
|
usort($rules, function ($a, $b) {
|
|
return $b->priority <=> $a->priority;
|
|
});
|
|
return $rules[0] ?? null;
|
|
}
|
|
|
|
private function normalizePrice($price) {
|
|
if ($price === '' || $price === null) {
|
|
return 0.0;
|
|
}
|
|
return floatval($price);
|
|
}
|
|
|
|
private function ruleMatches($rule, $product = null) {
|
|
if (!$rule->isActive()) {
|
|
return false;
|
|
}
|
|
|
|
if ($rule->hasReachedLimit()) {
|
|
return false;
|
|
}
|
|
|
|
if (!empty($rule->user_roles) && is_array($rule->user_roles)) {
|
|
if (!$this->userHasAllowedRole($rule->user_roles)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (empty($rule->conditions)) {
|
|
return true;
|
|
}
|
|
|
|
foreach ($rule->conditions as $condition) {
|
|
if (!$this->evaluateCondition($condition, $product)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private function evaluateCondition($condition, $product = null) {
|
|
$type = $condition['type'] ?? '';
|
|
$value = $condition['value'] ?? null;
|
|
|
|
switch ($type) {
|
|
case 'user_type':
|
|
return $this->compareAnyValue($this->getUserTypeAliases(), $value, $condition['operator'] ?? 'is');
|
|
case 'cart_total_min':
|
|
return $this->getCartTotal() >= floatval($value);
|
|
case 'cart_total_max':
|
|
return $this->getCartTotal() <= floatval($value);
|
|
case 'cart_item_count_min':
|
|
return $this->getCartItemCount() >= intval($value);
|
|
case 'cart_item_count_max':
|
|
return $this->getCartItemCount() <= intval($value);
|
|
case 'product_category':
|
|
return $this->productHasCategory($product, (array) $value);
|
|
case 'exclude_product_category':
|
|
return !$this->productHasCategory($product, (array) $value);
|
|
case 'product_tag':
|
|
return $this->productHasTerm($product, (array) $value, 'product_tag');
|
|
case 'product_ids':
|
|
return $this->productIsInIds($product, (array) $value);
|
|
case 'exclude_product_ids':
|
|
return !$this->productIsInIds($product, (array) $value);
|
|
case 'cart_contains_product':
|
|
return $this->cartContainsProduct((array) $value);
|
|
case 'cart_contains_category':
|
|
return $this->cartContainsCategory((array) $value);
|
|
case 'customer_order_count_min':
|
|
return $this->getCustomerOrderCount() >= intval($value);
|
|
case 'customer_order_count_max':
|
|
return $this->getCustomerOrderCount() <= intval($value);
|
|
case 'day_of_week':
|
|
return in_array((string) current_time('N'), array_map('strval', $this->normalizeIdList($value)), true);
|
|
default:
|
|
return true;
|
|
}
|
|
}
|
|
|
|
private function getUserType() {
|
|
if (!is_user_logged_in()) {
|
|
return 'guest';
|
|
}
|
|
|
|
$user_id = get_current_user_id();
|
|
$order_count = wc_get_customer_order_count($user_id);
|
|
|
|
return $order_count > 0 ? 'returning' : 'new';
|
|
}
|
|
|
|
private function getUserTypeAliases() {
|
|
$type = $this->getUserType();
|
|
$aliases = [$type];
|
|
if (is_user_logged_in()) {
|
|
$aliases[] = 'logged_in';
|
|
}
|
|
return $aliases;
|
|
}
|
|
|
|
private function getCustomerOrderCount() {
|
|
if (!is_user_logged_in()) {
|
|
return 0;
|
|
}
|
|
|
|
return (int) wc_get_customer_order_count(get_current_user_id());
|
|
}
|
|
|
|
private function userHasAllowedRole($roles) {
|
|
if (!is_user_logged_in()) {
|
|
return false;
|
|
}
|
|
|
|
$user = wp_get_current_user();
|
|
foreach ($roles as $role) {
|
|
if (in_array($role, $user->roles, true)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private function getCartTotal() {
|
|
if (!function_exists('WC') || !WC()->cart) {
|
|
return 0;
|
|
}
|
|
return floatval(WC()->cart->get_subtotal());
|
|
}
|
|
|
|
private function getCartItemCount() {
|
|
if (!function_exists('WC') || !WC()->cart) {
|
|
return 0;
|
|
}
|
|
return intval(WC()->cart->get_cart_contents_count());
|
|
}
|
|
|
|
private function productHasCategory($product, $categories) {
|
|
return $this->productHasTerm($product, $categories, 'product_cat');
|
|
}
|
|
|
|
private function productHasTerm($product, $terms, $taxonomy) {
|
|
if (!$product || empty($terms)) {
|
|
return false;
|
|
}
|
|
|
|
$product_terms = wp_get_post_terms($product->get_id(), $taxonomy, ['fields' => 'ids']);
|
|
if (is_wp_error($product_terms)) {
|
|
return false;
|
|
}
|
|
|
|
return (bool) array_intersect($product_terms, $this->normalizeIdList($terms));
|
|
}
|
|
|
|
private function productIsInIds($product, $ids) {
|
|
if (!$product || empty($ids)) {
|
|
return false;
|
|
}
|
|
|
|
$ids = $this->normalizeIdList($ids);
|
|
$productIds = [(int) $product->get_id()];
|
|
if ($product->is_type('variation') && method_exists($product, 'get_parent_id')) {
|
|
$productIds[] = (int) $product->get_parent_id();
|
|
}
|
|
|
|
return (bool) array_intersect($productIds, $ids);
|
|
}
|
|
|
|
private function cartContainsProduct($ids) {
|
|
if (!function_exists('WC') || !WC()->cart) {
|
|
return false;
|
|
}
|
|
|
|
$ids = $this->normalizeIdList($ids);
|
|
foreach (WC()->cart->get_cart() as $cartItem) {
|
|
if (in_array((int) $cartItem['product_id'], $ids, true) || in_array((int) $cartItem['variation_id'], $ids, true)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private function cartContainsCategory($categories) {
|
|
if (!function_exists('WC') || !WC()->cart) {
|
|
return false;
|
|
}
|
|
|
|
foreach (WC()->cart->get_cart() as $cartItem) {
|
|
$product = wc_get_product($cartItem['product_id']);
|
|
if ($this->productHasCategory($product, $categories)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private function normalizeIdList($value) {
|
|
$values = is_array($value) ? $value : [$value];
|
|
$ids = [];
|
|
|
|
foreach ($values as $item) {
|
|
foreach (explode(',', (string) $item) as $id) {
|
|
$id = absint(trim($id));
|
|
if ($id > 0) {
|
|
$ids[] = $id;
|
|
}
|
|
}
|
|
}
|
|
|
|
return array_values(array_unique($ids));
|
|
}
|
|
|
|
private function applyRuleActions($rule, $price) {
|
|
foreach ($rule->actions as $action) {
|
|
$price = $this->applyAction($action, $price);
|
|
}
|
|
return $price;
|
|
}
|
|
|
|
private function applyAction($action, $price) {
|
|
$type = $action['type'] ?? '';
|
|
$value = isset($action['value']) ? floatval($action['value']) : 0;
|
|
|
|
switch ($type) {
|
|
case 'discount_percent':
|
|
if ($value <= 0) {
|
|
return $price;
|
|
}
|
|
return $price * (1 - min(100, $value) / 100);
|
|
case 'discount_fixed':
|
|
if ($value <= 0) {
|
|
return $price;
|
|
}
|
|
return $price - $value;
|
|
case 'set_price':
|
|
return $value > 0 ? $value : $price;
|
|
case 'increase_percent':
|
|
return $value > 0 ? $price * (1 + min(100, $value) / 100) : $price;
|
|
case 'increase_fixed':
|
|
return $value > 0 ? $price + $value : $price;
|
|
case 'free_shipping':
|
|
return $price;
|
|
default:
|
|
return $price;
|
|
}
|
|
}
|
|
|
|
private function enforceLimits($originalPrice, $price) {
|
|
$minPrice = max(0, floatval($this->settings->get('min_product_price', 0)));
|
|
$price = max($price, $minPrice);
|
|
|
|
$maxDiscountPercent = floatval($this->settings->get('max_discount_percent', 100));
|
|
if ($maxDiscountPercent > 0 && $maxDiscountPercent < 100) {
|
|
$maxDiscount = $originalPrice * ($maxDiscountPercent / 100);
|
|
$minAllowedPrice = $originalPrice - $maxDiscount;
|
|
$price = max($minAllowedPrice, $price);
|
|
}
|
|
|
|
return $price;
|
|
}
|
|
|
|
private function estimateRuleDiscount($rule, $price) {
|
|
foreach ($rule->actions as $action) {
|
|
if (($action['type'] ?? '') === 'discount_percent') {
|
|
return $price * floatval($action['value']) / 100;
|
|
}
|
|
if (($action['type'] ?? '') === 'discount_fixed') {
|
|
return floatval($action['value']);
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
public function applyFreeShippingRates($rates) {
|
|
$rules = $this->getApplicableRules(null);
|
|
$hasFreeShippingRule = false;
|
|
|
|
foreach ($rules as $rule) {
|
|
foreach ($rule->actions as $action) {
|
|
if (($action['type'] ?? '') === 'free_shipping') {
|
|
$hasFreeShippingRule = true;
|
|
break 2;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!$hasFreeShippingRule) {
|
|
return $rates;
|
|
}
|
|
|
|
foreach ($rates as $rate) {
|
|
$rate->cost = 0;
|
|
if (!empty($rate->taxes) && is_array($rate->taxes)) {
|
|
foreach ($rate->taxes as $taxId => $tax) {
|
|
$rate->taxes[$taxId] = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $rates;
|
|
}
|
|
|
|
private function compareValue($actual, $expected, $operator) {
|
|
$expectedValues = array_map('trim', explode(',', (string) $expected));
|
|
$actual = (string) $actual;
|
|
|
|
switch ($operator) {
|
|
case 'is_not':
|
|
return $actual !== (string) $expected;
|
|
case 'in':
|
|
return in_array($actual, $expectedValues, true);
|
|
case 'not_in':
|
|
return !in_array($actual, $expectedValues, true);
|
|
case 'is':
|
|
default:
|
|
return $actual === (string) $expected;
|
|
}
|
|
}
|
|
|
|
private function compareAnyValue(array $actualValues, $expected, $operator) {
|
|
$expectedValues = array_map('trim', explode(',', (string) $expected));
|
|
|
|
if (in_array($operator, ['is_not', 'not_in'], true)) {
|
|
foreach ($actualValues as $actual) {
|
|
if (in_array((string) $actual, $expectedValues, true)) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
foreach ($actualValues as $actual) {
|
|
if (in_array((string) $actual, $expectedValues, true)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private function trackDiscountOnce($product, $oldPrice, $price, $ruleId) {
|
|
$productId = $product ? $product->get_id() : 0;
|
|
$key = implode(':', [$productId, (int) $ruleId, round($oldPrice, 4), round($price, 4)]);
|
|
|
|
if ($price >= $oldPrice || isset($this->trackedApplications[$key])) {
|
|
return;
|
|
}
|
|
|
|
$this->trackedApplications[$key] = true;
|
|
$this->trackingService->recordDiscountApplied($product, $oldPrice, $price, $ruleId);
|
|
$this->ruleRepository->incrementUsage($ruleId);
|
|
}
|
|
}
|