CRM/backend/app/Services/DashboardService.php

420 خطوط
21 KiB
PHP

<?php
namespace App\Services;
use App\Models\Lead;
use App\Models\Call;
use App\Models\Campaign;
use App\Models\Deal;
use App\Models\FollowUp;
use App\Models\ActivityLog;
use App\Models\ImportBatch;
use App\Models\LeadStatus;
use App\Models\PipelineStage;
use App\Models\CallResult;
use App\Models\QualityReview;
use App\Models\Setting;
use App\Models\User;
use App\Models\Team;
use Carbon\Carbon;
class DashboardService
{
public function admin(): array
{
$today = Carbon::today();
$now = Carbon::now();
return [
'total_leads' => Lead::count(),
'new_leads_today' => Lead::whereDate('created_at', $today)->count(),
'calls_today' => Call::whereDate('created_at', $today)->count(),
'successful_calls_today' => Call::whereDate('created_at', $today)
->whereIn('result', $this->successfulResultNames())
->count(),
'conversions_this_month' => Lead::where('final_result', 'موفق')
->whereMonth('updated_at', $today->month)
->whereYear('updated_at', $today->year)
->count(),
'assigned_leads' => Lead::where('is_unassigned', false)->count(),
'unassigned_leads' => Lead::where('is_unassigned', true)->count(),
'leads_contacted_today' => Lead::whereDate('last_call_at', $today)->count(),
'total_calls_today' => Call::whereDate('created_at', $today)->count(),
'answered_calls' => Call::whereDate('created_at', $today)
->whereIn('result', $this->successfulResultNames())
->count(),
'no_answer_calls' => Call::whereDate('created_at', $today)
->whereIn('result', ['پاسخ نداد', 'اشغال بود', 'خاموش بود'])
->count(),
'average_call_duration' => Call::whereDate('created_at', $today)->avg('duration') ?? 0,
'won_sales' => Lead::where('final_result', 'موفق')->count(),
'lost_sales' => Lead::where('final_result', 'ناموفق')->count(),
'conversion_rate' => $this->calculateConversionRate(),
'follow_up_backlog' => FollowUp::where('status', 'pending')->whereDate('scheduled_at', '<=', $now)->count(),
'overdue_follow_ups' => FollowUp::where('status', 'pending')
->where(fn($q) => $q->where('is_overdue', true)->orWhere('scheduled_at', '<', $now))
->count(),
'overdue_leads' => Lead::whereNotNull('next_follow_up_at')->where('next_follow_up_at', '<', $now)->whereNull('final_result')->count(),
'pipeline_data' => $this->pipelineSummary(),
'funnel_conversion_chart' => $this->funnelConversion(),
'call_trend_chart' => $this->callTrend(),
'source_performance_chart' => $this->sourcePerformance(),
'campaign_performance' => $this->campaignPerformance(),
'team_performance' => $this->teamPerformance(),
'top_agents' => $this->agentPerformance(null, 5),
'low_performance_alerts' => $this->lowPerformanceAlerts(),
'recent_imports' => ImportBatch::with('user:id,name')
->latest()
->limit(5)
->get(['id', 'user_id', 'filename', 'total_rows', 'imported_rows', 'failed_rows', 'skipped_rows', 'status', 'created_at']),
'voip_status' => $this->voipStatus(),
'system_health' => [
'pending_follow_ups' => FollowUp::where('status', 'pending')->count(),
'failed_imports_today' => ImportBatch::whereDate('created_at', $today)->where('failed_rows', '>', 0)->count(),
'open_deals' => class_exists(Deal::class) ? Deal::where('status', 'open')->count() : 0,
],
'leads_by_status' => LeadStatus::withCount('leads')
->orderBy('sort_order')
->get()
->map(fn (LeadStatus $status) => [
'status' => $status->name,
'count' => $status->leads_count,
'color' => $status->color,
])
->values(),
'recent_activities' => ActivityLog::with('user:id,name')
->latest()
->limit(10)
->get(),
];
}
public function supervisor(?int $teamId = null): array
{
$teamId = $teamId ?? auth()->user()->teams->first()?->id;
$agentIds = $teamId ? Team::find($teamId)?->members->pluck('id')->toArray() : [];
$today = Carbon::today();
$now = Carbon::now();
return [
'total_leads' => Lead::whereIn('assigned_to', $agentIds)->count(),
'new_leads_today' => Lead::whereIn('assigned_to', $agentIds)->whereDate('created_at', $today)->count(),
'calls_today' => Call::whereIn('user_id', $agentIds)->whereDate('created_at', $today)->count(),
'conversions_this_month' => Lead::whereIn('assigned_to', $agentIds)->where('final_result', 'موفق')
->whereMonth('updated_at', $today->month)->whereYear('updated_at', $today->year)->count(),
'total_team_leads' => Lead::whereIn('assigned_to', $agentIds)->count(),
'team_calls_today' => Call::whereIn('user_id', $agentIds)->whereDate('created_at', $today)->count(),
'team_successful_calls_today' => Call::whereIn('user_id', $agentIds)->whereDate('created_at', $today)->whereIn('result', $this->successfulResultNames())->count(),
'team_won_sales' => Lead::whereIn('assigned_to', $agentIds)
->where('final_result', 'موفق')
->count(),
'team_conversion_rate' => $this->calculateTeamConversionRate($agentIds),
'online_agents' => User::whereIn('id', $agentIds)
->where('last_login_at', '>=', now()->subMinutes(15))->count(),
'overdue_follow_ups' => FollowUp::whereIn('user_id', $agentIds)
->where('status', 'pending')->where(fn($q) => $q->where('is_overdue', true)->orWhere('scheduled_at', '<', $now))->count(),
'leads_without_calls' => Lead::whereIn('assigned_to', $agentIds)->whereNull('last_call_at')->count(),
'team_stats' => $this->agentPerformance($agentIds),
'leads_stuck_by_stage' => $this->stuckLeadsByStage($agentIds),
'calls_needing_review' => Call::whereIn('user_id', $agentIds)
->whereNotNull('recording_url')
->whereDoesntHave('qualityReview')
->latest()
->limit(10)
->get(['id', 'lead_id', 'user_id', 'result', 'duration', 'created_at']),
'quality_review_summary' => $this->qualitySummary($agentIds),
'agents_behind_target' => $this->agentsBehindTarget($agentIds),
'hot_leads' => Lead::with('assignedAgent:id,name')
->whereIn('assigned_to', $agentIds)
->where(fn($q) => $q->where('interest_level', 'hot')->orWhere('priority', '>=', 8))
->latest()
->limit(10)
->get(['id', 'company', 'first_name', 'last_name', 'interest_level', 'priority', 'assigned_to', 'next_follow_up_at']),
'important_team_alerts' => $this->teamAlerts($agentIds),
];
}
public function agent(?int $userId = null): array
{
$userId = $userId ?? auth()->id();
$today = Carbon::today();
return [
'my_leads' => Lead::where('assigned_to', $userId)->count(),
'my_calls_today' => Call::where('user_id', $userId)->whereDate('created_at', $today)->count(),
'my_follow_ups_today' => FollowUp::where('user_id', $userId)->whereDate('scheduled_at', $today)->where('status', 'pending')->count(),
'today_follow_ups' => FollowUp::where('user_id', $userId)
->whereDate('scheduled_at', $today)->where('status', 'pending')->count(),
'overdue_follow_ups' => FollowUp::where('user_id', $userId)
->where('status', 'pending')->where(fn($q) => $q->where('is_overdue', true)->orWhere('scheduled_at', '<', Carbon::now()))->count(),
'calls_today' => Call::where('user_id', $userId)->whereDate('created_at', $today)->count(),
'answered_calls_today' => Call::where('user_id', $userId)->whereDate('created_at', $today)
->whereIn('result', $this->successfulResultNames())->count(),
'won_sales' => Lead::where('assigned_to', $userId)
->where('final_result', 'موفق')
->count(),
'conversion_rate' => $this->calculateAgentConversionRate($userId),
'daily_call_target' => $this->intSetting('daily_call_target', 40),
'successful_call_target' => $this->intSetting('successful_call_target', 15),
'follow_up_target' => $this->intSetting('follow_up_target', 20),
'progress' => [
'calls' => $this->progress(Call::where('user_id', $userId)->whereDate('created_at', $today)->count(), $this->intSetting('daily_call_target', 40)),
'successful_calls' => $this->progress(Call::where('user_id', $userId)->whereDate('created_at', $today)->whereIn('result', $this->successfulResultNames())->count(), $this->intSetting('successful_call_target', 15)),
'follow_ups' => $this->progress(FollowUp::where('user_id', $userId)->whereDate('scheduled_at', $today)->count(), $this->intSetting('follow_up_target', 20)),
],
'next_call' => Lead::where('assigned_to', $userId)
->whereNull('final_result')
->orderByRaw('CASE WHEN next_follow_up_at IS NOT NULL AND next_follow_up_at <= ? THEN 0 ELSE 1 END', [Carbon::now()])
->orderByDesc('priority')
->oldest('last_call_at')
->first(['id', 'company', 'first_name', 'last_name', 'priority', 'last_call_result', 'next_follow_up_at']),
'hot_leads' => Lead::where('assigned_to', $userId)
->where(fn($q) => $q->where('interest_level', 'hot')->orWhere('priority', '>=', 8))
->latest()
->limit(5)
->get(['id', 'company', 'first_name', 'last_name', 'interest_level', 'priority', 'next_follow_up_at']),
'urgent_tasks' => FollowUp::with('lead:id,company,first_name,last_name')
->where('user_id', $userId)
->where('status', 'pending')
->where('scheduled_at', '<=', Carbon::now()->addDay())
->orderBy('scheduled_at')
->limit(8)
->get(['id', 'lead_id', 'scheduled_at', 'notes', 'status']),
'suggested_next_action' => $this->suggestedNextAction($userId),
];
}
private function calculateConversionRate(): float
{
$total = Lead::count();
if ($total === 0) return 0;
$won = Lead::where('final_result', 'موفق')->count();
return round(($won / $total) * 100, 1);
}
private function pipelineSummary()
{
return PipelineStage::query()
->where('is_active', true)
->orderBy('sort_order')
->get()
->groupBy('name')
->map(function ($stages, string $name) {
$ids = $stages->pluck('id');
$first = $stages->first();
return [
'stage' => $name,
'count' => Lead::whereIn('pipeline_stage_id', $ids)->count(),
'color' => $first->color,
];
})
->values();
}
private function calculateTeamConversionRate(array $agentIds): float
{
$total = Lead::whereIn('assigned_to', $agentIds)->count();
if ($total === 0) return 0;
$won = Lead::whereIn('assigned_to', $agentIds)
->where('final_result', 'موفق')
->count();
return round(($won / $total) * 100, 1);
}
private function calculateAgentConversionRate(int $userId): float
{
$total = Lead::where('assigned_to', $userId)->count();
if ($total === 0) return 0;
$won = Lead::where('assigned_to', $userId)
->where('final_result', 'موفق')
->count();
return round(($won / $total) * 100, 1);
}
private function successfulResultNames(): array
{
return CallResult::where('is_positive', true)->pluck('name')->all();
}
private function intSetting(string $key, int $fallback): int
{
return (int) (Setting::where('key', $key)->value('value') ?: $fallback);
}
private function progress(int $value, int $target): array
{
return ['value' => $value, 'target' => $target, 'percent' => $target > 0 ? min(100, round(($value / $target) * 100, 1)) : 0];
}
private function callTrend(?array $agentIds = null): array
{
return collect(range(6, 0))->map(function (int $daysAgo) use ($agentIds) {
$date = Carbon::today()->subDays($daysAgo);
$query = Call::whereDate('created_at', $date);
if (is_array($agentIds)) $query->whereIn('user_id', $agentIds);
return [
'date' => $date->toDateString(),
'calls' => (clone $query)->count(),
'successful' => (clone $query)->whereIn('result', $this->successfulResultNames())->count(),
];
})->values()->all();
}
private function sourcePerformance(?array $agentIds = null): array
{
$query = Lead::query();
if (is_array($agentIds)) $query->whereIn('assigned_to', $agentIds);
return $query->selectRaw("COALESCE(source, 'نامشخص') as source, count(*) as total, sum(case when final_result = 'موفق' then 1 else 0 end) as won")
->groupBy('source')
->orderByDesc('total')
->limit(10)
->get()
->map(fn($row) => [
'source' => $row->source,
'total' => (int) $row->total,
'won' => (int) $row->won,
'conversion_rate' => $row->total > 0 ? round(($row->won / $row->total) * 100, 1) : 0,
])->all();
}
private function campaignPerformance(): array
{
return Campaign::withCount('leads')
->latest()
->limit(8)
->get()
->map(fn(Campaign $campaign) => [
'id' => $campaign->id,
'name' => $campaign->name,
'target' => $campaign->target,
'leads' => $campaign->leads_count,
'won' => Lead::where('campaign_id', $campaign->id)->where('final_result', 'موفق')->count(),
])->values()->all();
}
private function teamPerformance(): array
{
return Team::with('members:id,name')->get()->map(function (Team $team) {
$agentIds = $team->members->pluck('id')->all();
return [
'id' => $team->id,
'name' => $team->name,
'agents' => count($agentIds),
'leads' => Lead::whereIn('assigned_to', $agentIds)->count(),
'calls_today' => Call::whereIn('user_id', $agentIds)->whereDate('created_at', Carbon::today())->count(),
'conversion_rate' => $this->calculateTeamConversionRate($agentIds),
];
})->values()->all();
}
private function agentPerformance(?array $agentIds = null, ?int $limit = null): array
{
$users = User::role('agent')->when(is_array($agentIds), fn($q) => $q->whereIn('id', $agentIds))->orderBy('name')->get();
$rows = $users->map(function (User $agent) {
$todayCalls = Call::where('user_id', $agent->id)->whereDate('created_at', Carbon::today())->count();
$successful = Call::where('user_id', $agent->id)->whereDate('created_at', Carbon::today())->whereIn('result', $this->successfulResultNames())->count();
return [
'agent_id' => $agent->id,
'agent_name' => $agent->name,
'leads' => Lead::where('assigned_to', $agent->id)->count(),
'calls' => $todayCalls,
'successful_calls' => $successful,
'conversions' => Lead::where('assigned_to', $agent->id)->where('final_result', 'موفق')->count(),
'call_target_percent' => $this->progress($todayCalls, $this->intSetting('daily_call_target', 40))['percent'],
];
})->sortByDesc('calls')->values();
return $limit ? $rows->take($limit)->values()->all() : $rows->all();
}
private function lowPerformanceAlerts(): array
{
return collect($this->agentPerformance())->filter(fn($row) => $row['call_target_percent'] < 50)
->map(fn($row) => ['agent_id' => $row['agent_id'], 'agent_name' => $row['agent_name'], 'message' => 'کمتر از ۵۰٪ هدف تماس امروز انجام شده است.'])
->values()
->all();
}
private function stuckLeadsByStage(array $agentIds): array
{
$threshold = Carbon::now()->subDays(7);
return PipelineStage::orderBy('sort_order')->get()->map(fn(PipelineStage $stage) => [
'stage' => $stage->name,
'count' => Lead::whereIn('assigned_to', $agentIds)->where('pipeline_stage_id', $stage->id)->where('updated_at', '<=', $threshold)->whereNull('final_result')->count(),
])->values()->all();
}
private function qualitySummary(array $agentIds): array
{
$query = QualityReview::whereIn('agent_id', $agentIds);
return [
'reviewed_calls' => (clone $query)->count(),
'average_score' => round((float) ((clone $query)->avg('overall_score') ?? 0), 1),
'low_score_count' => (clone $query)->where('overall_score', '<', 60)->count(),
];
}
private function agentsBehindTarget(array $agentIds): array
{
return collect($this->agentPerformance($agentIds))
->filter(fn($row) => $row['call_target_percent'] < 80)
->values()
->all();
}
private function teamAlerts(array $agentIds): array
{
$now = Carbon::now();
return [
['label' => 'پیگیری عقب‌افتاده', 'count' => FollowUp::whereIn('user_id', $agentIds)->where('status', 'pending')->where('scheduled_at', '<', $now)->count()],
['label' => 'لید بدون تماس', 'count' => Lead::whereIn('assigned_to', $agentIds)->whereNull('last_call_at')->count()],
['label' => 'تماس نیازمند بازبینی', 'count' => Call::whereIn('user_id', $agentIds)->whereNotNull('recording_url')->whereDoesntHave('qualityReview')->count()],
];
}
private function voipStatus(): array
{
$provider = Setting::where('key', 'voip_provider')->value('value') ?: 'none';
$configured = match ($provider) {
'ami' => (bool) Setting::where('key', 'voip_ami_host')->value('value')
&& (bool) Setting::where('key', 'voip_ami_port')->value('value')
&& (bool) Setting::where('key', 'voip_ami_username')->value('value')
&& (bool) Setting::where('key', 'voip_ami_secret')->value('value'),
'api' => (bool) Setting::where('key', 'voip_api_base_url')->value('value')
&& (bool) Setting::where('key', 'voip_api_token')->value('value'),
'socket' => (bool) Setting::where('key', 'voip_socket_host')->value('value')
&& (bool) Setting::where('key', 'voip_socket_port')->value('value'),
'mock' => true,
default => false,
};
return [
'provider' => $provider,
'configured' => $configured,
'message' => $configured ? 'تنظیمات اصلی تلفن اینترنتی ثبت شده است.' : 'تلفن اینترنتی کامل پیکربندی نشده است.',
];
}
private function funnelConversion(): array
{
$total = max(Lead::count(), 1);
return collect($this->pipelineSummary())->map(fn($row) => [
'stage' => $row['stage'],
'count' => $row['count'],
'percentage' => round(($row['count'] / $total) * 100, 1),
'color' => $row['color'],
])->all();
}
private function suggestedNextAction(int $userId): string
{
if (FollowUp::where('user_id', $userId)->where('status', 'pending')->where('scheduled_at', '<', Carbon::now())->exists()) {
return 'ابتدا پیگیری‌های عقب‌افتاده را انجام دهید.';
}
if (Lead::where('assigned_to', $userId)->whereNull('last_call_at')->exists()) {
return 'با لیدهای بدون تماس شروع کنید.';
}
return 'تماس بعدی پیشنهادی را از صف تماس بردارید.';
}
}