CRM/backend/app/Http/Controllers/Api/AgentMobileController.php

596 خطوط
25 KiB
PHP

<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Call;
use App\Models\CallResult;
use App\Models\Contact;
use App\Models\ContactPhone;
use App\Models\ContactRelation;
use App\Models\FollowUp;
use App\Models\Lead;
use App\Services\ActivityLogger;
use App\Services\CallService;
use Illuminate\Support\Facades\DB;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class AgentMobileController extends Controller
{
public function __construct(private CallService $callService) {}
public function bootstrap(Request $request): JsonResponse
{
return response()->json([
'data' => [
'preferred_view' => $request->user()->sales_agent_preferred_view ?? 'auto',
'call_results' => CallResult::where('is_active', true)
->orderBy('sort_order')
->get(['name', 'slug', 'requires_follow_up', 'is_final'])
->map(fn(CallResult $result) => [
'name' => $result->name,
'slug' => $result->slug,
'requires_follow_up' => $result->requires_follow_up,
'is_final' => $result->is_final,
])
->values(),
],
]);
}
public function dashboard(Request $request): JsonResponse
{
$user = $request->user();
$today = now()->toDateString();
$leadQuery = $this->assignedLeadQuery($user->id);
$callQuery = Call::where('user_id', $user->id)->whereDate('created_at', $today);
$followUpQuery = FollowUp::where('user_id', $user->id);
$nextLead = $this->queueQuery($user->id)->first();
return response()->json([
'data' => [
'stats' => [
'today_calls_count' => (clone $callQuery)->count(),
'today_follow_ups_count' => (clone $followUpQuery)->whereDate('scheduled_at', $today)->where('status', 'pending')->count(),
'overdue_follow_ups_count' => (clone $followUpQuery)->where('status', 'pending')->where('scheduled_at', '<', now())->count(),
'new_assigned_leads_count' => (clone $leadQuery)->whereDate('created_at', $today)->count(),
'successful_calls_today' => (clone $callQuery)->whereIn('result', $this->successfulResults())->count(),
'failed_calls_today' => (clone $callQuery)->whereNotNull('result')->whereNotIn('result', $this->successfulResults())->count(),
'leads_needing_action' => $this->queueQuery($user->id)->count(),
],
'suggested_call' => $nextLead ? $this->leadCard($nextLead) : null,
],
]);
}
public function callQueue(Request $request): JsonResponse
{
$query = $this->queueQuery($request->user()->id);
$this->applyMobileFilter($query, $request->query('filter'));
return response()->json([
'data' => $query->limit(30)->get()->map(fn(Lead $lead) => $this->leadCard($lead))->values(),
]);
}
public function leads(Request $request): JsonResponse
{
$query = $this->assignedLeadQuery($request->user()->id);
$this->applyMobileFilter($query, $request->query('filter'));
if ($request->query('filter') === 'interested') {
$query->where(function ($q) {
$q->where('interest_level', 'hot')
->orWhere('last_call_result', 'علاقه‌مند بود')
->orWhereHas('pipelineStage', fn($stage) => $stage->where('name', 'like', '%علاقه‌مند%'));
});
}
if ($request->query('filter') === 'proposal') {
$query->whereHas('pipelineStage', fn($stage) => $stage->where('name', 'like', '%پیشنهاد%'));
}
if ($request->query('filter') === 'closed') {
$query->whereNotNull('final_result');
}
return response()->json([
'data' => $query
->orderByDesc('priority')
->orderByRaw('next_follow_up_at IS NULL')
->orderBy('next_follow_up_at')
->limit(40)
->get()
->map(fn(Lead $lead) => $this->leadCard($lead))
->values(),
]);
}
public function lead(Request $request, Lead $lead): JsonResponse
{
if ($lead->assigned_to !== $request->user()->id) {
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
}
$lead->load([
'leadStatus:id,name,color',
'pipelineStage:id,name,color',
'contacts.phones:id,contact_id,type,status,call_count,successful_call_count,failed_call_count,last_called_at,last_call_result',
'contactRelations.fromContact:id,name,role',
'contactRelations.toContact:id,name,role',
'calls' => fn($q) => $q->with('contact:id,name,role')->latest()->limit(12),
'callLogs' => fn($q) => $q->with('contact:id,name,role')->latest('called_at')->limit(12),
'followUps' => fn($q) => $q->latest('scheduled_at')->limit(8),
]);
$primary = $this->primaryContact($lead);
return response()->json([
'data' => [
'card' => $this->leadCard($lead),
'primary_contact' => $primary ? $this->contactSummary($primary) : null,
'summary' => [
'id' => $lead->id,
'name' => $this->leadName($lead),
'company' => $lead->company,
'city' => $lead->city,
'province' => $lead->province,
'source' => $lead->source,
'interest_level' => $lead->interest_level,
'priority' => $lead->priority,
'score' => $lead->lead_score,
'status' => $lead->leadStatus?->name,
'stage' => $lead->pipelineStage?->name,
'notes' => $lead->notes,
'tags' => $this->tags($lead),
'final_result' => $lead->final_result,
],
'calls' => $lead->calls->map(fn(Call $call) => [
'id' => $call->id,
'contact_name' => $call->contact?->name,
'result' => $call->result,
'notes' => $call->notes,
'created_at' => optional($call->created_at)->toIso8601String(),
])->values(),
'contacts' => $lead->contacts->map(fn($contact) => $this->contactSummary($contact))->values(),
'notes' => array_values(array_filter([
$lead->notes ? ['id' => 'lead-notes', 'text' => $lead->notes, 'created_at' => optional($lead->updated_at)->toIso8601String()] : null,
$lead->customer_notes ? ['id' => 'customer-notes', 'text' => $lead->customer_notes, 'created_at' => optional($lead->updated_at)->toIso8601String()] : null,
])),
'files' => [],
'contact_routes' => $lead->contactRelations->map(fn($relation) => [
'id' => $relation->id,
'from' => $relation->fromContact?->name,
'to' => $relation->toContact?->name,
'type' => $relation->relation_type,
'description' => $relation->description,
])->values(),
],
]);
}
public function followUps(Request $request): JsonResponse
{
$items = FollowUp::with([
'lead:id,first_name,last_name,company,lead_status_id,pipeline_stage_id,last_call_result,next_follow_up_at,assigned_to',
'lead.leadStatus:id,name,color',
'lead.pipelineStage:id,name,color',
'lead.contacts.phones:id,contact_id,type,status,call_count,last_call_result,last_called_at',
])
->where('user_id', $request->user()->id)
->where('scheduled_at', '<=', now()->addWeek())
->orderBy('scheduled_at')
->limit(80)
->get()
->map(fn(FollowUp $followUp) => $this->followUpCard($followUp));
return response()->json([
'data' => [
'overdue' => $items->where('group', 'overdue')->values(),
'today' => $items->where('group', 'today')->values(),
'tomorrow' => $items->where('group', 'tomorrow')->values(),
'week' => $items->where('group', 'week')->values(),
'done' => $items->where('group', 'done')->values(),
],
]);
}
public function preferredView(Request $request): JsonResponse
{
$validated = $request->validate([
'preferred_view' => ['required', Rule::in(['auto', 'mobile', 'desktop'])],
]);
$user = $request->user();
$user->forceFill([
'sales_agent_preferred_view' => $validated['preferred_view'],
])->save();
ActivityLogger::log('agent_mobile_preferred_view_updated', "User {$user->phone} updated Sales Agent preferred view");
$user->load('roles.permissions', 'teams');
$user->permissions = $user->getAllPermissions()->pluck('name');
return response()->json(['data' => $user]);
}
public function startCall(Request $request): JsonResponse
{
$validated = $request->validate([
'lead_id' => 'required|exists:leads,id',
'contact_phone_id' => 'nullable|exists:contact_phones,id',
]);
$lead = Lead::findOrFail($validated['lead_id']);
if (!$this->canAccessLead($request, $lead)) {
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
}
if (!empty($validated['contact_phone_id'])) {
$belongsToLead = ContactPhone::where('id', $validated['contact_phone_id'])
->whereHas('contact', fn($query) => $query->where('lead_id', $lead->id))
->exists();
if (!$belongsToLead) {
return response()->json(['message' => 'شماره انتخاب‌شده برای این لید معتبر نیست'], 422);
}
}
$payload = $this->callService->initiateCall(
$lead->id,
$request->user()->id,
$validated['contact_phone_id'] ?? null,
false
);
unset($payload['recording_url'], $payload['provider_call_id']);
return response()->json(['data' => $payload], 201);
}
public function callResult(Request $request): JsonResponse
{
$validated = $request->validate([
'call_id' => 'required|exists:calls,id',
'result' => ['required', Rule::in($this->mobileCallResults())],
'notes' => 'nullable|string|max:2000',
'next_follow_up_at' => 'nullable|date',
'mark_phone_wrong' => 'nullable|boolean',
'disinterest_reason' => 'nullable|string|max:500',
'close_lead' => 'nullable|boolean',
'referral' => 'nullable|array',
'referral.name' => 'required_if:result,معرفی شماره جدید|string|max:255',
'referral.phone' => 'required_if:result,معرفی شماره جدید|string|max:30',
'referral.phone_type' => 'nullable|string|in:mobile,landline,extension',
'referral.role' => 'nullable|string|max:80',
'referral.relation_description' => 'nullable|string|max:255',
'referral.description' => 'nullable|string|max:1000',
'referral.make_primary' => 'nullable|boolean',
'referral.next_follow_up_at' => 'nullable|date',
]);
$call = Call::with('lead')->findOrFail($validated['call_id']);
if ($call->user_id !== $request->user()->id || !$call->lead || !$this->canAccessLead($request, $call->lead)) {
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
}
$followUpAt = $validated['next_follow_up_at'] ?? $validated['referral']['next_follow_up_at'] ?? null;
$callResult = CallResult::where('name', $validated['result'])->first();
if ($callResult?->requires_follow_up && !$followUpAt) {
return response()->json(['message' => 'برای این نتیجه تماس، زمان پیگیری الزامی است'], 422);
}
$registeredCall = $this->callService->registerResult(
$call->id,
$validated['result'],
$validated['notes'] ?? null,
$followUpAt,
$validated['referral'] ?? null,
[
'mark_phone_wrong' => (bool) ($validated['mark_phone_wrong'] ?? false),
'disinterest_reason' => $validated['disinterest_reason'] ?? null,
'close_lead' => (bool) ($validated['close_lead'] ?? false),
]
);
return response()->json([
'message' => 'نتیجه تماس ثبت شد',
'data' => [
'call_id' => $registeredCall->id,
'lead_id' => $registeredCall->lead_id,
'result' => $registeredCall->result,
'next_call' => optional($this->queueQuery($request->user()->id)->where('id', '!=', $registeredCall->lead_id)->first(), fn($lead) => $this->leadCard($lead)),
],
]);
}
public function storeFollowUp(Request $request): JsonResponse
{
$validated = $request->validate([
'lead_id' => 'required|exists:leads,id',
'call_id' => 'nullable|exists:calls,id',
'scheduled_at' => 'required|date',
'notes' => 'nullable|string|max:2000',
]);
$lead = Lead::findOrFail($validated['lead_id']);
if (!$this->canAccessLead($request, $lead)) {
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
}
if (!empty($validated['call_id']) && !Call::where('id', $validated['call_id'])->where('user_id', $request->user()->id)->where('lead_id', $lead->id)->exists()) {
return response()->json(['message' => 'تماس انتخاب‌شده معتبر نیست'], 422);
}
$followUp = FollowUp::create([
'lead_id' => $lead->id,
'user_id' => $request->user()->id,
'call_id' => $validated['call_id'] ?? null,
'scheduled_at' => $validated['scheduled_at'],
'status' => 'pending',
'notes' => $validated['notes'] ?? null,
]);
$lead->update(['next_follow_up_at' => $validated['scheduled_at']]);
ActivityLogger::log('agent_mobile_follow_up_created', "Mobile follow-up created for lead {$lead->id}", $followUp);
return response()->json(['data' => $this->followUpCard($followUp->load('lead.contacts.phones'))], 201);
}
public function newContact(Request $request): JsonResponse
{
$validated = $request->validate([
'lead_id' => 'required|exists:leads,id',
'from_contact_id' => 'nullable|exists:contacts,id',
'name' => 'required|string|max:255',
'phone' => 'required|string|max:30',
'phone_type' => 'nullable|string|in:mobile,landline,extension',
'role' => 'nullable|string|max:80',
'relation_description' => 'nullable|string|max:255',
'description' => 'nullable|string|max:1000',
'make_primary' => 'nullable|boolean',
]);
$lead = Lead::findOrFail($validated['lead_id']);
if (!$this->canAccessLead($request, $lead)) {
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
}
if (!empty($validated['from_contact_id']) && !Contact::where('id', $validated['from_contact_id'])->where('lead_id', $lead->id)->exists()) {
return response()->json(['message' => 'مخاطب معرف برای این لید معتبر نیست'], 422);
}
$contact = DB::transaction(function () use ($validated, $lead, $request) {
if ($validated['make_primary'] ?? false) {
Contact::where('lead_id', $lead->id)->update(['is_primary' => false]);
}
$contact = Contact::create([
'lead_id' => $lead->id,
'name' => $validated['name'],
'role' => $validated['role'] ?? null,
'description' => $validated['description'] ?? null,
'status' => 'active',
'is_primary' => (bool) ($validated['make_primary'] ?? false),
'primary_reason' => ($validated['make_primary'] ?? false) ? 'ثبت‌شده از نسخه موبایل' : null,
'created_by' => $request->user()->id,
]);
ContactPhone::create([
'contact_id' => $contact->id,
'phone' => $validated['phone'],
'type' => $validated['phone_type'] ?? 'mobile',
'status' => 'active',
]);
ContactRelation::create([
'lead_id' => $lead->id,
'from_contact_id' => $validated['from_contact_id'] ?? null,
'to_contact_id' => $contact->id,
'relation_type' => 'introduced',
'description' => $validated['relation_description'] ?? null,
'created_by' => $request->user()->id,
]);
return $contact->load('phones');
});
ActivityLogger::log('agent_mobile_contact_created', "Mobile contact {$contact->name} created for lead {$lead->id}", $contact);
return response()->json(['data' => $this->contactSummary($contact)], 201);
}
private function assignedLeadQuery(int $userId)
{
return Lead::query()
->with([
'leadStatus:id,name,color',
'pipelineStage:id,name,color',
'contacts.phones:id,contact_id,type,status,call_count,last_call_result,last_called_at',
])
->where('assigned_to', $userId);
}
private function canAccessLead(Request $request, Lead $lead): bool
{
return (int) $lead->assigned_to === (int) $request->user()->id;
}
private function mobileCallResults(): array
{
$results = CallResult::where('is_active', true)->orderBy('sort_order')->pluck('name')->all();
return array_values(array_unique(array_merge($results, [
'پاسخ داد',
'جواب نداد',
'اشغال بود',
'بعداً تماس بگیر',
'علاقه‌مند است',
'پیشنهاد ارسال شد',
'شماره اشتباه',
'معرفی شماره جدید',
'عدم تمایل',
])));
}
private function queueQuery(int $userId)
{
return $this->assignedLeadQuery($userId)
->where(function ($query) {
$query->whereNull('last_call_at')
->orWhere('next_follow_up_at', '<=', now())
->orWhereHas('followUps', fn($followUp) => $followUp->where('status', 'pending')->where('scheduled_at', '<=', now()));
})
->orderByDesc('priority')
->orderByRaw('next_follow_up_at IS NULL')
->orderBy('next_follow_up_at')
->orderBy('created_at');
}
private function applyMobileFilter($query, ?string $filter): void
{
match ($filter) {
'today' => $query->whereDate('next_follow_up_at', now()->toDateString()),
'overdue' => $query->where('next_follow_up_at', '<', now()),
'no-call' => $query->whereNull('last_call_at'),
'high-priority' => $query->where('priority', '>=', 7),
default => null,
};
}
private function leadCard(Lead $lead): array
{
$primary = $this->primaryContact($lead);
return [
'id' => $lead->id,
'lead_name' => $this->leadName($lead),
'main_contact_name' => $primary?->name,
'main_contact_role' => $primary?->role,
'main_contact_phone_id' => $primary?->phones?->first()?->id,
'lead_status' => $lead->leadStatus?->name,
'lead_status_color' => $lead->leadStatus?->color,
'stage' => $lead->pipelineStage?->name,
'stage_color' => $lead->pipelineStage?->color,
'last_call_result' => $lead->last_call_result,
'last_call_at' => optional($lead->last_call_at)->toIso8601String(),
'follow_up_time' => optional($lead->next_follow_up_at)->toIso8601String(),
'priority' => $lead->priority ?? 0,
'priority_label' => $this->priorityLabel($lead->priority ?? 0),
'short_note' => $lead->notes ? mb_strimwidth($lead->notes, 0, 120, '...') : null,
'next_action' => $this->nextAction($lead),
'follow_up_status' => $this->followUpStatus($lead),
'tags' => array_slice($this->tags($lead), 0, 4),
'call_attempts' => $lead->call_attempts ?? 0,
];
}
private function followUpCard(FollowUp $followUp): array
{
$lead = $followUp->lead;
$primary = $lead ? $this->primaryContact($lead) : null;
$scheduled = $followUp->scheduled_at;
return [
'id' => $followUp->id,
'lead_id' => $lead?->id,
'lead_name' => $lead ? $this->leadName($lead) : 'لید حذف‌شده',
'contact_name' => $primary?->name,
'contact_phone_id' => $primary?->phones?->first()?->id,
'follow_up_time' => optional($scheduled)->toIso8601String(),
'follow_up_type' => $followUp->call_id ? 'بعد از تماس' : 'پیگیری',
'last_call_result' => $lead?->last_call_result,
'notes' => $followUp->notes,
'status' => $followUp->status,
'group' => $this->followUpGroup($followUp),
];
}
private function contactSummary($contact): array
{
$phones = $contact->phones ?? collect();
$mainPhone = $phones->first();
return [
'id' => $contact->id,
'name' => $contact->name,
'role' => $contact->role,
'description' => $contact->description,
'status' => $contact->status,
'is_primary' => (bool) $contact->is_primary,
'phone_id' => $mainPhone?->id,
'phone_type' => $mainPhone?->type,
'phone_status' => $mainPhone?->status,
'call_count' => (int) $phones->sum('call_count'),
'successful_call_count' => (int) $phones->sum('successful_call_count'),
'failed_call_count' => (int) $phones->sum('failed_call_count'),
'last_call_result' => $mainPhone?->last_call_result,
'last_called_at' => optional($mainPhone?->last_called_at)->toIso8601String(),
];
}
private function primaryContact(Lead $lead)
{
$contacts = $lead->relationLoaded('contacts') ? $lead->contacts : $lead->contacts()->with('phones')->get();
return $contacts->firstWhere('is_primary', true) ?? $contacts->first();
}
private function leadName(Lead $lead): string
{
return trim("{$lead->first_name} {$lead->last_name}") ?: ($lead->company ?: "لید {$lead->id}");
}
private function tags(Lead $lead): array
{
if (!$lead->tags) {
return [];
}
$decoded = json_decode($lead->tags, true);
if (is_array($decoded)) {
return array_values(array_filter($decoded, fn($tag) => is_string($tag) && $tag !== ''));
}
return array_values(array_filter(array_map('trim', explode(',', $lead->tags))));
}
private function priorityLabel(int $priority): string
{
if ($priority >= 8) return 'خیلی بالا';
if ($priority >= 5) return 'بالا';
if ($priority >= 2) return 'متوسط';
return 'عادی';
}
private function nextAction(Lead $lead): string
{
if (!$lead->last_call_at) return 'تماس اول';
if ($lead->next_follow_up_at && $lead->next_follow_up_at->isPast()) return 'پیگیری عقب‌افتاده';
if ($lead->next_follow_up_at && $lead->next_follow_up_at->isToday()) return 'پیگیری امروز';
return 'ادامه پیگیری';
}
private function followUpStatus(Lead $lead): string
{
if (!$lead->next_follow_up_at) return 'بدون زمان پیگیری';
if ($lead->next_follow_up_at->isPast()) return 'عقب‌افتاده';
if ($lead->next_follow_up_at->isToday()) return 'امروز';
return 'زمان‌بندی‌شده';
}
private function followUpGroup(FollowUp $followUp): string
{
if ($followUp->status === 'completed') return 'done';
if ($followUp->scheduled_at->isPast() && !$followUp->scheduled_at->isToday()) return 'overdue';
if ($followUp->scheduled_at->isToday()) return 'today';
if ($followUp->scheduled_at->isTomorrow()) return 'tomorrow';
return 'week';
}
private function successfulResults(): array
{
return CallResult::where('is_positive', true)->pluck('name')->all();
}
}