CRM/backend/app/Http/Controllers/Api/LeadController.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;
}
}