Files
sodino/app/Services/AnalyticsService.php

499 lines
18 KiB
PHP

<?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);
$roiReport = $this->getRoiReport($filters);
$upsellPerformance = $this->getUpsellPerformance($filters);
$bannerPerformance = $this->getBannerPerformance($filters, $summary);
$userBehavior = $this->getUserBehavior($filters);
$insights = $this->getInsights($summary, $filters, $roiReport);
$result = [
'summary' => $summary,
'sales_chart' => $salesChart,
'rule_performance' => $rulePerformance,
'roi_report' => $roiReport,
'upsell_performance' => $upsellPerformance,
'banner_performance' => $bannerPerformance,
'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 getRoiReport(array $filters = []) {
$orders = $this->getOrdersInRange($filters);
$attributedRevenue = 0;
$attributedDiscount = 0;
$attributedOrders = [];
$ruleRows = [];
$upsellRows = [];
foreach ($orders as $order) {
$orderId = $order->get_id();
foreach ($order->get_items() as $item) {
$lineRevenue = (float) $item->get_total();
$ruleIds = $this->parseIdList($item->get_meta('_sodino_rule_ids', true));
$ruleDiscount = (float) $item->get_meta('_sodino_rule_discount', true);
$upsellId = (int) $item->get_meta('_sodino_upsell_id', true);
if (!empty($ruleIds) || $upsellId > 0) {
$attributedRevenue += $lineRevenue;
$attributedDiscount += $ruleDiscount;
$attributedOrders[$orderId] = true;
}
foreach ($ruleIds as $ruleId) {
if (!isset($ruleRows[$ruleId])) {
$ruleRows[$ruleId] = [
'rule_id' => $ruleId,
'name' => $this->getRuleName($ruleId),
'orders' => [],
'revenue' => 0,
'discount' => 0,
];
}
$allocation = count($ruleIds) > 0 ? $lineRevenue / count($ruleIds) : $lineRevenue;
$discountAllocation = count($ruleIds) > 0 ? $ruleDiscount / count($ruleIds) : $ruleDiscount;
$ruleRows[$ruleId]['orders'][$orderId] = true;
$ruleRows[$ruleId]['revenue'] += $allocation;
$ruleRows[$ruleId]['discount'] += $discountAllocation;
}
if ($upsellId > 0) {
if (!isset($upsellRows[$upsellId])) {
$upsellRows[$upsellId] = [
'upsell_id' => $upsellId,
'title' => $this->getUpsellTitle($upsellId),
'orders' => [],
'revenue' => 0,
];
}
$upsellRows[$upsellId]['orders'][$orderId] = true;
$upsellRows[$upsellId]['revenue'] += $lineRevenue;
}
}
}
$ruleRows = array_map(function ($row) {
$row['order_count'] = count($row['orders']);
unset($row['orders']);
$row['revenue'] = round($row['revenue'], 2);
$row['discount'] = round($row['discount'], 2);
return $row;
}, array_values($ruleRows));
usort($ruleRows, function ($a, $b) {
return $b['revenue'] <=> $a['revenue'];
});
$upsellRows = array_map(function ($row) {
$row['order_count'] = count($row['orders']);
unset($row['orders']);
$row['revenue'] = round($row['revenue'], 2);
return $row;
}, array_values($upsellRows));
usort($upsellRows, function ($a, $b) {
return $b['revenue'] <=> $a['revenue'];
});
return [
'attributed_revenue' => round($attributedRevenue, 2),
'attributed_discount' => round($attributedDiscount, 2),
'attributed_order_count' => count($attributedOrders),
'rule_rows' => $ruleRows,
'upsell_rows' => $upsellRows,
];
}
public function getUpsellPerformance(array $filters = []) {
$roi = $this->getRoiReport($filters);
$orderRows = [];
foreach ($roi['upsell_rows'] as $row) {
$orderRows[(int) $row['upsell_id']] = $row;
}
$repository = new \Sodino\Repositories\UpsellRepository();
$rows = [];
foreach ($repository->getAll() as $upsell) {
$orderRow = $orderRows[(int) $upsell->id] ?? null;
$impressions = max(0, (int) $upsell->impressions);
$addToCartConversions = max(0, (int) $upsell->conversions);
$orderCount = $orderRow ? (int) $orderRow['order_count'] : 0;
$revenue = $orderRow ? (float) $orderRow['revenue'] : 0;
$rows[] = [
'id' => (int) $upsell->id,
'title' => $upsell->title,
'impressions' => $impressions,
'add_to_cart' => $addToCartConversions,
'orders' => $orderCount,
'revenue' => round($revenue, 2),
'cart_conversion_rate' => $impressions > 0 ? round(($addToCartConversions / $impressions) * 100, 2) : 0,
'order_conversion_rate' => $impressions > 0 ? round(($orderCount / $impressions) * 100, 2) : 0,
];
}
usort($rows, function ($a, $b) {
return $b['revenue'] <=> $a['revenue'];
});
return $rows;
}
public function getBannerPerformance(array $filters = [], array $summary = []) {
$repository = new \Sodino\Repositories\BannerRepository();
$purchaseCount = (int) ($summary['purchase_count'] ?? $this->eventRepository->getCount(array_merge($filters, ['event_type' => 'purchase'])));
$clickTotal = 0;
foreach ($repository->getAll() as $banner) {
$clickTotal += max(0, (int) $banner->clicks);
}
$rows = [];
foreach ($repository->getAll() as $banner) {
$impressions = max(0, (int) $banner->impressions);
$clicks = max(0, (int) $banner->clicks);
$estimatedOrders = $clickTotal > 0 ? round(($clicks / $clickTotal) * $purchaseCount, 2) : 0;
$rows[] = [
'id' => (int) $banner->id,
'title' => $banner->title,
'impressions' => $impressions,
'clicks' => $clicks,
'ctr' => $impressions > 0 ? round(($clicks / $impressions) * 100, 2) : 0,
'estimated_orders' => $estimatedOrders,
];
}
usort($rows, function ($a, $b) {
return $b['clicks'] <=> $a['clicks'];
});
return $rows;
}
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 = [], array $roiReport = []) {
$insights = [];
if (!empty($roiReport['attributed_revenue'])) {
$insights[] = sprintf(
__('سودینو %s فروش اثرگرفته ثبت کرده است.', 'sodino'),
wp_strip_all_tags(wc_price((float) $roiReport['attributed_revenue']))
);
}
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) {
$today = current_time('Y-m-d');
$result = [
'start' => date('Y-m-d', strtotime($today . ' -6 days')),
'end' => $today,
];
if ($range === '30d') {
$result['start'] = date('Y-m-d', strtotime($today . ' -29 days'));
$result['end'] = $today;
}
if ($range === 'custom' && !empty($start) && !empty($end)) {
$startTimestamp = strtotime($start);
$endTimestamp = strtotime($end);
if ($startTimestamp && $endTimestamp) {
if ($endTimestamp < $startTimestamp) {
$endTimestamp = $startTimestamp;
}
$result['start'] = date('Y-m-d', $startTimestamp);
$result['end'] = date('Y-m-d', $endTimestamp);
}
}
return $result;
}
private function getOrdersInRange(array $filters = []) {
if (!function_exists('wc_get_orders')) {
return [];
}
$range = $this->getDateRange($filters['range'] ?? '7d', $filters['start_date'] ?? '', $filters['end_date'] ?? '');
$start = $filters['from'] ?? $range['start'];
$end = $filters['to'] ?? $range['end'];
return wc_get_orders([
'limit' => -1,
'status' => ['completed', 'processing'],
'date_created' => $start . '...' . $end,
'return' => 'objects',
]);
}
private function parseIdList($value) {
$ids = [];
foreach (explode(',', (string) $value) as $id) {
$id = absint(trim($id));
if ($id > 0) {
$ids[] = $id;
}
}
return array_values(array_unique($ids));
}
private function getRuleName($ruleId) {
$rule = $this->ruleRepository->getById((int) $ruleId);
return $rule ? $rule->name : sprintf(__('قانون #%d', 'sodino'), (int) $ruleId);
}
private function getUpsellTitle($upsellId) {
$repository = new \Sodino\Repositories\UpsellRepository();
$upsell = $repository->getById((int) $upsellId);
return $upsell ? $upsell->title : sprintf(__('آپسل #%d', 'sodino'), (int) $upsellId);
}
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;
}
}