420 خطوط
21 KiB
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 'تماس بعدی پیشنهادی را از صف تماس بردارید.';
|
|
}
|
|
}
|