feat(Rule): add new rules
This commit is contained in:
@@ -22,8 +22,25 @@ class AdminController {
|
||||
private $allowedBannerDeviceTargets = ['all', 'desktop', 'mobile'];
|
||||
private $allowedUpsellTriggerTypes = ['product', 'category', 'cart_total'];
|
||||
private $allowedUpsellDiscountTypes = ['percentage', 'fixed', 'none'];
|
||||
private $allowedRuleConditionTypes = ['user_type', 'product_category', 'product_ids', 'cart_total_min', 'cart_total_max', 'cart_item_count_min', 'cart_item_count_max'];
|
||||
private $allowedRuleActionTypes = ['discount_percent', 'discount_fixed', 'set_price', 'free_shipping'];
|
||||
private $allowedRuleConditionTypes = [
|
||||
'user_type',
|
||||
'product_category',
|
||||
'exclude_product_category',
|
||||
'product_tag',
|
||||
'product_ids',
|
||||
'exclude_product_ids',
|
||||
'cart_total_min',
|
||||
'cart_total_max',
|
||||
'cart_item_count_min',
|
||||
'cart_item_count_max',
|
||||
'cart_contains_product',
|
||||
'cart_contains_category',
|
||||
'customer_order_count_min',
|
||||
'customer_order_count_max',
|
||||
'day_of_week',
|
||||
];
|
||||
private $allowedRuleConditionOperators = ['is', 'is_not', 'in', 'not_in'];
|
||||
private $allowedRuleActionTypes = ['discount_percent', 'discount_fixed', 'set_price', 'increase_percent', 'increase_fixed', 'free_shipping'];
|
||||
private $allowedStrategies = ['priority', 'highest_discount', 'first_valid'];
|
||||
|
||||
public function __construct(RuleRepository $ruleRepository, UpsellRepository $upsellRepository, BannerRepository $bannerRepository) {
|
||||
@@ -667,6 +684,72 @@ class AdminController {
|
||||
return $deleted === false ? 0 : (int) $deleted;
|
||||
}
|
||||
|
||||
private function sanitizeRuleConditions($rawConditions) {
|
||||
$conditions = [];
|
||||
$rawConditions = is_array($rawConditions) ? wp_unslash($rawConditions) : [];
|
||||
|
||||
foreach ($rawConditions as $condition) {
|
||||
if (!is_array($condition)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$type = sanitize_key($condition['type'] ?? '');
|
||||
if (!in_array($type, $this->allowedRuleConditionTypes, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$operator = sanitize_key($condition['operator'] ?? 'is');
|
||||
if (!in_array($operator, $this->allowedRuleConditionOperators, true)) {
|
||||
$operator = 'is';
|
||||
}
|
||||
|
||||
$value = sanitize_text_field($condition['value'] ?? '');
|
||||
if ($value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$conditions[] = [
|
||||
'type' => $type,
|
||||
'operator' => $operator,
|
||||
'value' => $value,
|
||||
];
|
||||
}
|
||||
|
||||
return $conditions;
|
||||
}
|
||||
|
||||
private function sanitizeRuleActions($rawActions) {
|
||||
$actions = [];
|
||||
$rawActions = is_array($rawActions) ? wp_unslash($rawActions) : [];
|
||||
|
||||
foreach ($rawActions as $action) {
|
||||
if (!is_array($action)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$type = sanitize_key($action['type'] ?? '');
|
||||
if (!in_array($type, $this->allowedRuleActionTypes, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = max(0, floatval($action['value'] ?? 0));
|
||||
if (in_array($type, ['discount_percent', 'increase_percent'], true)) {
|
||||
$value = min(100, $value);
|
||||
}
|
||||
|
||||
if ($type !== 'free_shipping' && $value <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$actions[] = [
|
||||
'type' => $type,
|
||||
'value' => $value,
|
||||
];
|
||||
}
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
private function getSettingsDefaults() {
|
||||
return [
|
||||
'plugin_enabled' => 1,
|
||||
@@ -764,41 +847,30 @@ class AdminController {
|
||||
$name = sanitize_text_field($_POST['name'] ?? '');
|
||||
$this->requireValue($name, __('عنوان قانون الزامی است.', 'sodino'), 'sodino-add-rule');
|
||||
|
||||
$conditionType = sanitize_key($_POST['condition_type'] ?? 'user_type');
|
||||
if (!in_array($conditionType, $this->allowedRuleConditionTypes, true)) {
|
||||
$conditionType = 'user_type';
|
||||
$conditions = $this->sanitizeRuleConditions($_POST['conditions'] ?? []);
|
||||
if (empty($conditions)) {
|
||||
$this->redirectWithNotice($this->getBackUrl('sodino-add-rule'), __('حداقل یک شرط معتبر برای قانون لازم است.', 'sodino'), 'error');
|
||||
}
|
||||
|
||||
$conditionValue = sanitize_text_field($_POST['condition_value'] ?? '');
|
||||
$this->requireValue($conditionValue, __('مقدار شرط قانون الزامی است.', 'sodino'), 'sodino-add-rule');
|
||||
|
||||
$actionType = sanitize_key($_POST['action_type'] ?? 'discount_percent');
|
||||
if (!in_array($actionType, $this->allowedRuleActionTypes, true)) {
|
||||
$actionType = 'discount_percent';
|
||||
$actions = $this->sanitizeRuleActions($_POST['actions'] ?? []);
|
||||
if (empty($actions)) {
|
||||
$this->redirectWithNotice($this->getBackUrl('sodino-add-rule'), __('حداقل یک عملیات معتبر برای قانون لازم است.', 'sodino'), 'error');
|
||||
}
|
||||
|
||||
$actionValue = sanitize_text_field($_POST['action_value'] ?? '0');
|
||||
if ($actionType !== 'free_shipping' && floatval($actionValue) <= 0) {
|
||||
$this->redirectWithNotice($this->getBackUrl('sodino-add-rule'), __('مقدار عملیات باید بزرگتر از صفر باشد.', 'sodino'), 'error');
|
||||
$startDate = $this->normalizeDatetime($_POST['start_date'] ?? '');
|
||||
$endDate = $this->normalizeDatetime($_POST['end_date'] ?? '');
|
||||
if ($startDate && $endDate && strtotime($endDate) < strtotime($startDate)) {
|
||||
$this->redirectWithNotice($this->getBackUrl('sodino-add-rule'), __('تاریخ پایان قانون نباید قبل از تاریخ شروع باشد.', 'sodino'), 'error');
|
||||
}
|
||||
|
||||
$rule->name = $name;
|
||||
$rule->priority = max(1, intval($_POST['priority'] ?? 10));
|
||||
$rule->usage_limit = max(0, intval($_POST['usage_limit'] ?? 0));
|
||||
$rule->user_roles = array_map('sanitize_text_field', (array) ($_POST['user_roles'] ?? []));
|
||||
|
||||
$rule->conditions = [
|
||||
[
|
||||
'type' => $conditionType,
|
||||
'value' => $conditionValue,
|
||||
],
|
||||
];
|
||||
$rule->actions = [
|
||||
[
|
||||
'type' => $actionType,
|
||||
'value' => $actionValue,
|
||||
],
|
||||
];
|
||||
$rule->start_date = $startDate;
|
||||
$rule->end_date = $endDate;
|
||||
$rule->conditions = $conditions;
|
||||
$rule->actions = $actions;
|
||||
$rule->syncLegacyFields();
|
||||
$rule->enabled = isset($_POST['enabled']) ? 1 : 0;
|
||||
|
||||
|
||||
@@ -51,9 +51,7 @@ class PricingService {
|
||||
$oldPrice = $price;
|
||||
$price = $this->applyRuleActions($rule, $price);
|
||||
|
||||
if ($price < $oldPrice) {
|
||||
$this->trackDiscountOnce($product, $oldPrice, $price, $rule->id);
|
||||
}
|
||||
$this->trackDiscountOnce($product, $oldPrice, $price, $rule->id);
|
||||
}
|
||||
|
||||
$price = $this->enforceLimits($originalPrice, $price);
|
||||
@@ -62,20 +60,16 @@ class PricingService {
|
||||
}
|
||||
|
||||
private function getApplicableRules($product) {
|
||||
$cache_key = 'applicable_rules_' . ($product ? $product->get_id() : 'all');
|
||||
|
||||
return $this->cache->remember($cache_key, function() use ($product) {
|
||||
$rules = $this->ruleRepository->getEnabled();
|
||||
$applicable = [];
|
||||
$rules = $this->ruleRepository->getEnabled();
|
||||
$applicable = [];
|
||||
|
||||
foreach ($rules as $rule) {
|
||||
if ($this->ruleMatches($rule, $product)) {
|
||||
$applicable[] = $rule;
|
||||
}
|
||||
foreach ($rules as $rule) {
|
||||
if ($this->ruleMatches($rule, $product)) {
|
||||
$applicable[] = $rule;
|
||||
}
|
||||
}
|
||||
|
||||
return $applicable;
|
||||
}, 300, 'pricing');
|
||||
return $applicable;
|
||||
}
|
||||
|
||||
private function chooseRule(array $rules, $price) {
|
||||
@@ -139,7 +133,7 @@ class PricingService {
|
||||
|
||||
switch ($type) {
|
||||
case 'user_type':
|
||||
return $this->getUserType() === $value;
|
||||
return $this->compareAnyValue($this->getUserTypeAliases(), $value, $condition['operator'] ?? 'is');
|
||||
case 'cart_total_min':
|
||||
return $this->getCartTotal() >= floatval($value);
|
||||
case 'cart_total_max':
|
||||
@@ -150,8 +144,24 @@ class PricingService {
|
||||
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;
|
||||
}
|
||||
@@ -168,6 +178,23 @@ class PricingService {
|
||||
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;
|
||||
@@ -197,16 +224,20 @@ class PricingService {
|
||||
}
|
||||
|
||||
private function productHasCategory($product, $categories) {
|
||||
if (!$product || empty($categories)) {
|
||||
return $this->productHasTerm($product, $categories, 'product_cat');
|
||||
}
|
||||
|
||||
private function productHasTerm($product, $terms, $taxonomy) {
|
||||
if (!$product || empty($terms)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$product_cats = wp_get_post_terms($product->get_id(), 'product_cat', ['fields' => 'ids']);
|
||||
if (is_wp_error($product_cats)) {
|
||||
$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_cats, $this->normalizeIdList($categories));
|
||||
return (bool) array_intersect($product_terms, $this->normalizeIdList($terms));
|
||||
}
|
||||
|
||||
private function productIsInIds($product, $ids) {
|
||||
@@ -214,7 +245,43 @@ class PricingService {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($product->get_id(), $this->normalizeIdList($ids), true);
|
||||
$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) {
|
||||
@@ -249,7 +316,7 @@ class PricingService {
|
||||
if ($value <= 0) {
|
||||
return $price;
|
||||
}
|
||||
return $price * (1 - $value / 100);
|
||||
return $price * (1 - min(100, $value) / 100);
|
||||
case 'discount_fixed':
|
||||
if ($value <= 0) {
|
||||
return $price;
|
||||
@@ -257,6 +324,10 @@ class PricingService {
|
||||
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:
|
||||
@@ -290,11 +361,78 @@ class PricingService {
|
||||
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 (isset($this->trackedApplications[$key])) {
|
||||
if ($price >= $oldPrice || isset($this->trackedApplications[$key])) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user