feat: Implement upsell functionality with repository and service layers
This commit is contained in:
281
app/Services/AnalyticsService.php
Normal file
281
app/Services/AnalyticsService.php
Normal file
@@ -0,0 +1,281 @@
|
||||
<?php
|
||||
namespace Sodino\Services;
|
||||
|
||||
use Sodino\Repositories\EventRepository;
|
||||
use Sodino\Repositories\RuleRepository;
|
||||
|
||||
class AnalyticsService {
|
||||
private $eventRepository;
|
||||
private $ruleRepository;
|
||||
|
||||
public function __construct(EventRepository $eventRepository, RuleRepository $ruleRepository) {
|
||||
$this->eventRepository = $eventRepository;
|
||||
$this->ruleRepository = $ruleRepository;
|
||||
}
|
||||
|
||||
public function primeCache() {
|
||||
$cache_keys = [
|
||||
'sodino_dashboard_summary',
|
||||
'sodino_dashboard_sales_chart',
|
||||
'sodino_dashboard_rule_performance',
|
||||
'sodino_dashboard_user_behavior',
|
||||
];
|
||||
|
||||
foreach ($cache_keys as $key) {
|
||||
delete_transient($key);
|
||||
}
|
||||
}
|
||||
|
||||
public function getDashboardData(array $filters = []) {
|
||||
$cache_key = 'sodino_dashboard_' . md5(wp_json_encode($filters));
|
||||
$cached = get_transient($cache_key);
|
||||
if ($cached !== false) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$range = $this->getDateRange($filters['range'] ?? '7d', $filters['start_date'] ?? '', $filters['end_date'] ?? '');
|
||||
$filters['from'] = $range['start'];
|
||||
$filters['to'] = $range['end'];
|
||||
|
||||
if (!empty($filters['category_id'])) {
|
||||
$filters['product_ids'] = $this->getProductIdsByCategory($filters['category_id']);
|
||||
}
|
||||
|
||||
$summary = $this->getSummary($filters);
|
||||
$salesChart = $this->getSalesChart($filters);
|
||||
$rulePerformance = $this->getRulePerformance($filters);
|
||||
$userBehavior = $this->getUserBehavior($filters);
|
||||
$insights = $this->getInsights($summary, $filters);
|
||||
|
||||
$result = [
|
||||
'summary' => $summary,
|
||||
'sales_chart' => $salesChart,
|
||||
'rule_performance' => $rulePerformance,
|
||||
'user_behavior' => $userBehavior,
|
||||
'insights' => $insights,
|
||||
];
|
||||
|
||||
set_transient($cache_key, $result, 10 * MINUTE_IN_SECONDS);
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function getSummary(array $filters = []) {
|
||||
$purchaseFilters = array_merge($filters, ['event_type' => 'purchase']);
|
||||
$discountFilters = array_merge($filters, ['event_type' => 'discount_applied']);
|
||||
|
||||
$purchaseCount = $this->eventRepository->getCount($purchaseFilters);
|
||||
$totalRevenue = $this->eventRepository->getSum('value', $purchaseFilters);
|
||||
$totalDiscount = $this->eventRepository->getSum('discount_value', $discountFilters);
|
||||
|
||||
$productViewCount = $this->eventRepository->getCount(array_merge($filters, ['event_type' => 'product_view']));
|
||||
$addToCartCount = $this->eventRepository->getCount(array_merge($filters, ['event_type' => 'add_to_cart']));
|
||||
$checkoutStartCount = $this->eventRepository->getCount(array_merge($filters, ['event_type' => 'checkout_start']));
|
||||
|
||||
$conversionRate = 0;
|
||||
if ($checkoutStartCount > 0) {
|
||||
$conversionRate = round(($purchaseCount / $checkoutStartCount) * 100, 2);
|
||||
} elseif ($addToCartCount > 0) {
|
||||
$conversionRate = round(($purchaseCount / $addToCartCount) * 100, 2);
|
||||
}
|
||||
|
||||
$addToCartRate = 0;
|
||||
if ($productViewCount > 0) {
|
||||
$addToCartRate = round(($addToCartCount / $productViewCount) * 100, 2);
|
||||
}
|
||||
|
||||
$bestRule = $this->getBestRule($filters);
|
||||
|
||||
return [
|
||||
'total_revenue' => $totalRevenue,
|
||||
'total_discount' => $totalDiscount,
|
||||
'purchase_count' => $purchaseCount,
|
||||
'conversion_rate' => $conversionRate,
|
||||
'add_to_cart_rate' => $addToCartRate,
|
||||
'best_rule' => $bestRule,
|
||||
];
|
||||
}
|
||||
|
||||
public function getSalesChart(array $filters = []) {
|
||||
$filters = array_merge($filters, $this->getDateRange($filters['range'] ?? '7d', $filters['start_date'] ?? '', $filters['end_date'] ?? ''));
|
||||
|
||||
if (!empty($filters['category_id'])) {
|
||||
$filters['product_ids'] = $this->getProductIdsByCategory($filters['category_id']);
|
||||
}
|
||||
|
||||
$start = new \DateTime($filters['start']);
|
||||
$end = new \DateTime($filters['end']);
|
||||
$days = [];
|
||||
$series = [ 'before' => [], 'after' => [], 'labels' => [] ];
|
||||
|
||||
while ($start <= $end) {
|
||||
$date = $start->format('Y-m-d');
|
||||
$series['labels'][] = $date;
|
||||
$dayFilters = array_merge($filters, ['from' => $date . ' 00:00:00', 'to' => $date . ' 23:59:59']);
|
||||
$purchases = $this->eventRepository->getEvents(array_merge($dayFilters, ['event_type' => 'purchase']));
|
||||
$dayRevenue = 0;
|
||||
$dayDiscount = 0;
|
||||
foreach ($purchases as $purchase) {
|
||||
$dayRevenue += floatval($purchase['value']);
|
||||
}
|
||||
$discountEvents = $this->eventRepository->getEvents(array_merge($dayFilters, ['event_type' => 'discount_applied']));
|
||||
foreach ($discountEvents as $discount) {
|
||||
$dayDiscount += floatval($discount['discount_value']);
|
||||
}
|
||||
$series['after'][] = round($dayRevenue, 2);
|
||||
$series['before'][] = round($dayRevenue + $dayDiscount, 2);
|
||||
$start->modify('+1 day');
|
||||
}
|
||||
|
||||
return $series;
|
||||
}
|
||||
|
||||
public function getRulePerformance(array $filters = []) {
|
||||
$rules = $this->ruleRepository->getAll();
|
||||
$result = [];
|
||||
|
||||
foreach ($rules as $rule) {
|
||||
$ruleFilters = array_merge($filters, ['event_type' => 'discount_applied', 'rule_id' => $rule->id]);
|
||||
$count = $this->eventRepository->getCount($ruleFilters);
|
||||
$revenue = $this->eventRepository->getSum('value', $ruleFilters);
|
||||
$totalDiscount = $this->eventRepository->getSum('discount_value', $ruleFilters);
|
||||
|
||||
if ($count > 0) {
|
||||
$result[] = [
|
||||
'name' => $rule->name,
|
||||
'count' => $count,
|
||||
'revenue' => round($revenue, 2),
|
||||
'discount' => round($totalDiscount, 2),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function getUserBehavior(array $filters = []) {
|
||||
$productViewCount = $this->eventRepository->getCount(array_merge($filters, ['event_type' => 'product_view']));
|
||||
$addToCartCount = $this->eventRepository->getCount(array_merge($filters, ['event_type' => 'add_to_cart']));
|
||||
$checkoutStartCount = $this->eventRepository->getCount(array_merge($filters, ['event_type' => 'checkout_start']));
|
||||
$purchaseCount = $this->eventRepository->getCount(array_merge($filters, ['event_type' => 'purchase']));
|
||||
|
||||
return [
|
||||
'product_views' => $productViewCount,
|
||||
'add_to_cart' => $addToCartCount,
|
||||
'checkout_start' => $checkoutStartCount,
|
||||
'purchases' => $purchaseCount,
|
||||
];
|
||||
}
|
||||
|
||||
private function getBestRule(array $filters = []) {
|
||||
$performance = $this->getRulePerformance($filters);
|
||||
if (empty($performance)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
usort($performance, function ($a, $b) {
|
||||
return $b['revenue'] <=> $a['revenue'];
|
||||
});
|
||||
|
||||
return $performance[0]['name'] ?? null;
|
||||
}
|
||||
|
||||
private function getInsights(array $summary, array $filters = []) {
|
||||
$insights = [];
|
||||
|
||||
if (!empty($summary['best_rule'])) {
|
||||
$insights[] = sprintf('%s %s', __('قانون برتر:', 'sodino'), esc_html($summary['best_rule']));
|
||||
}
|
||||
|
||||
if ($summary['total_discount'] > 0 && $summary['total_revenue'] > 0) {
|
||||
$discountShare = round(($summary['total_discount'] / ($summary['total_revenue'] + $summary['total_discount'])) * 100, 2);
|
||||
$insights[] = sprintf(
|
||||
'%s %s%% %s',
|
||||
__('تخفیفها باعث ایجاد', 'sodino'),
|
||||
esc_html($discountShare),
|
||||
__('درصد از درآمد شدهاند.', 'sodino')
|
||||
);
|
||||
}
|
||||
|
||||
if ($summary['conversion_rate'] > 0) {
|
||||
$insights[] = sprintf('%s %s%% %s', __('نرخ تبدیل تقریبی', 'sodino'), esc_html($summary['conversion_rate']), __('است.', 'sodino'));
|
||||
}
|
||||
|
||||
if (empty($insights)) {
|
||||
$insights[] = __('هیچ دادهٔ قابل تحلیلی هنوز ثبت نشده است.', 'sodino');
|
||||
}
|
||||
|
||||
return $insights;
|
||||
}
|
||||
|
||||
private function getDateRange($range, $start, $end) {
|
||||
$result = [
|
||||
'start' => date('Y-m-d', strtotime('-6 days')),
|
||||
'end' => date('Y-m-d'),
|
||||
];
|
||||
|
||||
if ($range === '30d') {
|
||||
$result['start'] = date('Y-m-d', strtotime('-29 days'));
|
||||
$result['end'] = date('Y-m-d');
|
||||
}
|
||||
|
||||
if ($range === 'custom' && !empty($start) && !empty($end)) {
|
||||
$result['start'] = date('Y-m-d', strtotime($start));
|
||||
$result['end'] = date('Y-m-d', strtotime($end));
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function getProductIdsByCategory($category_id) {
|
||||
$products = get_posts([
|
||||
'post_type' => 'product',
|
||||
'numberposts' => -1,
|
||||
'fields' => 'ids',
|
||||
'tax_query' => [
|
||||
[
|
||||
'taxonomy' => 'product_cat',
|
||||
'field' => 'term_id',
|
||||
'terms' => intval($category_id),
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
return $products ?: [];
|
||||
}
|
||||
|
||||
public function getProductOptions() {
|
||||
$products = get_posts([
|
||||
'post_type' => 'product',
|
||||
'numberposts' => -1,
|
||||
'fields' => 'ids',
|
||||
'orderby' => 'title',
|
||||
'order' => 'ASC',
|
||||
]);
|
||||
|
||||
$options = [];
|
||||
foreach ($products as $product_id) {
|
||||
$options[] = [
|
||||
'id' => $product_id,
|
||||
'name' => get_the_title($product_id),
|
||||
];
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
public function getCategoryOptions() {
|
||||
$categories = get_terms(['taxonomy' => 'product_cat', 'hide_empty' => false]);
|
||||
$options = [];
|
||||
|
||||
if (!is_wp_error($categories)) {
|
||||
foreach ($categories as $category) {
|
||||
$options[] = [
|
||||
'id' => $category->term_id,
|
||||
'name' => $category->name,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
}
|
||||
@@ -2,54 +2,98 @@
|
||||
namespace Sodino\Services;
|
||||
|
||||
use Sodino\Repositories\RuleRepository;
|
||||
use Sodino\Services\TrackingService;
|
||||
|
||||
/**
|
||||
* Pricing Service
|
||||
*/
|
||||
class PricingService {
|
||||
private $ruleRepository;
|
||||
private $trackingService;
|
||||
private $rulesCache = null;
|
||||
private $freeShipping = false;
|
||||
|
||||
public function __construct(RuleRepository $ruleRepository) {
|
||||
public function __construct(RuleRepository $ruleRepository, TrackingService $trackingService) {
|
||||
$this->ruleRepository = $ruleRepository;
|
||||
$this->trackingService = $trackingService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply dynamic pricing to a product price
|
||||
*/
|
||||
public function applyDynamicPricing($price, $product) {
|
||||
$settings = $this->getSettings();
|
||||
if (empty($settings['plugin_enabled']) || empty($settings['pricing_enabled'])) {
|
||||
return $price;
|
||||
}
|
||||
|
||||
if (!$price || !is_numeric($price)) {
|
||||
return $price;
|
||||
}
|
||||
|
||||
$price = $this->normalizePrice($price);
|
||||
if (!$settings['cart_pricing_enabled'] && is_cart()) {
|
||||
return $price;
|
||||
}
|
||||
|
||||
$originalPrice = $price;
|
||||
$rules = $this->getEnabledRules();
|
||||
$matchedRule = null;
|
||||
$matchedRules = [];
|
||||
|
||||
foreach ($rules as $rule) {
|
||||
if ($this->ruleMatches($rule, $product)) {
|
||||
if ($matchedRule === null || $rule->priority > $matchedRule->priority) {
|
||||
$matchedRule = $rule;
|
||||
}
|
||||
$matchedRules[] = $rule;
|
||||
}
|
||||
}
|
||||
|
||||
if ($matchedRule) {
|
||||
$price = $this->applyActions($matchedRule, $price);
|
||||
if (empty($matchedRules)) {
|
||||
return $price;
|
||||
}
|
||||
|
||||
if (!$settings['allow_multiple_rules']) {
|
||||
$chosenRule = $this->chooseRule($matchedRules, $price, $settings['strategy']);
|
||||
$matchedRules = $chosenRule ? [$chosenRule] : [];
|
||||
}
|
||||
|
||||
foreach ($matchedRules as $rule) {
|
||||
$oldPrice = $price;
|
||||
$price = $this->applyActions($rule, $price);
|
||||
if ($price < $oldPrice) {
|
||||
$this->trackingService->recordDiscountApplied($product, $oldPrice, $price, $rule->id);
|
||||
}
|
||||
}
|
||||
|
||||
$price = $this->enforceLimits($originalPrice, $price, $settings);
|
||||
|
||||
return max(0, $price);
|
||||
}
|
||||
|
||||
public function shouldApplyFreeShipping() {
|
||||
$rules = $this->getEnabledRules();
|
||||
foreach ($rules as $rule) {
|
||||
if ($this->ruleMatches($rule, null) && $this->ruleHasFreeShipping($rule)) {
|
||||
return true;
|
||||
}
|
||||
private function chooseRule(array $rules, $price, $strategy) {
|
||||
if ($strategy === 'highest_discount') {
|
||||
usort($rules, function ($a, $b) use ($price) {
|
||||
return $this->estimateRuleDiscount($b, $price) <=> $this->estimateRuleDiscount($a, $price);
|
||||
});
|
||||
return $rules[0] ?? null;
|
||||
}
|
||||
return false;
|
||||
|
||||
if ($strategy === 'first_valid') {
|
||||
return $rules[0] ?? null;
|
||||
}
|
||||
|
||||
usort($rules, function ($a, $b) {
|
||||
return $b->priority <=> $a->priority;
|
||||
});
|
||||
return $rules[0] ?? null;
|
||||
}
|
||||
|
||||
public function resetFreeShippingFlag() {
|
||||
$this->freeShipping = false;
|
||||
private function getSettings() {
|
||||
$defaults = [
|
||||
'plugin_enabled' => 1,
|
||||
'pricing_enabled' => 1,
|
||||
'upsell_enabled' => 1,
|
||||
'allow_multiple_rules' => 0,
|
||||
'strategy' => 'priority',
|
||||
'max_discount_percent' => 100,
|
||||
'min_product_price' => 0,
|
||||
'ab_testing_enabled' => 0,
|
||||
'cart_pricing_enabled' => 1,
|
||||
'scheduled_campaigns_enabled' => 1,
|
||||
];
|
||||
|
||||
return wp_parse_args(get_option('sodino_settings', []), $defaults);
|
||||
}
|
||||
|
||||
private function getEnabledRules() {
|
||||
@@ -67,22 +111,29 @@ class PricingService {
|
||||
return floatval($price);
|
||||
}
|
||||
|
||||
private function getUserType() {
|
||||
if (!is_user_logged_in()) {
|
||||
return 'guest';
|
||||
private function ruleMatches($rule, $product = null) {
|
||||
if (!$rule->enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$user_id = get_current_user_id();
|
||||
$order_count = wc_get_customer_order_count($user_id);
|
||||
if ($rule->usage_limit > 0 && $this->trackingService->getRuleUsageCount($rule->id) >= $rule->usage_limit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $order_count > 0 ? 'returning' : 'new';
|
||||
}
|
||||
if (!empty($rule->user_roles) && is_array($rule->user_roles)) {
|
||||
if (!$this->userHasAllowedRole($rule->user_roles)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function ruleMatches($rule, $product = null) {
|
||||
if (!$this->isRuleActive($rule)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (empty($rule->conditions)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($rule->conditions as $condition) {
|
||||
if (!$this->evaluateCondition($condition, $product)) {
|
||||
return false;
|
||||
@@ -93,10 +144,6 @@ class PricingService {
|
||||
}
|
||||
|
||||
private function isRuleActive($rule) {
|
||||
if (!$rule->enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$now = current_time('Y-m-d H:i:s');
|
||||
|
||||
if (!empty($rule->start_date) && $now < $rule->start_date) {
|
||||
@@ -134,6 +181,32 @@ class PricingService {
|
||||
}
|
||||
}
|
||||
|
||||
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 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 (!WC()->cart) {
|
||||
return 0;
|
||||
@@ -170,20 +243,9 @@ class PricingService {
|
||||
private function applyActions($rule, $price) {
|
||||
foreach ($rule->actions as $action) {
|
||||
$price = $this->applyAction($action, $price);
|
||||
if (($action['type'] ?? '') === 'free_shipping') {
|
||||
$this->freeShipping = true;
|
||||
}
|
||||
}
|
||||
return $price;
|
||||
}
|
||||
|
||||
private function ruleHasFreeShipping($rule) {
|
||||
foreach ($rule->actions as $action) {
|
||||
if (($action['type'] ?? '') === 'free_shipping') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
return $price;
|
||||
}
|
||||
|
||||
private function applyAction($action, $price) {
|
||||
@@ -207,4 +269,29 @@ class PricingService {
|
||||
return $price;
|
||||
}
|
||||
}
|
||||
|
||||
private function enforceLimits($originalPrice, $price, array $settings) {
|
||||
$minPrice = max(0, floatval($settings['min_product_price']));
|
||||
$price = max($price, $minPrice);
|
||||
|
||||
$maxDiscountPercent = floatval($settings['max_discount_percent']);
|
||||
if ($maxDiscountPercent > 0 && $maxDiscountPercent < 100) {
|
||||
$limit = $originalPrice * ($maxDiscountPercent / 100);
|
||||
$price = max($originalPrice - $limit, $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;
|
||||
}
|
||||
}
|
||||
|
||||
137
app/Services/TrackingService.php
Normal file
137
app/Services/TrackingService.php
Normal file
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
namespace Sodino\Services;
|
||||
|
||||
use Sodino\Repositories\EventRepository;
|
||||
|
||||
class TrackingService {
|
||||
private $eventRepository;
|
||||
private $loggedEvents = [];
|
||||
|
||||
public function __construct(EventRepository $eventRepository) {
|
||||
$this->eventRepository = $eventRepository;
|
||||
}
|
||||
|
||||
public function trackProductView($product_id) {
|
||||
if (!$product_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$session_key = 'sodino_viewed_' . intval($product_id);
|
||||
if ($this->hasLogged($session_key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->logEvent('product_view', [
|
||||
'product_id' => $product_id,
|
||||
]);
|
||||
$this->markLogged($session_key);
|
||||
}
|
||||
|
||||
public function trackAddToCart($product_id, $variation_id = null, $quantity = 1) {
|
||||
if (!$product_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->logEvent('add_to_cart', [
|
||||
'product_id' => $product_id,
|
||||
'variation_id' => $variation_id,
|
||||
'value' => floatval($quantity),
|
||||
]);
|
||||
}
|
||||
|
||||
public function trackCheckoutStart() {
|
||||
$this->logEvent('checkout_start', []);
|
||||
}
|
||||
|
||||
public function trackPurchase($order_id) {
|
||||
if (!$order_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$order = wc_get_order($order_id);
|
||||
if (!$order) {
|
||||
return;
|
||||
}
|
||||
|
||||
$total = floatval($order->get_total());
|
||||
$discount = 0;
|
||||
if (method_exists($order, 'get_total_discount')) {
|
||||
$discount = floatval($order->get_total_discount());
|
||||
} else {
|
||||
foreach ($order->get_items() as $item) {
|
||||
$discount += floatval($item->get_subtotal()) - floatval($item->get_total());
|
||||
}
|
||||
}
|
||||
|
||||
$this->logEvent('purchase', [
|
||||
'value' => $total,
|
||||
'discount_value' => max(0, $discount),
|
||||
]);
|
||||
}
|
||||
|
||||
public function recordDiscountApplied($product, $original_price, $discounted_price, $rule_id = null) {
|
||||
if (!$product || $original_price <= 0 || $discounted_price >= $original_price) {
|
||||
return;
|
||||
}
|
||||
|
||||
$product_id = $product->get_id();
|
||||
$variation_id = $product->is_type('variation') ? $product_id : 0;
|
||||
$discount_value = round($original_price - $discounted_price, 2);
|
||||
|
||||
$key = 'discount_applied_' . $product_id . '_' . $rule_id . '_' . $discount_value;
|
||||
if ($this->hasLogged($key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->logEvent('discount_applied', [
|
||||
'product_id' => $product_id,
|
||||
'variation_id' => $variation_id,
|
||||
'rule_id' => $rule_id,
|
||||
'value' => $discounted_price,
|
||||
'discount_value' => $discount_value,
|
||||
]);
|
||||
$this->markLogged($key);
|
||||
}
|
||||
|
||||
public function getRuleUsageCount($rule_id) {
|
||||
return $this->eventRepository->getRuleUsageCount($rule_id);
|
||||
}
|
||||
|
||||
private function logEvent($type, array $data = []) {
|
||||
$event = [
|
||||
'event_type' => $type,
|
||||
'product_id' => isset($data['product_id']) ? intval($data['product_id']) : null,
|
||||
'variation_id' => isset($data['variation_id']) ? intval($data['variation_id']) : null,
|
||||
'user_id' => get_current_user_id() ?: null,
|
||||
'session_id' => $this->getSessionId(),
|
||||
'rule_id' => isset($data['rule_id']) ? intval($data['rule_id']) : null,
|
||||
'value' => isset($data['value']) ? floatval($data['value']) : 0,
|
||||
'discount_value' => isset($data['discount_value']) ? floatval($data['discount_value']) : 0,
|
||||
'metadata' => isset($data['metadata']) ? wp_json_encode($data['metadata']) : null,
|
||||
'created_at' => current_time('mysql'),
|
||||
];
|
||||
|
||||
$this->eventRepository->insert($event);
|
||||
}
|
||||
|
||||
private function getSessionId() {
|
||||
if (function_exists('WC') && WC()->session) {
|
||||
$session_id = WC()->session->get('sodino_session_id');
|
||||
if (!$session_id) {
|
||||
$session_id = uniqid('sodino_', true);
|
||||
WC()->session->set('sodino_session_id', $session_id);
|
||||
}
|
||||
return $session_id;
|
||||
}
|
||||
|
||||
return 'guest_' . md5($_SERVER['REMOTE_ADDR'] . '|' . $_SERVER['HTTP_USER_AGENT']);
|
||||
}
|
||||
|
||||
private function hasLogged($key) {
|
||||
return isset($this->loggedEvents[$key]);
|
||||
}
|
||||
|
||||
private function markLogged($key) {
|
||||
$this->loggedEvents[$key] = true;
|
||||
}
|
||||
}
|
||||
149
app/Services/UpsellService.php
Normal file
149
app/Services/UpsellService.php
Normal file
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
namespace Sodino\Services;
|
||||
|
||||
use Sodino\Repositories\UpsellRepository;
|
||||
|
||||
class UpsellService {
|
||||
private $upsellRepository;
|
||||
private $cache = null;
|
||||
|
||||
public function __construct(UpsellRepository $upsellRepository) {
|
||||
$this->upsellRepository = $upsellRepository;
|
||||
}
|
||||
|
||||
public function getActiveUpsells() {
|
||||
if ($this->cache === null) {
|
||||
$this->cache = $this->upsellRepository->getActive();
|
||||
}
|
||||
return $this->cache;
|
||||
}
|
||||
|
||||
public function getMatchingUpsells($cart) {
|
||||
if (!$cart || $cart->is_empty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$matches = [];
|
||||
foreach ($this->getActiveUpsells() as $upsell) {
|
||||
if ($this->cartMatchesTrigger($upsell, $cart) && !$this->isProductAlreadyInCart($cart, $upsell->target_product_id)) {
|
||||
$matches[] = $upsell;
|
||||
}
|
||||
}
|
||||
|
||||
usort($matches, function ($a, $b) {
|
||||
return $b->priority <=> $a->priority;
|
||||
});
|
||||
|
||||
return $matches;
|
||||
}
|
||||
|
||||
public function applyUpsellDiscount($product, $upsell) {
|
||||
if (!$product || !$upsell) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$price = floatval($product->get_price());
|
||||
if ($upsell->discount_type === 'percentage') {
|
||||
return max(0, $price * (1 - floatval($upsell->discount_value) / 100));
|
||||
}
|
||||
|
||||
if ($upsell->discount_type === 'fixed') {
|
||||
return max(0, $price - floatval($upsell->discount_value));
|
||||
}
|
||||
|
||||
return $price;
|
||||
}
|
||||
|
||||
public function getTriggerLabel($upsell) {
|
||||
switch ($upsell->trigger_type) {
|
||||
case 'product':
|
||||
return __('محصول خاص', 'sodino');
|
||||
case 'category':
|
||||
return __('دستهبندی', 'sodino');
|
||||
case 'cart_total':
|
||||
return __('مبلغ سبد خرید', 'sodino');
|
||||
default:
|
||||
return __('نامشخص', 'sodino');
|
||||
}
|
||||
}
|
||||
|
||||
public function getDiscountLabel($upsell) {
|
||||
if ($upsell->discount_type === 'fixed') {
|
||||
return sprintf('%s تومان', number_format_i18n($upsell->discount_value));
|
||||
}
|
||||
|
||||
if ($upsell->discount_type === 'percentage') {
|
||||
return sprintf('%s %%', esc_html($upsell->discount_value));
|
||||
}
|
||||
|
||||
return __('بدون تخفیف', 'sodino');
|
||||
}
|
||||
|
||||
private function cartMatchesTrigger($upsell, $cart) {
|
||||
if (!$upsell->isActive()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$triggerType = $upsell->trigger_type;
|
||||
$triggerValue = $upsell->trigger_value;
|
||||
|
||||
switch ($triggerType) {
|
||||
case 'product':
|
||||
return $this->cartContainsProduct($cart, intval($triggerValue));
|
||||
case 'category':
|
||||
return $this->cartContainsCategory($cart, intval($triggerValue));
|
||||
case 'cart_total':
|
||||
return floatval($cart->get_cart_contents_total()) >= floatval($triggerValue);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function cartContainsProduct($cart, $productId) {
|
||||
if (!$productId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($cart->get_cart() as $cartItem) {
|
||||
if ((int) $cartItem['product_id'] === $productId || (int) $cartItem['variation_id'] === $productId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function cartContainsCategory($cart, $categoryId) {
|
||||
if (!$categoryId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($cart->get_cart() as $cartItem) {
|
||||
$product = wc_get_product($cartItem['product_id']);
|
||||
if (!$product) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$terms = wp_get_post_terms($product->get_id(), 'product_cat', ['fields' => 'ids']);
|
||||
if (is_array($terms) && in_array($categoryId, $terms, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function isProductAlreadyInCart($cart, $productId) {
|
||||
if (!$productId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($cart->get_cart() as $cartItem) {
|
||||
if ((int) $cartItem['product_id'] === $productId || (int) $cartItem['variation_id'] === $productId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user