397 خطوط
15 KiB
PHP
397 خطوط
15 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\Lead;
|
|
use App\Models\PipelineStage;
|
|
use App\Models\Setting;
|
|
use App\Models\User;
|
|
use App\Services\ActivityLogger;
|
|
use App\Services\LeadService;
|
|
use App\Support\AccessControl;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Gate;
|
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
|
|
|
class LeadController extends Controller
|
|
{
|
|
public function __construct(private LeadService $leadService) {}
|
|
|
|
public function index(Request $request): JsonResponse
|
|
{
|
|
$user = auth()->user();
|
|
Gate::authorize('viewAny', Lead::class);
|
|
|
|
$filters = $request->only([
|
|
'search', 'status_id', 'pipeline_stage_id', 'assigned_to',
|
|
'status', 'stage', 'agent',
|
|
'team_id', 'campaign_id', 'source', 'priority',
|
|
'call_result', 'interest_level', 'overdue_follow_up',
|
|
'is_unassigned', 'date_from', 'date_to', 'has_follow_up',
|
|
'sort_field', 'sort_direction', 'sort', 'direction', 'per_page',
|
|
]);
|
|
|
|
if ($user->hasRole('agent')) {
|
|
$filters['assigned_to'] = $user->id;
|
|
} elseif ($user->hasRole('supervisor')) {
|
|
$teamId = $user->teams->first()?->id;
|
|
if ($teamId) {
|
|
$filters['team_id'] = $teamId;
|
|
}
|
|
}
|
|
|
|
$leads = $this->leadService->getFilteredLeads($filters);
|
|
|
|
return response()->json($leads);
|
|
}
|
|
|
|
public function funnel(Request $request): JsonResponse
|
|
{
|
|
$user = auth()->user();
|
|
Gate::authorize('viewAny', Lead::class);
|
|
|
|
$filters = $request->only([
|
|
'search', 'status_id', 'pipeline_stage_id', 'assigned_to',
|
|
'status', 'stage', 'agent',
|
|
'team_id', 'campaign_id', 'source', 'priority',
|
|
'call_result', 'interest_level', 'overdue_follow_up',
|
|
'is_unassigned', 'date_from', 'date_to', 'has_follow_up',
|
|
'sort_field', 'sort_direction', 'sort', 'direction',
|
|
]);
|
|
|
|
if ($user->hasRole('agent')) {
|
|
$filters['assigned_to'] = $user->id;
|
|
} elseif ($user->hasRole('supervisor')) {
|
|
$teamId = $user->teams->first()?->id;
|
|
if ($teamId) {
|
|
$filters['team_id'] = $teamId;
|
|
}
|
|
}
|
|
|
|
return response()->json($this->leadService->getGroupedFunnel($filters, (int) $request->get('per_stage', 25)));
|
|
}
|
|
|
|
public function export(Request $request): StreamedResponse|JsonResponse
|
|
{
|
|
$user = auth()->user();
|
|
if (!$user?->hasRole('admin') && !$user?->can('export_leads')) {
|
|
return response()->json(['message' => 'شما مجوز خروجی گرفتن از لیدها را ندارید.'], 403);
|
|
}
|
|
|
|
$filters = $request->only([
|
|
'search', 'status_id', 'pipeline_stage_id', 'assigned_to',
|
|
'status', 'stage', 'agent',
|
|
'team_id', 'campaign_id', 'source', 'priority',
|
|
'call_result', 'interest_level', 'overdue_follow_up',
|
|
'is_unassigned', 'date_from', 'date_to', 'has_follow_up',
|
|
'sort_field', 'sort_direction', 'sort', 'direction',
|
|
]);
|
|
|
|
if ($user->hasRole('agent')) {
|
|
$filters['assigned_to'] = $user->id;
|
|
} elseif ($user->hasRole('supervisor')) {
|
|
$teamId = $user->teams->first()?->id;
|
|
if ($teamId) {
|
|
$filters['team_id'] = $teamId;
|
|
}
|
|
}
|
|
|
|
$exportPhoneMode = Setting::where('key', 'export_phone_mode')->value('value') ?: 'permission_based';
|
|
$canViewFullPhone = $exportPhoneMode !== 'masked_only' && ($user->hasRole('admin') || $user->can('view_full_phone'));
|
|
$query = $this->leadService->getFilteredLeadQuery($filters);
|
|
|
|
ActivityLogger::log('leads_exported', 'Lead export requested');
|
|
|
|
return response()->streamDownload(function () use ($query, $canViewFullPhone) {
|
|
$handle = fopen('php://output', 'w');
|
|
fputcsv($handle, ['id', 'company', 'first_name', 'last_name', 'phone', 'email', 'city', 'source', 'stage', 'status', 'campaign', 'assigned_to', 'created_at']);
|
|
|
|
$query->chunk(500, function ($leads) use ($handle, $canViewFullPhone) {
|
|
foreach ($leads as $lead) {
|
|
fputcsv($handle, [
|
|
$lead->id,
|
|
$lead->company,
|
|
$lead->first_name,
|
|
$lead->last_name,
|
|
$canViewFullPhone ? $lead->phone : $lead->masked_phone,
|
|
$lead->email,
|
|
$lead->city,
|
|
$lead->source,
|
|
$lead->pipelineStage?->name,
|
|
$lead->leadStatus?->name,
|
|
$lead->campaign?->name,
|
|
$lead->assignedAgent?->name,
|
|
optional($lead->created_at)->toDateTimeString(),
|
|
]);
|
|
}
|
|
});
|
|
|
|
fclose($handle);
|
|
}, 'leads-export.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
|
|
}
|
|
|
|
public function store(Request $request): JsonResponse
|
|
{
|
|
Gate::authorize('create', Lead::class);
|
|
|
|
$validated = $request->validate([
|
|
'company' => 'required|string|max:255',
|
|
'first_name' => 'nullable|string|max:255',
|
|
'last_name' => 'nullable|string|max:255',
|
|
'phone' => 'required|string|max:20',
|
|
'phone_secondary' => 'nullable|string|max:20',
|
|
'email' => 'nullable|email|max:255',
|
|
'city' => 'nullable|string|max:255',
|
|
'province' => 'nullable|string|max:255',
|
|
'source' => 'nullable|string|max:255',
|
|
'product_interest' => 'nullable|string|max:255',
|
|
'priority' => 'nullable|integer|min:0|max:10',
|
|
'lead_score' => 'nullable|integer|min:0|max:100',
|
|
'interest_level' => 'nullable|in:cold,warm,hot',
|
|
'last_call_result' => 'nullable|string|max:50',
|
|
'notes' => 'nullable|string',
|
|
'tags' => 'nullable|string',
|
|
'lead_status_id' => 'nullable|exists:lead_statuses,id',
|
|
'pipeline_stage_id' => 'nullable|exists:pipeline_stages,id',
|
|
'campaign_id' => 'nullable|exists:campaigns,id',
|
|
'assigned_to' => 'nullable|exists:users,id',
|
|
]);
|
|
|
|
$user = auth()->user();
|
|
$duplicatePolicy = Setting::where('key', 'duplicate_phone_policy')->value('value') ?? 'warn';
|
|
$duplicateLead = Lead::where('phone', $validated['phone'])->first();
|
|
if ($duplicateLead && $duplicatePolicy === 'block') {
|
|
return response()->json(['message' => 'شماره تلفن تکراری است', 'duplicate_lead_id' => $duplicateLead->id], 422);
|
|
}
|
|
|
|
if ($user?->hasRole('agent')) {
|
|
$validated['assigned_to'] = $user->id;
|
|
} elseif ($user?->hasRole('supervisor') && !empty($validated['assigned_to'])) {
|
|
$assignee = User::find($validated['assigned_to']);
|
|
$teamIds = AccessControl::teamIds($user);
|
|
$assigneeTeamIds = $assignee?->teams()->pluck('teams.id')->all() ?? [];
|
|
if (!array_intersect($teamIds, $assigneeTeamIds)) {
|
|
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
|
|
}
|
|
}
|
|
|
|
$validated['first_name'] = $validated['first_name'] ?: $validated['company'];
|
|
$validated['last_name'] = $validated['last_name'] ?: 'رابط';
|
|
$validated['pipeline_stage_id'] = $validated['pipeline_stage_id']
|
|
?? PipelineStage::where('is_default', true)->value('id')
|
|
?? PipelineStage::orderBy('sort_order')->value('id');
|
|
$assignedTo = $validated['assigned_to'] ?? null;
|
|
if (!$assignedTo) {
|
|
$strategy = Setting::where('key', 'assignment_strategy')->value('value') ?? 'round_robin';
|
|
if ($strategy !== 'manual' || auth()->user()?->hasRole('admin')) {
|
|
$assignedTo = User::role('agent')
|
|
->withCount('assignedLeads')
|
|
->orderBy('assigned_leads_count')
|
|
->value('id');
|
|
}
|
|
}
|
|
|
|
if ($assignedTo) {
|
|
$validated['assigned_to'] = $assignedTo;
|
|
$assignee = User::with('teams')->find($assignedTo);
|
|
$validated['assigned_by'] = auth()->id();
|
|
$validated['team_id'] = $assignee?->teams->first()?->id;
|
|
$validated['is_unassigned'] = false;
|
|
$validated['pipeline_stage_id'] = $request->filled('pipeline_stage_id')
|
|
? $validated['pipeline_stage_id']
|
|
: (PipelineStage::where('slug', 'waiting_call')->value('id') ?? $validated['pipeline_stage_id']);
|
|
}
|
|
|
|
$lead = Lead::create($validated);
|
|
|
|
$contact = \App\Models\Contact::create([
|
|
'lead_id' => $lead->id,
|
|
'name' => trim(($lead->first_name ?? '') . ' ' . ($lead->last_name ?? '')) ?: ($lead->company ?? 'مخاطب اولیه'),
|
|
'role' => 'رابط',
|
|
'description' => 'مخاطب اولیه لید',
|
|
'status' => 'active',
|
|
'is_primary' => true,
|
|
'primary_reason' => 'مخاطب اولیه لید',
|
|
'created_by' => auth()->id(),
|
|
]);
|
|
|
|
foreach (array_filter([$validated['phone'] ?? null, $validated['phone_secondary'] ?? null]) as $phone) {
|
|
\App\Models\ContactPhone::create([
|
|
'contact_id' => $contact->id,
|
|
'phone' => $phone,
|
|
'type' => 'mobile',
|
|
'status' => 'active',
|
|
]);
|
|
}
|
|
|
|
if (!empty($validated['assigned_to'])) {
|
|
\App\Models\LeadAssignment::create([
|
|
'lead_id' => $lead->id,
|
|
'user_id' => $validated['assigned_to'],
|
|
'assigned_by' => auth()->id(),
|
|
'method' => 'manual',
|
|
]);
|
|
}
|
|
|
|
ActivityLogger::log('lead_created', "Lead {$lead->full_name} created", $lead);
|
|
|
|
$payload = $lead->load(['leadStatus', 'pipelineStage', 'campaign'])->toArray();
|
|
if ($duplicateLead && in_array($duplicatePolicy, ['warn', 'merge_suggestion'], true)) {
|
|
$payload['duplicate_warning'] = [
|
|
'policy' => $duplicatePolicy,
|
|
'duplicate_lead_id' => $duplicateLead->id,
|
|
];
|
|
}
|
|
|
|
return response()->json($payload, 201);
|
|
}
|
|
|
|
public function show(Lead $lead): JsonResponse
|
|
{
|
|
Gate::authorize('view', $lead);
|
|
|
|
return response()->json(
|
|
$lead->load([
|
|
'leadStatus', 'pipelineStage', 'campaign',
|
|
'assignedAgent', 'team',
|
|
'contacts.phones.lastCaller:id,name',
|
|
'contactRelations.fromContact:id,name,role',
|
|
'contactRelations.toContact:id,name,role',
|
|
'callLogs.contact:id,name,role',
|
|
'callLogs.phone:id,phone,type,status',
|
|
'callLogs.user:id,name',
|
|
'calls' => fn($q) => $q->with('contact:id,name,role', 'contactPhone:id,phone,type,status')->latest(),
|
|
'followUps' => fn($q) => $q->latest(),
|
|
'notes.user:id,name',
|
|
'attachments.uploader:id,name',
|
|
])
|
|
);
|
|
}
|
|
|
|
public function update(Request $request, Lead $lead): JsonResponse
|
|
{
|
|
Gate::authorize('update', $lead);
|
|
|
|
$validated = $request->validate([
|
|
'first_name' => 'sometimes|string|max:255',
|
|
'last_name' => 'sometimes|string|max:255',
|
|
'phone' => 'sometimes|string|max:20',
|
|
'phone_secondary' => 'nullable|string|max:20',
|
|
'email' => 'nullable|email|max:255',
|
|
'company' => 'nullable|string|max:255',
|
|
'city' => 'nullable|string|max:255',
|
|
'province' => 'nullable|string|max:255',
|
|
'source' => 'nullable|string|max:255',
|
|
'product_interest' => 'nullable|string|max:255',
|
|
'priority' => 'nullable|integer|min:0|max:10',
|
|
'lead_score' => 'nullable|integer|min:0|max:100',
|
|
'interest_level' => 'nullable|in:cold,warm,hot',
|
|
'last_call_result' => 'nullable|string|max:50',
|
|
'notes' => 'nullable|string',
|
|
'tags' => 'nullable|string',
|
|
'lead_status_id' => 'nullable|exists:lead_statuses,id',
|
|
'pipeline_stage_id' => 'nullable|exists:pipeline_stages,id',
|
|
]);
|
|
|
|
if (($request->has('lead_status_id') || $request->has('pipeline_stage_id')) && Gate::denies('changeStage', $lead)) {
|
|
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
|
|
}
|
|
|
|
$lead->update($validated);
|
|
|
|
ActivityLogger::log('lead_updated', "Lead {$lead->id} updated", $lead);
|
|
|
|
return response()->json($lead->fresh()->load(['leadStatus', 'pipelineStage', 'campaign', 'assignedAgent']));
|
|
}
|
|
|
|
public function destroy(Lead $lead): JsonResponse
|
|
{
|
|
Gate::authorize('delete', $lead);
|
|
|
|
$lead->delete();
|
|
|
|
ActivityLogger::log('lead_deleted', "Lead {$lead->id} deleted", $lead);
|
|
|
|
return response()->json(['message' => 'لید حذف شد']);
|
|
}
|
|
|
|
public function bulkDestroy(Request $request): JsonResponse
|
|
{
|
|
$validated = $request->validate([
|
|
'ids' => 'required|array|min:1',
|
|
'ids.*' => 'integer|exists:leads,id',
|
|
]);
|
|
|
|
$leads = Lead::whereIn('id', $validated['ids'])->get();
|
|
if ($leads->count() !== count(array_unique($validated['ids'])) || $leads->contains(fn(Lead $lead) => Gate::denies('delete', $lead))) {
|
|
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
|
|
}
|
|
|
|
$deleted = Lead::whereIn('id', $validated['ids'])->delete();
|
|
|
|
ActivityLogger::log('leads_bulk_deleted', "{$deleted} leads deleted");
|
|
|
|
return response()->json([
|
|
'deleted' => $deleted,
|
|
'message' => "{$deleted} لید حذف شد",
|
|
]);
|
|
}
|
|
|
|
public function nextLead(): JsonResponse
|
|
{
|
|
$user = auth()->user();
|
|
|
|
$lead = Lead::where('assigned_to', $user->id)
|
|
->where(function ($q) {
|
|
$q->whereNull('last_call_at')
|
|
->orWhere('next_follow_up_at', '<=', now());
|
|
})
|
|
->orderBy('priority', 'desc')
|
|
->orderBy('created_at', 'asc')
|
|
->first();
|
|
|
|
if (!$lead) {
|
|
$lead = Lead::where('assigned_to', $user->id)
|
|
->orderBy('priority', 'desc')
|
|
->orderBy('last_call_at', 'asc')
|
|
->first();
|
|
}
|
|
|
|
if (!$lead) {
|
|
return response()->json(['message' => 'لیدی برای تماس وجود ندارد'], 404);
|
|
}
|
|
|
|
ActivityLogger::log('lead_viewed', "Lead {$lead->id} viewed as next lead", $lead);
|
|
|
|
return response()->json(
|
|
$lead->load(['leadStatus', 'pipelineStage', 'campaign', 'calls' => fn($q) => $q->latest()->limit(5)])
|
|
);
|
|
}
|
|
|
|
private function canManageLead(Lead $lead): bool
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (!$user) {
|
|
return false;
|
|
}
|
|
|
|
if ($user->hasRole('admin')) {
|
|
return true;
|
|
}
|
|
|
|
if ($user->hasRole('agent')) {
|
|
return $lead->assigned_to === $user->id;
|
|
}
|
|
|
|
if ($user->hasRole('supervisor')) {
|
|
$teamIds = $user->teams->pluck('id')->toArray();
|
|
return in_array($lead->team_id, $teamIds, true) || $lead->assigned_to === $user->id;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|