مخزن کامیت contained در
مسعود نیک زاد 2026-07-11 12:23:45 +03:30
کامیت b246b94754
275فایلهای تغییر یافته به همراه36088 افزوده شده و 0 حذف شده

58
.gitignore فروخته شده normal فایل
مشاهده پرونده

@ -0,0 +1,58 @@
# Root hygiene
.DS_Store
Thumbs.db
*.log
*.tmp
*.bak
# Local environment and secrets
.env
.env.*
!.env.example
auth.json
cookies*.txt
**/cookies*.txt
# Dependencies
backend/vendor/
frontend/node_modules/
node_modules/
vendor/
# Build output
backend/public/build/
backend/public/hot
frontend/dist/
frontend/dist-ssr/
# Laravel runtime data
backend/storage/app/*
!backend/storage/app/.gitignore
!backend/storage/app/public/
backend/storage/app/public/*
!backend/storage/app/public/.gitignore
backend/storage/framework/cache/*
!backend/storage/framework/cache/.gitignore
backend/storage/framework/sessions/*
!backend/storage/framework/sessions/.gitignore
backend/storage/framework/testing/*
!backend/storage/framework/testing/.gitignore
backend/storage/framework/views/*
!backend/storage/framework/views/.gitignore
backend/storage/logs/*
!backend/storage/logs/.gitignore
backend/bootstrap/cache/*
!backend/bootstrap/cache/.gitignore
backend/database/*.sqlite
backend/database/*.sqlite-*
backend/.phpunit.result.cache
# Frontend local files
frontend/*.local
# Editors
.fleet/
.idea/
.nova/
.vscode/
.zed/

1
README.md normal فایل
مشاهده پرونده

@ -0,0 +1 @@

18
backend/.editorconfig normal فایل
مشاهده پرونده

@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[compose.yaml]
indent_size = 4

76
backend/.env.example normal فایل
مشاهده پرونده

@ -0,0 +1,76 @@
APP_NAME="CRM"
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://127.0.0.1:8800
FRONTEND_URL=http://localhost:5173
SANCTUM_STATEFUL_DOMAINS=localhost:5173,127.0.0.1:5173
CORS_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
APP_LOCALE=fa
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=fa_IR
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
# PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=sqlite
DB_DATABASE=database/database.sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_USERNAME=root
# DB_PASSWORD=
SESSION_DRIVER=file
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
# Keep this null for local dev so Laravel creates host-only cookies for localhost/127.0.0.1.
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
MEMCACHED_PORT=11211
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_USERNAME=null
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
POSTMARK_API_KEY=
RESEND_API_KEY=
SLACK_BOT_USER_OAUTH_TOKEN=
SLACK_BOT_USER_DEFAULT_CHANNEL=
VITE_APP_NAME="${APP_NAME}"

11
backend/.gitattributes فروخته شده normal فایل
مشاهده پرونده

@ -0,0 +1,11 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore

44
backend/.gitignore فروخته شده normal فایل
مشاهده پرونده

@ -0,0 +1,44 @@
*.log
.DS_Store
.env
.env.*
!.env.example
.phpactor.json
.phpunit.result.cache
cookies*.txt
/.fleet
/.idea
/.nova
/.phpunit.cache
/.vscode
/.zed
/auth.json
/node_modules
/public/build
/public/hot
/public/storage
/storage/*.key
/storage/app/*
!/storage/app/.gitignore
!/storage/app/public/
/storage/app/public/*
!/storage/app/public/.gitignore
/storage/framework/cache/*
!/storage/framework/cache/.gitignore
/storage/framework/sessions/*
!/storage/framework/sessions/.gitignore
/storage/framework/testing/*
!/storage/framework/testing/.gitignore
/storage/framework/views/*
!/storage/framework/views/.gitignore
/storage/logs/*
!/storage/logs/.gitignore
/storage/pail
/bootstrap/cache/*
!/bootstrap/cache/.gitignore
/database/*.sqlite
/database/*.sqlite-*
/vendor
Homestead.json
Homestead.yaml
Thumbs.db

38
backend/README.md normal فایل
مشاهده پرونده

@ -0,0 +1,38 @@
# CRM Backend
Laravel 12 API for the CRM. See the root `README.md` for full setup, testing, security, and project-structure notes.
## Quick Start
```bash
composer install
copy .env.example .env
php artisan key:generate
type nul > database\database.sqlite
php artisan migrate --seed
php artisan serve --host=127.0.0.1 --port=8800
```
Required PHP extensions include `gd`, `mbstring`, `openssl`, `pdo_sqlite`, `zip`, `xml`, `xmlreader`, `xmlwriter`, `simplexml`, `dom`, `fileinfo`, and `curl`. Check the current machine with:
```bash
composer check-platform-reqs
```
Default local backend:
```text
Host: 127.0.0.1
Port: 8800
URL: http://127.0.0.1:8800
```
To use another port, pass a different `--port` to `php artisan serve` and update `APP_URL`, `SANCTUM_STATEFUL_DOMAINS`, `CORS_ALLOWED_ORIGINS`, and the frontend `VITE_BACKEND_URL`.
Run tests with:
```bash
php artisan test
```
Do not commit `.env`, `database/*.sqlite`, `storage` runtime data, `bootstrap/cache` output, cookie files, or `vendor`.

مشاهده پرونده

@ -0,0 +1,34 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\ActivityLog;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ActivityLogController extends Controller
{
public function index(Request $request): JsonResponse
{
$query = ActivityLog::with('user:id,name');
if ($request->action) {
$query->where('action', 'like', "%{$request->action}%");
}
if ($request->user_id) {
$query->where('user_id', $request->user_id);
}
if ($request->date_from) {
$query->whereDate('created_at', '>=', $request->date_from);
}
if ($request->date_to) {
$query->whereDate('created_at', '<=', $request->date_to);
}
return response()->json($query->orderBy('created_at', 'desc')->paginate($request->per_page ?? 30));
}
}

مشاهده پرونده

@ -0,0 +1,595 @@
<?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();
}
}

مشاهده پرونده

@ -0,0 +1,147 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Lead;
use App\Models\User;
use App\Services\ActivityLogger;
use App\Services\AssignmentService;
use App\Support\AccessControl;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class AssignmentController extends Controller
{
public function __construct(private AssignmentService $assignmentService) {}
public function assign(Request $request): JsonResponse
{
$validated = $request->validate([
'lead_id' => 'required|exists:leads,id',
'agent_id' => 'required|exists:users,id',
]);
$lead = Lead::findOrFail($validated['lead_id']);
$agent = User::findOrFail($validated['agent_id']);
if (!$this->canAssignToAgent($lead, $agent)) {
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
}
$lead = $this->assignmentService->assignToAgent(
$validated['lead_id'],
$validated['agent_id'],
auth()->id()
);
ActivityLogger::log('lead_owner_changed', "Lead {$lead->id} assigned to user {$agent->id}", $lead);
return response()->json($lead->load('assignedAgent'));
}
public function bulkAssign(Request $request): JsonResponse
{
$validated = $request->validate([
'lead_ids' => 'required|array|min:1',
'lead_ids.*' => 'exists:leads,id',
'agent_id' => 'required|exists:users,id',
]);
$agent = User::findOrFail($validated['agent_id']);
$leads = Lead::whereIn('id', $validated['lead_ids'])->get();
if ($leads->count() !== count(array_unique($validated['lead_ids'])) || $leads->contains(fn(Lead $lead) => !$this->canAssignToAgent($lead, $agent))) {
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
}
$leads = $this->assignmentService->bulkAssign(
$validated['lead_ids'],
$validated['agent_id'],
auth()->id()
);
ActivityLogger::log('lead_bulk_owner_changed', count($leads) . " leads assigned to user {$agent->id}");
return response()->json(['assigned' => count($leads)]);
}
public function roundRobin(Request $request): JsonResponse
{
$validated = $request->validate([
'lead_ids' => 'required|array|min:1',
'lead_ids.*' => 'exists:leads,id',
'agent_ids' => 'required|array|min:1',
'agent_ids.*' => 'exists:users,id',
]);
$agents = User::whereIn('id', $validated['agent_ids'])->get()->keyBy('id');
$leads = Lead::whereIn('id', $validated['lead_ids'])->get();
if (
$agents->count() !== count(array_unique($validated['agent_ids']))
|| $leads->count() !== count(array_unique($validated['lead_ids']))
|| $leads->contains(fn(Lead $lead) => Gate::denies('assign', $lead))
|| $agents->contains(fn(User $agent) => !$this->agentIsAssignable($agent))
) {
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
}
$leads = $this->assignmentService->roundRobin(
$validated['lead_ids'],
$validated['agent_ids'],
auth()->id()
);
ActivityLogger::log('lead_round_robin_owner_changed', count($leads) . ' leads assigned by round robin');
return response()->json(['assigned' => count($leads)]);
}
public function reassign(Request $request): JsonResponse
{
$validated = $request->validate([
'lead_id' => 'required|exists:leads,id',
'agent_id' => 'required|exists:users,id',
]);
$lead = Lead::findOrFail($validated['lead_id']);
$agent = User::findOrFail($validated['agent_id']);
if (!$this->canAssignToAgent($lead, $agent)) {
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
}
$lead = $this->assignmentService->assignToAgent(
$validated['lead_id'],
$validated['agent_id'],
auth()->id()
);
ActivityLogger::log('lead_owner_changed', "Lead {$lead->id} reassigned to user {$agent->id}", $lead);
return response()->json($lead->load('assignedAgent'));
}
public function returnToPool(int $leadId): JsonResponse
{
$leadModel = Lead::findOrFail($leadId);
Gate::authorize('assign', $leadModel);
$lead = $this->assignmentService->returnToPool($leadId);
return response()->json($lead);
}
private function canAssignToAgent(Lead $lead, User $agent): bool
{
return Gate::allows('assign', $lead) && $this->agentIsAssignable($agent);
}
private function agentIsAssignable(User $agent): bool
{
$user = auth()->user();
if (!$agent->hasRole('agent') || !$agent->is_active) {
return false;
}
if ($user?->hasRole('admin')) {
return true;
}
if ($user?->hasRole('supervisor')) {
return (bool) array_intersect(AccessControl::teamIds($user), $agent->teams()->pluck('teams.id')->all());
}
return false;
}
}

مشاهده پرونده

@ -0,0 +1,85 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Attachment;
use App\Models\Company;
use App\Models\Deal;
use App\Models\Lead;
use App\Models\Setting;
use App\Services\ActivityLogger;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class AttachmentController extends Controller
{
public function index(Request $request): JsonResponse
{
$validated = $request->validate(['entity_type' => 'required|in:lead,company,deal', 'entity_id' => 'required|integer']);
$model = $this->resolve($validated['entity_type'], $validated['entity_id']);
return response()->json(['data' => $model->attachments()->with('uploader:id,name')->latest()->get()]);
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'entity_type' => 'required|in:lead,company,deal',
'entity_id' => 'required|integer',
'file' => 'required|file|mimes:' . $this->allowedMimes() . '|max:' . ($this->maxFileMb() * 1024),
], [
'file.mimes' => 'نوع فایل پیوست مجاز نیست.',
'file.max' => 'حجم فایل پیوست بیش از حد مجاز است.',
]);
$model = $this->resolve($validated['entity_type'], $validated['entity_id']);
$file = $request->file('file');
$path = $file->store('attachments');
$attachment = $model->attachments()->create([
'uploaded_by' => auth()->id(),
'original_name' => $file->getClientOriginalName(),
'path' => $path,
'mime_type' => $file->getMimeType() ?: 'application/octet-stream',
'size' => $file->getSize(),
]);
ActivityLogger::log('attachment_uploaded', "File {$attachment->original_name} uploaded", $model);
return response()->json($attachment->load('uploader:id,name'), 201);
}
public function download(Attachment $attachment)
{
ActivityLogger::log('attachment_downloaded', "File {$attachment->original_name} downloaded", $attachment->attachable);
return Storage::download($attachment->path, $attachment->original_name);
}
public function destroy(Attachment $attachment): JsonResponse
{
abort_unless(auth()->user()?->hasRole('admin') || auth()->id() === $attachment->uploaded_by, 403, 'دسترسی غیرمجاز');
Storage::delete($attachment->path);
$attachment->delete();
ActivityLogger::log('attachment_deleted', "File {$attachment->id} deleted");
return response()->json(['message' => 'فایل حذف شد']);
}
private function resolve(string $type, int $id): Lead|Company|Deal
{
return match ($type) {
'lead' => Lead::findOrFail($id),
'company' => Company::findOrFail($id),
'deal' => Deal::findOrFail($id),
};
}
private function allowedMimes(): string
{
return Setting::where('key', 'attachment_allowed_file_types')->value('value')
?: 'pdf,jpg,jpeg,png,doc,docx,xls,xlsx,txt';
}
private function maxFileMb(): int
{
return max(1, (int) (Setting::where('key', 'attachment_max_file_size_mb')->value('value') ?: 10));
}
}

مشاهده پرونده

@ -0,0 +1,139 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\ActivityLogger;
use App\Support\PasswordPolicy;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class AuthController extends Controller
{
public function login(Request $request): JsonResponse
{
$request->validate([
'phone' => 'required|string',
'password' => 'required',
]);
$login = $this->normalizeLoginIdentifier($request->phone);
$user = str_contains($login, '@')
? User::whereRaw('lower(email) = ?', [$login])->first()
: User::get()->first(fn (User $candidate) => $this->normalizeLoginIdentifier($candidate->phone) === $login);
if (!$user || !Hash::check($request->password, $user->password)) {
throw ValidationException::withMessages([
'phone' => ['شماره موبایل یا رمز عبور اشتباه است'],
]);
}
if (!$user->is_active) {
return response()->json(['message' => 'حساب کاربری شما غیرفعال است'], 403);
}
Auth::guard('web')->login($user);
$request->session()->regenerate();
$user->update([
'last_login_at' => now(),
'last_login_ip' => $request->ip(),
'current_login_at' => now(),
]);
ActivityLogger::log('login', "User {$user->phone} logged in");
$user->load('roles.permissions', 'teams');
return response()->json([
'data' => $user,
]);
}
public function logout(Request $request): JsonResponse
{
$user = $request->user();
if ($user?->current_login_at) {
$user->increment('total_presence_seconds', max(0, $user->current_login_at->diffInSeconds(now())));
$user->forceFill([
'current_login_at' => null,
'last_logout_at' => now(),
])->save();
}
ActivityLogger::log('logout', "User logged out");
Auth::guard('web')->logout();
if ($request->hasSession()) {
$request->session()->invalidate();
$request->session()->regenerateToken();
}
return response()->json(['message' => 'خروج با موفقیت انجام شد']);
}
public function me(Request $request): JsonResponse
{
$user = $request->user()->load('roles.permissions', 'teams');
$user->permissions = $user->getAllPermissions()->pluck('name');
return response()->json([
'data' => $user,
]);
}
public function updateProfile(Request $request): JsonResponse
{
$user = $request->user();
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email,' . $user->id,
'phone' => 'nullable|string|max:20|unique:users,phone,' . $user->id,
'password' => ['nullable', ...PasswordPolicy::rules()],
'avatar' => 'nullable|image|mimes:jpg,jpeg,png,webp|max:2048',
]);
if ($request->hasFile('avatar')) {
$path = $request->file('avatar')->store('avatars', 'public');
$validated['avatar'] = $path;
}
if (!empty($validated['password'])) {
$validated['password'] = Hash::make($validated['password']);
} else {
unset($validated['password']);
}
$user->update($validated);
$user->load('roles.permissions', 'teams');
$user->permissions = $user->getAllPermissions()->pluck('name');
ActivityLogger::log('profile_updated', "User {$user->phone} updated profile");
return response()->json(['data' => $user]);
}
private function normalizeLoginIdentifier(?string $value): string
{
$value = trim((string) $value);
if (str_contains($value, '@')) {
return strtolower($value);
}
$value = strtr($value, [
'۰' => '0', '۱' => '1', '۲' => '2', '۳' => '3', '۴' => '4',
'۵' => '5', '۶' => '6', '۷' => '7', '۸' => '8', '۹' => '9',
'٠' => '0', '١' => '1', '٢' => '2', '٣' => '3', '٤' => '4',
'٥' => '5', '٦' => '6', '٧' => '7', '٨' => '8', '٩' => '9',
]);
return preg_replace('/\D+/', '', $value) ?? '';
}
}

مشاهده پرونده

@ -0,0 +1,118 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Call;
use App\Models\CallResult;
use App\Models\Lead;
use App\Services\ActivityLogger;
use App\Services\CallService;
use App\Services\VoIP\VoIPManager;
use App\Support\AccessControl;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class CallController extends Controller
{
public function __construct(
private CallService $callService,
private VoIPManager $voipManager
) {}
public function index(Request $request): JsonResponse
{
$user = auth()->user();
Gate::authorize('viewAny', Call::class);
$query = Call::with('lead:id,first_name,last_name,phone', 'user:id,name', 'contact:id,name,role', 'contactPhone:id,phone,type,status');
AccessControl::scopeCalls($query, $user);
if ($request->lead_id) {
$query->where('lead_id', $request->lead_id);
}
if ($request->result) {
$query->where('result', $request->result);
}
if ($request->date_from) {
$query->whereDate('created_at', '>=', $request->date_from);
}
if ($request->date_to) {
$query->whereDate('created_at', '<=', $request->date_to);
}
return response()->json($query->orderBy('created_at', 'desc')->paginate($request->per_page ?? 15));
}
public function results(): JsonResponse
{
return response()->json(
CallResult::where('is_active', true)
->orderBy('sort_order')
->get(['id', 'name', 'slug', 'color', 'requires_follow_up', 'is_positive', 'is_negative', 'is_final'])
);
}
public function store(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']);
Gate::authorize('create', [Call::class, $lead]);
$result = $this->callService->initiateCall($validated['lead_id'], auth()->id(), $validated['contact_phone_id'] ?? null);
return response()->json($result, 201);
}
public function show(Call $call): JsonResponse
{
Gate::authorize('view', $call);
if ($call->recording_url && Gate::allows('viewRecording', $call)) {
ActivityLogger::log('recording_viewed', "Recording viewed for call {$call->id}", $call);
}
return response()->json($call->load('lead', 'user', 'contact', 'contactPhone'));
}
public function registerResult(Request $request): JsonResponse
{
$validated = $request->validate([
'call_id' => 'required|exists:calls,id',
'result' => 'required|string|max:50',
'notes' => 'nullable|string',
'next_follow_up_at' => 'nullable|date',
'referral' => 'nullable|array',
'referral.name' => 'required_with:referral|string|max:255',
'referral.phone' => 'required_with:referral|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',
'referral.make_primary' => 'nullable|boolean',
'referral.next_follow_up_at' => 'nullable|date',
]);
$call = Call::with('lead')->findOrFail($validated['call_id']);
Gate::authorize('registerResult', $call);
$callResult = CallResult::where('name', $validated['result'])->first();
if ($callResult?->requires_follow_up && empty($validated['next_follow_up_at']) && empty($validated['referral']['next_follow_up_at'])) {
return response()->json(['message' => 'برای این نتیجه تماس، زمان پیگیری الزامی است'], 422);
}
$call = $this->callService->registerResult(
$validated['call_id'],
$validated['result'],
$validated['notes'] ?? null,
$validated['next_follow_up_at'] ?? null,
$validated['referral'] ?? null
);
return response()->json($call);
}
}

مشاهده پرونده

@ -0,0 +1,116 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Campaign;
use App\Services\ActivityLogger;
use App\Support\AccessControl;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class CampaignController extends Controller
{
public function index(Request $request): JsonResponse
{
$query = Campaign::withCount('leads')->with('assignedAgents:id,name', 'assignedSupervisors:id,name');
Gate::authorize('viewAny', Campaign::class);
AccessControl::scopeCampaigns($query, auth()->user());
if ($request->search) {
$query->where('name', 'like', "%{$request->search}%");
}
if ($request->status) {
$query->where('status', $request->status);
}
return response()->json($query->orderBy('created_at', 'desc')->paginate($request->per_page ?? 15));
}
public function store(Request $request): JsonResponse
{
Gate::authorize('create', Campaign::class);
$validated = $request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'product_service' => 'nullable|string|max:255',
'start_date' => 'nullable|date',
'end_date' => 'nullable|date|after_or_equal:start_date',
'target' => 'nullable|integer|min:0',
'status' => 'nullable|string|in:draft,active,paused,completed,archived',
'agent_ids' => 'nullable|array',
'agent_ids.*' => 'exists:users,id',
'supervisor_ids' => 'nullable|array',
'supervisor_ids.*' => 'exists:users,id',
]);
$campaign = Campaign::create($validated);
if ($request->agent_ids) {
$campaign->assignedAgents()->sync($request->agent_ids);
}
if ($request->supervisor_ids) {
$campaign->assignedSupervisors()->sync($request->supervisor_ids);
}
ActivityLogger::log('campaign_created', "Campaign {$campaign->name} created", $campaign);
return response()->json($campaign->load('assignedAgents', 'assignedSupervisors'), 201);
}
public function show(Campaign $campaign): JsonResponse
{
Gate::authorize('view', $campaign);
return response()->json(
$campaign->load([
'assignedAgents', 'assignedSupervisors', 'salesScript',
'leads' => fn($q) => $q->with('leadStatus'),
])
);
}
public function update(Request $request, Campaign $campaign): JsonResponse
{
Gate::authorize('update', $campaign);
$validated = $request->validate([
'name' => 'sometimes|string|max:255',
'description' => 'nullable|string',
'product_service' => 'nullable|string|max:255',
'start_date' => 'nullable|date',
'end_date' => 'nullable|date|after_or_equal:start_date',
'target' => 'nullable|integer|min:0',
'status' => 'nullable|string|in:draft,active,paused,completed,archived',
'agent_ids' => 'nullable|array',
'agent_ids.*' => 'exists:users,id',
'supervisor_ids' => 'nullable|array',
'supervisor_ids.*' => 'exists:users,id',
]);
$campaign->update($validated);
if ($request->has('agent_ids')) {
$campaign->assignedAgents()->sync($request->agent_ids);
}
if ($request->has('supervisor_ids')) {
$campaign->assignedSupervisors()->sync($request->supervisor_ids);
}
ActivityLogger::log('campaign_updated', "Campaign {$campaign->name} updated", $campaign);
return response()->json($campaign->load('assignedAgents', 'assignedSupervisors'));
}
public function destroy(Campaign $campaign): JsonResponse
{
Gate::authorize('delete', $campaign);
$campaign->delete();
ActivityLogger::log('campaign_deleted', "Campaign {$campaign->name} deleted");
return response()->json(['message' => 'کمپین حذف شد']);
}
}

مشاهده پرونده

@ -0,0 +1,121 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Company;
use App\Services\ActivityLogger;
use App\Services\DuplicateService;
use App\Support\AccessControl;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CompanyController extends Controller
{
public function __construct(private DuplicateService $duplicates) {}
public function index(Request $request): JsonResponse
{
$query = Company::with('owner:id,name')->withCount(['contacts', 'leads', 'deals']);
$this->scope($query);
if ($request->search) {
$search = $request->search;
$query->where(fn($q) => $q->where('name', 'like', "%{$search}%")->orWhere('website', 'like', "%{$search}%")->orWhere('city', 'like', "%{$search}%"));
}
if ($request->status) $query->where('status', $request->status);
if ($request->owner_id) $query->where('owner_id', $request->owner_id);
return response()->json($query->latest()->paginate(min((int) $request->get('per_page', 15), 100)));
}
public function store(Request $request): JsonResponse
{
$validated = $this->validated($request);
$suggestions = $this->duplicates->companySuggestions($validated);
if ($request->boolean('block_duplicates') && $suggestions) {
return response()->json(['message' => 'شرکت مشابهی در سیستم وجود دارد.', 'duplicates' => $suggestions], 422);
}
$company = Company::create($this->prepare($validated) + ['created_by' => auth()->id()]);
ActivityLogger::log('company_created', "Company {$company->name} created", $company);
$payload = $company->load('owner:id,name')->toArray();
if ($suggestions) $payload['duplicate_suggestions'] = $suggestions;
return response()->json($payload, 201);
}
public function show(Company $company): JsonResponse
{
$this->authorizeAccess($company);
return response()->json($company->load([
'owner:id,name', 'contacts.phones', 'leads.pipelineStage', 'deals.product', 'notes.user:id,name', 'attachments.uploader:id,name',
]));
}
public function update(Request $request, Company $company): JsonResponse
{
$this->authorizeAccess($company);
$validated = $this->validated($request, true);
$company->update($this->prepare($validated));
ActivityLogger::log('company_updated', "Company {$company->name} updated", $company);
return response()->json($company->fresh('owner:id,name'));
}
public function destroy(Company $company): JsonResponse
{
$this->authorizeAccess($company, true);
$company->delete();
ActivityLogger::log('company_deleted', "Company {$company->id} deleted");
return response()->json(['message' => 'شرکت حذف شد']);
}
public function merge(Request $request, Company $company): JsonResponse
{
abort_unless(auth()->user()?->hasRole('admin') || auth()->user()?->can('merge_duplicates'), 403, 'شما مجوز ادغام رکوردهای تکراری را ندارید.');
$validated = $request->validate(['target_id' => 'required|exists:companies,id|different:' . $company->id]);
$target = Company::findOrFail($validated['target_id']);
return response()->json($this->duplicates->mergeCompanies($company, $target, auth()->id()));
}
private function validated(Request $request, bool $partial = false): array
{
$sometimes = $partial ? 'sometimes|' : '';
return $request->validate([
'name' => $sometimes . 'required|string|max:255',
'company_type' => 'nullable|string|max:80',
'industry' => 'nullable|string|max:120',
'city' => 'nullable|string|max:120',
'address' => 'nullable|string',
'website' => 'nullable|string|max:255',
'description' => 'nullable|string',
'status' => 'nullable|string|max:40',
'owner_id' => 'nullable|exists:users,id',
]);
}
private function prepare(array $data): array
{
if (array_key_exists('name', $data)) $data['normalized_name'] = DuplicateService::normalizeName($data['name']);
if (array_key_exists('website', $data)) $data['normalized_website'] = DuplicateService::normalizeWebsite($data['website']);
return $data;
}
private function scope($query): void
{
$user = auth()->user();
if ($user?->hasRole('admin')) return;
if ($user?->hasRole('agent')) $query->where('owner_id', $user->id);
if ($user?->hasRole('supervisor')) {
$query->where(function ($q) use ($user) {
$q->where('owner_id', $user->id)->orWhereIn('owner_id', AccessControl::teamMemberIds($user));
});
}
}
private function authorizeAccess(Company $company, bool $manage = false): void
{
$user = auth()->user();
abort_unless($user && ($user->hasRole('admin') || (!$manage && $company->owner_id === $user->id) || (!$manage && $user->hasRole('supervisor') && in_array($company->owner_id, AccessControl::teamMemberIds($user), true))), 403, 'دسترسی غیرمجاز');
}
}

مشاهده پرونده

@ -0,0 +1,178 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Contact;
use App\Models\ContactPhone;
use App\Models\Lead;
use App\Services\ActivityLogger;
use App\Support\AccessControl;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
class ContactController extends Controller
{
public function index(Request $request): JsonResponse
{
$query = Contact::with('phones', 'lead:id,company,first_name,last_name', 'company:id,name', 'deal:id,title');
if ($request->lead_id) $query->where('lead_id', $request->lead_id);
if ($request->company_id) $query->where('company_id', $request->company_id);
if ($request->deal_id) $query->where('deal_id', $request->deal_id);
if ($request->status) $query->where('status', $request->status);
if ($request->search) {
$search = $request->search;
$query->where(fn($q) => $q->where('name', 'like', "%{$search}%")->orWhere('email', 'like', "%{$search}%"));
}
return response()->json($query->latest()->paginate(min((int) $request->get('per_page', 15), 100)));
}
public function storeStandalone(Request $request): JsonResponse
{
$validated = $this->validateContact($request);
$contact = DB::transaction(fn() => $this->createContact($validated));
ActivityLogger::log('contact_created', "Contact {$contact->name} created", $contact);
return response()->json($contact->load('phones.lastCaller', 'company:id,name', 'lead:id,company'), 201);
}
public function store(Request $request, Lead $lead): JsonResponse
{
$this->authorizeLeadAccess($lead);
$validated = $this->validateContact($request);
$validated['lead_id'] = $lead->id;
$contact = DB::transaction(function () use ($lead, $validated) {
if (!empty($validated['is_primary'])) {
Contact::where('lead_id', $lead->id)->update(['is_primary' => false]);
}
$contact = $this->createContact($validated);
if ($contact->is_primary) {
ActivityLogger::log('primary_contact_changed', "Primary contact changed to {$contact->name}", $lead);
}
return $contact;
});
ActivityLogger::log('contact_created', "Contact {$contact->name} created for lead {$lead->id}", $contact);
return response()->json($contact->load('phones.lastCaller'), 201);
}
public function setPrimary(Request $request, Contact $contact): JsonResponse
{
if ($contact->lead) {
$this->authorizeLeadAccess($contact->lead);
}
$validated = $request->validate([
'reason' => 'nullable|string|max:255',
]);
DB::transaction(function () use ($contact, $validated) {
Contact::where('lead_id', $contact->lead_id)->update(['is_primary' => false]);
$contact->update([
'is_primary' => true,
'primary_reason' => $validated['reason'] ?? 'انتخاب دستی توسط کاربر',
]);
});
ActivityLogger::log('primary_contact_changed', "Primary contact changed to {$contact->name}", $contact->lead);
return response()->json($contact->fresh('phones.lastCaller'));
}
public function update(Request $request, Contact $contact): JsonResponse
{
$validated = $this->validateContact($request, true);
$contact->update($validated);
if ($request->has('phones')) {
$contact->phones()->delete();
foreach ($validated['phones'] ?? [] as $phone) {
ContactPhone::create([
'contact_id' => $contact->id,
'phone' => $phone['phone'],
'type' => $phone['type'] ?? 'mobile',
'status' => $phone['status'] ?? 'active',
]);
}
}
ActivityLogger::log('contact_updated', "Contact {$contact->name} updated", $contact);
return response()->json($contact->fresh('phones.lastCaller'));
}
public function destroy(Contact $contact): JsonResponse
{
$contact->delete();
ActivityLogger::log('contact_deleted', "Contact {$contact->id} deleted");
return response()->json(['message' => 'مخاطب حذف شد']);
}
private function authorizeLeadAccess(Lead $lead): void
{
Gate::authorize('update', $lead);
}
private function validateContact(Request $request, bool $partial = false): array
{
$sometimes = $partial ? 'sometimes|' : '';
return $request->validate([
'lead_id' => 'nullable|exists:leads,id',
'company_id' => 'nullable|exists:companies,id',
'deal_id' => 'nullable|exists:deals,id',
'name' => $sometimes . 'required|string|max:255',
'first_name' => 'nullable|string|max:120',
'last_name' => 'nullable|string|max:120',
'role' => 'nullable|string|max:80',
'job_title' => 'nullable|string|max:120',
'email' => 'nullable|email|max:255',
'preferred_channel' => 'nullable|string|max:40',
'description' => 'nullable|string',
'status' => 'nullable|string|max:30',
'is_primary' => 'nullable|boolean',
'primary_reason' => 'nullable|string|max:255',
'phones' => ($partial ? 'nullable' : 'required') . '|array|min:1',
'phones.*.phone' => 'required|string|max:30',
'phones.*.type' => 'nullable|string|in:mobile,landline,extension',
'phones.*.status' => 'nullable|string|max:30',
]);
}
private function createContact(array $validated): Contact
{
$contact = Contact::create([
'lead_id' => $validated['lead_id'] ?? null,
'company_id' => $validated['company_id'] ?? null,
'deal_id' => $validated['deal_id'] ?? null,
'name' => $validated['name'],
'first_name' => $validated['first_name'] ?? null,
'last_name' => $validated['last_name'] ?? null,
'role' => $validated['role'] ?? null,
'job_title' => $validated['job_title'] ?? null,
'email' => $validated['email'] ?? null,
'preferred_channel' => $validated['preferred_channel'] ?? null,
'description' => $validated['description'] ?? null,
'status' => $validated['status'] ?? 'active',
'is_primary' => (bool) ($validated['is_primary'] ?? false),
'primary_reason' => $validated['primary_reason'] ?? (!empty($validated['is_primary']) ? 'انتخاب دستی توسط کاربر' : null),
'created_by' => auth()->id(),
]);
foreach ($validated['phones'] as $phone) {
ContactPhone::create([
'contact_id' => $contact->id,
'phone' => $phone['phone'],
'type' => $phone['type'] ?? 'mobile',
'status' => $phone['status'] ?? 'active',
]);
}
return $contact;
}
}

مشاهده پرونده

@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\DashboardService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class DashboardController extends Controller
{
public function __construct(private DashboardService $dashboardService) {}
public function admin(): JsonResponse
{
$stats = $this->dashboardService->admin();
// Agent ranking
$agents = \App\Models\User::role('agent')
->withCount(['assignedLeads', 'calls'])
->get()
->map(fn($agent) => [
'id' => $agent->id,
'name' => $agent->name,
'leads_count' => $agent->assigned_leads_count,
'calls_count' => $agent->calls_count,
])
->sortByDesc('calls_count')
->values();
$stats['agent_ranking'] = $agents;
return response()->json($stats);
}
public function supervisor(): JsonResponse
{
$teamId = auth()->user()->teams->first()?->id;
$stats = $this->dashboardService->supervisor($teamId);
// Team members
$team = \App\Models\Team::with('members')->find($teamId);
$stats['team_members'] = $team?->members->map(fn($member) => [
'id' => $member->id,
'name' => $member->name,
'is_online' => $member->last_login_at && $member->last_login_at->gt(now()->subMinutes(15)),
'calls_today' => $member->calls()->whereDate('created_at', now()->today())->count(),
]);
return response()->json($stats);
}
public function agent(): JsonResponse
{
$stats = $this->dashboardService->agent();
return response()->json($stats);
}
}

مشاهده پرونده

@ -0,0 +1,87 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Deal;
use App\Services\ActivityLogger;
use App\Support\AccessControl;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class DealController extends Controller
{
public function index(Request $request): JsonResponse
{
$query = Deal::with('company:id,name', 'lead:id,company,first_name,last_name', 'contact:id,name', 'product:id,name', 'owner:id,name');
$this->scope($query);
foreach (['status', 'sales_stage', 'company_id', 'lead_id', 'owner_id', 'product_id'] as $filter) {
if ($request->filled($filter)) $query->where($filter, $request->get($filter));
}
if ($request->search) $query->where('title', 'like', "%{$request->search}%");
return response()->json($query->latest()->paginate(min((int) $request->get('per_page', 15), 100)));
}
public function store(Request $request): JsonResponse
{
$deal = Deal::create($this->validated($request) + ['created_by' => auth()->id()]);
ActivityLogger::log('deal_created', "Deal {$deal->title} created", $deal);
return response()->json($deal->load('company', 'contact', 'product', 'owner'), 201);
}
public function show(Deal $deal): JsonResponse
{
$this->authorizeAccess($deal);
return response()->json($deal->load('company', 'lead', 'contact.phones', 'product.salesScript.sections', 'owner:id,name', 'timelineNotes.user:id,name', 'attachments.uploader:id,name'));
}
public function update(Request $request, Deal $deal): JsonResponse
{
$this->authorizeAccess($deal);
$deal->update($this->validated($request, true));
ActivityLogger::log('deal_updated', "Deal {$deal->title} updated", $deal);
return response()->json($deal->fresh('company', 'contact', 'product', 'owner'));
}
public function destroy(Deal $deal): JsonResponse
{
$this->authorizeAccess($deal, true);
$deal->delete();
ActivityLogger::log('deal_deleted', "Deal {$deal->id} deleted");
return response()->json(['message' => 'فرصت فروش حذف شد']);
}
private function validated(Request $request, bool $partial = false): array
{
$sometimes = $partial ? 'sometimes|' : '';
return $request->validate([
'title' => $sometimes . 'required|string|max:255',
'company_id' => 'nullable|exists:companies,id',
'lead_id' => 'nullable|exists:leads,id',
'contact_id' => 'nullable|exists:contacts,id',
'product_id' => 'nullable|exists:products,id',
'estimated_value' => 'nullable|numeric|min:0',
'win_probability' => 'nullable|integer|min:0|max:100',
'sales_stage' => 'nullable|string|max:80',
'expected_close_date' => 'nullable|date',
'owner_id' => 'nullable|exists:users,id',
'status' => 'nullable|string|max:40',
'won_lost_reason' => 'nullable|string|max:255',
'notes' => 'nullable|string',
]);
}
private function scope($query): void
{
$user = auth()->user();
if ($user?->hasRole('admin')) return;
if ($user?->hasRole('agent')) $query->where('owner_id', $user->id);
if ($user?->hasRole('supervisor')) $query->whereIn('owner_id', array_merge([$user->id], AccessControl::teamMemberIds($user)));
}
private function authorizeAccess(Deal $deal, bool $manage = false): void
{
$user = auth()->user();
abort_unless($user && ($user->hasRole('admin') || (!$manage && $deal->owner_id === $user->id) || (!$manage && $user->hasRole('supervisor') && in_array($deal->owner_id, AccessControl::teamMemberIds($user), true))), 403, 'دسترسی غیرمجاز');
}
}

مشاهده پرونده

@ -0,0 +1,32 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\DuplicateService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class DuplicateController extends Controller
{
public function __construct(private DuplicateService $duplicates) {}
public function check(Request $request): JsonResponse
{
$validated = $request->validate([
'entity_type' => 'required|in:lead,company',
'exclude_id' => 'nullable|integer',
'name' => 'nullable|string',
'company' => 'nullable|string',
'website' => 'nullable|string',
'email' => 'nullable|string',
'phone' => 'nullable|string',
]);
$items = $validated['entity_type'] === 'company'
? $this->duplicates->companySuggestions($validated, $validated['exclude_id'] ?? null)
: $this->duplicates->leadSuggestions($validated, $validated['exclude_id'] ?? null);
return response()->json(['data' => $items]);
}
}

مشاهده پرونده

@ -0,0 +1,186 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\FollowUp;
use App\Models\Lead;
use App\Services\ActivityLogger;
use App\Services\NotificationService;
use App\Support\AccessControl;
use App\Support\WorkingHours;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class FollowUpController extends Controller
{
public function index(Request $request): JsonResponse
{
$user = auth()->user();
Gate::authorize('viewAny', FollowUp::class);
$query = FollowUp::with('lead:id,first_name,last_name,phone', 'user:id,name');
AccessControl::scopeFollowUps($query, $user);
if ($request->status) {
$query->where('status', $request->status);
}
if ($request->lead_id) {
$query->where('lead_id', $request->lead_id);
}
if ($request->date_from) {
$query->whereDate('scheduled_at', '>=', $request->date_from);
}
if ($request->date_to) {
$query->whereDate('scheduled_at', '<=', $request->date_to);
}
return response()->json($query->orderBy('scheduled_at')->paginate($request->per_page ?? 15));
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'lead_id' => 'required|exists:leads,id',
'scheduled_at' => 'required|date',
'notes' => 'nullable|string',
]);
$lead = Lead::findOrFail($validated['lead_id']);
Gate::authorize('create', [FollowUp::class, $lead]);
if (!WorkingHours::followUpAllowed($validated['scheduled_at'])) {
return response()->json(['message' => 'زمان پیگیری خارج از روز یا ساعت کاری مجاز است.'], 422);
}
$followUp = FollowUp::create([
'lead_id' => $validated['lead_id'],
'user_id' => auth()->id(),
'scheduled_at' => $validated['scheduled_at'],
'notes' => $validated['notes'] ?? null,
'status' => 'pending',
]);
ActivityLogger::log('follow_up_created', "Follow-up for lead {$validated['lead_id']} scheduled", $followUp);
NotificationService::notifyFollowUpReminder($followUp->user_id, $lead->full_name ?: ($lead->company ?? "لید {$lead->id}"));
return response()->json($followUp->load('lead'), 201);
}
public function update(Request $request, FollowUp $followUp): JsonResponse
{
Gate::authorize('update', $followUp);
$validated = $request->validate([
'scheduled_at' => 'sometimes|date',
'notes' => 'nullable|string',
'status' => 'sometimes|string|in:pending,completed,cancelled',
]);
$followUp->update($validated);
ActivityLogger::log('follow_up_updated', "Follow-up {$followUp->id} updated", $followUp);
return response()->json($followUp->load('lead'));
}
public function markDone(FollowUp $followUp): JsonResponse
{
Gate::authorize('update', $followUp);
$followUp->update([
'status' => 'completed',
'completed_at' => now(),
]);
ActivityLogger::log('follow_up_completed', "Follow-up {$followUp->id} completed", $followUp);
return response()->json($followUp);
}
public function today(): JsonResponse
{
$user = auth()->user();
$query = FollowUp::with('lead:id,first_name,last_name,phone')
->whereDate('scheduled_at', now()->toDateString())
->where('status', 'pending');
AccessControl::scopeFollowUps($query, $user);
$followUps = $query->orderBy('scheduled_at')->get();
foreach ($followUps as $followUp) {
NotificationService::sendOnce(
$followUp->user_id,
'پیگیری عقب‌افتاده',
'پیگیری لید ' . ($followUp->lead?->full_name ?: ($followUp->lead?->company ?? "لید {$followUp->lead_id}")) . ' عقب افتاده است',
'overdue_follow_up',
['follow_up_id' => $followUp->id, 'lead_id' => $followUp->lead_id]
);
}
return response()->json($followUps);
}
public function overdue(): JsonResponse
{
$user = auth()->user();
$query = FollowUp::with('lead:id,first_name,last_name,phone')
->where('status', 'pending')
->where(function ($q) {
$q->where('is_overdue', true)
->orWhere('scheduled_at', '<', now());
});
AccessControl::scopeFollowUps($query, $user);
return response()->json($query->orderBy('scheduled_at')->get());
}
private function canManageFollowUp(FollowUp $followUp): bool
{
$user = auth()->user();
if (!$user) {
return false;
}
if ($user->hasRole('admin')) {
return true;
}
if ($user->hasRole('agent')) {
return $followUp->user_id === $user->id;
}
if ($user->hasRole('supervisor')) {
$teamIds = $user->teams->pluck('id')->toArray();
$agentIds = \App\Models\Team::whereIn('id', $teamIds)->with('members')->get()->pluck('members.*.id')->flatten();
return $agentIds->contains($followUp->user_id);
}
return false;
}
private function canAccessLead(\App\Models\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;
}
}

مشاهده پرونده

@ -0,0 +1,161 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\ImportBatch;
use App\Models\Setting;
use App\Services\ImportService;
use App\Services\ActivityLogger;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
class ImportController extends Controller
{
public function __construct(private ImportService $importService) {}
public function upload(Request $request): JsonResponse
{
Gate::authorize('import', ImportBatch::class);
$allowedTypes = Setting::where('key', 'import_allowed_file_types')->value('value') ?: 'xlsx,xls,csv';
$maxMb = max(1, (int) (Setting::where('key', 'import_max_file_size_mb')->value('value') ?: 10));
$request->validate([
'file' => 'required|file|mimes:' . $allowedTypes . '|max:' . ($maxMb * 1024),
], [
'file.mimes' => 'نوع فایل import مجاز نیست.',
'file.max' => "حجم فایل import نباید بیشتر از {$maxMb} مگابایت باشد.",
]);
$file = $request->file('file');
$path = $file->store('imports');
$batch = ImportBatch::create([
'user_id' => auth()->id(),
'filename' => $file->getClientOriginalName(),
'status' => 'pending',
'can_rollback' => Setting::where('key', 'import_rollback_allowed')->value('value') !== 'false',
]);
try {
$fullPath = Storage::path($path);
$preview = $this->importService->preview($fullPath);
// Store rows for later processing
$rows = \Maatwebsite\Excel\Facades\Excel::toArray([], $fullPath)[0] ?? [];
$dataRows = array_slice($rows, 1);
foreach ($dataRows as $index => $row) {
$batch->rows()->create([
'original_data' => $row,
'status' => 'pending',
]);
}
$batch->update(['total_rows' => count($dataRows)]);
ActivityLogger::log('lead_import_uploaded', "Import batch {$batch->id} uploaded", $batch);
return response()->json([
'batch_id' => $batch->id,
'filename' => $file->getClientOriginalName(),
'headers' => $preview['headers'],
'total_rows' => $preview['total_rows'],
'preview' => $preview['preview'],
]);
} catch (\Exception $e) {
$batch->update(['status' => 'failed', 'errors' => $e->getMessage()]);
return response()->json(['message' => $e->getMessage()], 422);
}
}
public function confirm(Request $request): JsonResponse
{
$user = auth()->user();
Gate::authorize('import', ImportBatch::class);
$validated = $request->validate([
'batch_id' => 'required|exists:import_batches,id',
'column_mapping' => 'required|array',
'column_mapping.company' => 'required|integer',
'column_mapping.first_name' => 'nullable|integer',
'column_mapping.last_name' => 'nullable|integer',
'column_mapping.phone' => 'required|integer',
'column_mapping.phone_secondary' => 'nullable|integer',
'column_mapping.email' => 'nullable|integer',
'column_mapping.city' => 'nullable|integer',
'column_mapping.province' => 'nullable|integer',
'column_mapping.source' => 'nullable|integer',
'column_mapping.product_interest' => 'nullable|integer',
'column_mapping.priority' => 'nullable|integer',
'column_mapping.lead_score' => 'nullable|integer',
'column_mapping.interest_level' => 'nullable|integer',
'column_mapping.notes' => 'nullable|integer',
'column_mapping.tags' => 'nullable|integer',
'campaign_id' => 'nullable|exists:campaigns,id',
'agent_ids' => 'nullable|array',
'agent_ids.*' => 'exists:users,id',
]);
$agentIds = $validated['agent_ids'] ?? null;
if ($user?->hasRole('agent')) {
$agentIds = [$user->id];
}
$pendingBatch = ImportBatch::findOrFail($validated['batch_id']);
Gate::authorize('view', $pendingBatch);
$batch = $this->importService->confirm(
$validated['batch_id'],
$validated['column_mapping'],
$validated['campaign_id'] ?? null,
$agentIds,
auth()->id()
);
ActivityLogger::log('lead_import_confirmed', "Import batch {$batch->id} confirmed", $batch);
return response()->json($batch->load('rows'));
}
public function rollback(int $batchId): JsonResponse
{
$batch = ImportBatch::findOrFail($batchId);
Gate::authorize('rollback', $batch);
$this->importService->rollback($batchId);
ActivityLogger::log('lead_import_rolled_back', "Import batch {$batchId} rolled back", $batch);
return response()->json(['message' => 'بازگشت import انجام شد']);
}
public function index(): JsonResponse
{
Gate::authorize('viewAny', ImportBatch::class);
return response()->json(
ImportBatch::with('user:id,name')
->orderBy('created_at', 'desc')
->paginate(15)
);
}
public function template()
{
Gate::authorize('import', ImportBatch::class);
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->fromArray([
['company', 'phone', 'phone_secondary', 'first_name', 'last_name', 'email', 'city', 'province', 'source', 'product_interest', 'priority', 'lead_score', 'interest_level', 'notes', 'tags'],
['Acme Industrial', '02112345678', '09121234567', 'Ali', 'Karimi', 'ali@example.com', 'Tehran', 'Tehran', 'Website', 'CRM', '5', '70', 'warm', 'Initial import note', 'b2b,website'],
['Pars Co', '02187654321', '', 'Sara', 'Ahmadi', 'sara@example.com', 'Shiraz', 'Fars', 'Exhibition', 'Support', '3', '40', 'cold', '', 'exhibition'],
]);
$path = storage_path('app/import-template-b2b.xlsx');
(new Xlsx($spreadsheet))->save($path);
return response()->download($path, 'sample-leads-b2b.xlsx')->deleteFileAfterSend(true);
}
}

مشاهده پرونده

@ -0,0 +1,396 @@
<?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;
}
}

مشاهده پرونده

@ -0,0 +1,77 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\LeadStatus;
use App\Services\ActivityLogger;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class LeadStatusController extends Controller
{
public function index(): JsonResponse
{
return response()->json(LeadStatus::orderBy('sort_order')->get());
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'slug' => 'required|string|max:80|unique:lead_statuses,slug',
'color' => 'nullable|string|max:20',
'icon' => 'nullable|string|max:255',
'sort_order' => 'nullable|integer',
'is_active' => 'nullable|boolean',
'is_default' => 'nullable|boolean',
'is_won' => 'nullable|boolean',
'is_lost' => 'nullable|boolean',
]);
$status = LeadStatus::create($validated);
ActivityLogger::log('lead_status_created', "Status {$status->name} created");
return response()->json($status, 201);
}
public function update(Request $request, LeadStatus $leadStatus): JsonResponse
{
$validated = $request->validate([
'name' => 'sometimes|string|max:255',
'slug' => 'nullable|string|max:80|unique:lead_statuses,slug,' . $leadStatus->id,
'color' => 'nullable|string|max:20',
'icon' => 'nullable|string|max:255',
'sort_order' => 'nullable|integer',
'is_active' => 'nullable|boolean',
'is_default' => 'nullable|boolean',
'is_won' => 'nullable|boolean',
'is_lost' => 'nullable|boolean',
]);
$leadStatus->update($validated);
ActivityLogger::log('lead_status_updated', "Status {$leadStatus->name} updated");
return response()->json($leadStatus);
}
public function destroy(LeadStatus $leadStatus): JsonResponse
{
$leadStatus->delete();
ActivityLogger::log('lead_status_deleted', "Status {$leadStatus->name} deleted");
return response()->json(['message' => 'وضعیت حذف شد']);
}
public function reorder(Request $request): JsonResponse
{
$request->validate(['order' => 'required|array', 'order.*.id' => 'exists:lead_statuses,id', 'order.*.sort_order' => 'required|integer']);
foreach ($request->order as $item) {
LeadStatus::where('id', $item['id'])->update(['sort_order' => $item['sort_order']]);
}
return response()->json(['message' => 'مرتب‌سازی انجام شد']);
}
}

مشاهده پرونده

@ -0,0 +1,57 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\LostReason;
use App\Services\ActivityLogger;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class LostReasonController extends Controller
{
public function index(): JsonResponse
{
return response()->json(LostReason::orderBy('sort_order')->get());
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'slug' => 'required|string|max:80|unique:lost_reasons,slug',
'color' => 'nullable|string|max:20',
'sort_order' => 'nullable|integer',
'is_active' => 'nullable|boolean',
]);
$reason = LostReason::create($validated);
ActivityLogger::log('lost_reason_created', "Lost reason {$reason->name} created", $reason);
return response()->json($reason, 201);
}
public function update(Request $request, LostReason $lostReason): JsonResponse
{
$validated = $request->validate([
'name' => 'sometimes|string|max:255',
'slug' => 'sometimes|string|max:80|unique:lost_reasons,slug,' . $lostReason->id,
'color' => 'nullable|string|max:20',
'sort_order' => 'nullable|integer',
'is_active' => 'nullable|boolean',
]);
$lostReason->update($validated);
ActivityLogger::log('lost_reason_updated', "Lost reason {$lostReason->name} updated", $lostReason);
return response()->json($lostReason);
}
public function destroy(LostReason $lostReason): JsonResponse
{
$lostReason->update(['is_active' => false]);
ActivityLogger::log('lost_reason_disabled', "Lost reason {$lostReason->name} disabled", $lostReason);
return response()->json(['message' => 'دلیل شکست غیرفعال شد']);
}
}

مشاهده پرونده

@ -0,0 +1,50 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Company;
use App\Models\Deal;
use App\Models\Lead;
use App\Models\Note;
use App\Services\ActivityLogger;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class NoteController extends Controller
{
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'entity_type' => 'required|in:lead,company,deal',
'entity_id' => 'required|integer',
'content' => 'required|string',
]);
$model = $this->resolve($validated['entity_type'], $validated['entity_id']);
$note = $model->notes()->create([
'user_id' => auth()->id(),
'content' => $validated['content'],
]);
ActivityLogger::log('note_created', 'یادداشت جدید ثبت شد', $model);
return response()->json($note->load('user:id,name'), 201);
}
public function destroy(Note $note): JsonResponse
{
abort_unless(auth()->id() === $note->user_id || auth()->user()?->hasRole('admin'), 403, 'دسترسی غیرمجاز');
$note->delete();
ActivityLogger::log('note_deleted', "Note {$note->id} deleted");
return response()->json(['message' => 'یادداشت حذف شد']);
}
private function resolve(string $type, int $id): Lead|Company|Deal
{
return match ($type) {
'lead' => Lead::findOrFail($id),
'company' => Company::findOrFail($id),
'deal' => Deal::findOrFail($id),
};
}
}

مشاهده پرونده

@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Notification;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class NotificationController extends Controller
{
public function index(): JsonResponse
{
$notifications = Notification::where('user_id', auth()->id())
->orderBy('created_at', 'desc')
->paginate(20);
return response()->json($notifications);
}
public function markRead(Notification $notification): JsonResponse
{
if ($notification->user_id !== auth()->id()) {
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
}
$notification->update(['is_read' => true]);
return response()->json($notification);
}
public function markAllRead(): JsonResponse
{
Notification::where('user_id', auth()->id())
->where('is_read', false)
->update(['is_read' => true]);
return response()->json(['message' => 'همه اعلان‌ها خوانده شد']);
}
public function unreadCount(): JsonResponse
{
$count = Notification::where('user_id', auth()->id())
->where('is_read', false)
->count();
return response()->json(['count' => $count]);
}
}

مشاهده پرونده

@ -0,0 +1,145 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\FollowUp;
use App\Models\PipelineStage;
use App\Services\NotificationService;
use App\Services\ActivityLogger;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class PipelineStageController extends Controller
{
public function index(): JsonResponse
{
return response()->json(
PipelineStage::withCount('leads')
->where('is_active', true)
->orderBy('sort_order')
->get()
);
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'slug' => 'required|string|max:80|unique:pipeline_stages,slug',
'color' => 'nullable|string|max:20',
'sort_order' => 'nullable|integer',
'is_active' => 'nullable|boolean',
'is_default' => 'nullable|boolean',
'is_won' => 'nullable|boolean',
'is_lost' => 'nullable|boolean',
'requires_follow_up' => 'nullable|boolean',
]);
$stage = PipelineStage::create($validated);
ActivityLogger::log('pipeline_stage_created', "Stage {$stage->name} created");
return response()->json($stage, 201);
}
public function update(Request $request, PipelineStage $pipelineStage): JsonResponse
{
$validated = $request->validate([
'name' => 'sometimes|string|max:255',
'slug' => 'nullable|string|max:80|unique:pipeline_stages,slug,' . $pipelineStage->id,
'color' => 'nullable|string|max:20',
'sort_order' => 'nullable|integer',
'is_active' => 'nullable|boolean',
'is_default' => 'nullable|boolean',
'is_won' => 'nullable|boolean',
'is_lost' => 'nullable|boolean',
'requires_follow_up' => 'nullable|boolean',
]);
$pipelineStage->update($validated);
ActivityLogger::log('pipeline_stage_updated', "Stage {$pipelineStage->name} updated");
return response()->json($pipelineStage);
}
public function destroy(PipelineStage $pipelineStage): JsonResponse
{
$pipelineStage->delete();
ActivityLogger::log('pipeline_stage_deleted', "Stage {$pipelineStage->name} deleted");
return response()->json(['message' => 'مرحله حذف شد']);
}
public function updateLeadStage(Request $request, PipelineStage $pipelineStage): JsonResponse
{
$validated = $request->validate([
'lead_id' => 'required|exists:leads,id',
'next_follow_up_at' => 'nullable|date',
'final_result' => 'nullable|in:موفق,ناموفق',
'lost_reason' => 'nullable|string|max:255',
'deal_value' => 'nullable|numeric|min:0',
'sold_product' => 'nullable|string|max:255',
'contract_date' => 'nullable|date',
'payment_status' => 'nullable|string|max:30',
'customer_notes' => 'nullable|string',
]);
$lead = \App\Models\Lead::findOrFail($validated['lead_id']);
Gate::authorize('changeStage', $lead);
$update = ['pipeline_stage_id' => $pipelineStage->id];
if ($pipelineStage->requires_follow_up) {
$request->validate(['next_follow_up_at' => 'required|date']);
$update['next_follow_up_at'] = $validated['next_follow_up_at'];
}
if ($pipelineStage->is_won || $pipelineStage->is_lost) {
$request->validate(['final_result' => 'required|in:موفق,ناموفق']);
$update['final_result'] = $validated['final_result'];
$statusName = $validated['final_result'];
$update['lead_status_id'] = \App\Models\LeadStatus::where($statusName === 'موفق' ? 'is_won' : 'is_lost', true)->value('id');
}
if (($pipelineStage->is_won || $pipelineStage->is_lost) && ($validated['final_result'] ?? null) === 'ناموفق') {
$request->validate(['lost_reason' => 'required|string|max:255|exists:lost_reasons,name']);
$update['lost_reason'] = $validated['lost_reason'];
$update['deal_value'] = null;
$update['sold_product'] = null;
$update['contract_date'] = null;
$update['payment_status'] = null;
$update['customer_notes'] = null;
}
if (($pipelineStage->is_won || $pipelineStage->is_lost) && ($validated['final_result'] ?? null) === 'موفق') {
$update['lost_reason'] = null;
$update['deal_value'] = $validated['deal_value'] ?? null;
$update['sold_product'] = $validated['sold_product'] ?? null;
$update['contract_date'] = $validated['contract_date'] ?? null;
$update['payment_status'] = $validated['payment_status'] ?? null;
$update['customer_notes'] = $validated['customer_notes'] ?? null;
}
$lead->update($update);
if ($pipelineStage->requires_follow_up) {
FollowUp::create([
'lead_id' => $lead->id,
'user_id' => $lead->assigned_to ?? auth()->id(),
'scheduled_at' => $validated['next_follow_up_at'],
'notes' => 'ثبت شده از قیف فروش',
'status' => 'pending',
]);
if ($lead->assigned_to) {
NotificationService::notifyFollowUpReminder($lead->assigned_to, $lead->company ?? $lead->full_name);
}
}
ActivityLogger::log('lead_stage_changed', "Lead {$lead->id} moved to stage {$pipelineStage->name}", $lead);
return response()->json($lead->load(['pipelineStage', 'assignedAgent', 'campaign']));
}
}

مشاهده پرونده

@ -0,0 +1,69 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Product;
use App\Services\ActivityLogger;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ProductController extends Controller
{
public function index(Request $request): JsonResponse
{
$query = Product::with('salesScript:id,title')->withCount('deals');
if ($request->search) $query->where('name', 'like', "%{$request->search}%");
if ($request->filled('is_active')) $query->where('is_active', filter_var($request->is_active, FILTER_VALIDATE_BOOLEAN));
return response()->json($query->latest()->paginate(min((int) $request->get('per_page', 15), 100)));
}
public function store(Request $request): JsonResponse
{
$this->authorizeManage();
$product = Product::create($this->validated($request) + ['created_by' => auth()->id()]);
ActivityLogger::log('product_created', "Product {$product->name} created", $product);
return response()->json($product->load('salesScript:id,title'), 201);
}
public function show(Product $product): JsonResponse
{
return response()->json($product->load('salesScript.sections', 'deals:id,title,product_id,status'));
}
public function update(Request $request, Product $product): JsonResponse
{
$this->authorizeManage();
$product->update($this->validated($request, true));
ActivityLogger::log('product_updated', "Product {$product->name} updated", $product);
return response()->json($product->fresh('salesScript:id,title'));
}
public function destroy(Product $product): JsonResponse
{
$this->authorizeManage();
$product->delete();
ActivityLogger::log('product_deleted', "Product {$product->id} deleted");
return response()->json(['message' => 'محصول/خدمت حذف شد']);
}
private function validated(Request $request, bool $partial = false): array
{
$sometimes = $partial ? 'sometimes|' : '';
return $request->validate([
'name' => $sometimes . 'required|string|max:255',
'category' => 'nullable|string|max:120',
'base_price' => 'nullable|numeric|min:0',
'description' => 'nullable|string',
'is_active' => 'nullable|boolean',
'sales_script_id' => 'nullable|exists:sales_scripts,id',
'faq' => 'nullable|array',
'objection_handling' => 'nullable|array',
]);
}
private function authorizeManage(): void
{
abort_unless(auth()->user()?->hasRole('admin') || auth()->user()?->hasRole('supervisor') || auth()->user()?->can('manage_products'), 403, 'شما مجوز مدیریت محصولات را ندارید.');
}
}

مشاهده پرونده

@ -0,0 +1,77 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\QualityReview;
use App\Services\ActivityLogger;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class QualityReviewController extends Controller
{
public function index(Request $request): JsonResponse
{
$user = auth()->user();
$query = QualityReview::with('call:id,lead_id,created_at', 'agent:id,name', 'reviewer:id,name');
if ($user->hasRole('agent')) {
$query->where('agent_id', $user->id);
} elseif ($user->hasRole('supervisor')) {
$teamIds = $user->teams->pluck('id')->toArray();
$agentIds = \App\Models\Team::whereIn('id', $teamIds)->with('members')->get()->pluck('members.*.id')->flatten();
$query->whereIn('agent_id', $agentIds);
}
return response()->json($query->orderBy('created_at', 'desc')->paginate($request->per_page ?? 15));
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'call_id' => 'required|exists:calls,id',
'agent_id' => 'required|exists:users,id',
'greeting_score' => 'required|integer|min:0|max:100',
'product_intro_score' => 'required|integer|min:0|max:100',
'needs_discovery_score' => 'required|integer|min:0|max:100',
'objection_handling_score' => 'required|integer|min:0|max:100',
'closing_score' => 'required|integer|min:0|max:100',
'crm_accuracy_score' => 'required|integer|min:0|max:100',
'follow_up_quality_score' => 'required|integer|min:0|max:100',
'feedback' => 'nullable|string',
'tag' => 'nullable|string|max:30',
]);
$validated['reviewer_id'] = auth()->id();
$validated['overall_score'] = (int) round(collect([
$validated['greeting_score'], $validated['product_intro_score'],
$validated['needs_discovery_score'], $validated['objection_handling_score'],
$validated['closing_score'], $validated['crm_accuracy_score'],
$validated['follow_up_quality_score'],
])->avg());
$review = QualityReview::create($validated);
ActivityLogger::log('quality_review_created', "Quality review for agent {$validated['agent_id']} created", $review);
return response()->json($review->load('agent:id,name', 'reviewer:id,name'), 201);
}
public function show(QualityReview $qualityReview): JsonResponse
{
return response()->json($qualityReview->load('call.lead', 'agent', 'reviewer'));
}
public function update(Request $request, QualityReview $qualityReview): JsonResponse
{
$validated = $request->validate([
'feedback' => 'nullable|string',
'tag' => 'nullable|string|max:30',
'is_shared_with_agent' => 'nullable|boolean',
]);
$qualityReview->update($validated);
return response()->json($qualityReview);
}
}

مشاهده پرونده

@ -0,0 +1,373 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Team;
use App\Models\User;
use App\Models\Campaign;
use App\Services\ActivityLogger;
use App\Services\ReportService;
use App\Support\AccessControl;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Symfony\Component\HttpFoundation\StreamedResponse;
class ReportController extends Controller
{
public function __construct(private ReportService $reportService) {}
public function agentPerformance(Request $request): JsonResponse
{
Gate::authorize('view-report-data');
$validated = $request->validate([
'agent_id' => 'nullable|exists:users,id',
'date_from' => 'nullable|date',
'date_to' => 'nullable|date',
]);
$allowedAgentIds = $this->allowedAgentIds();
if (!empty($validated['agent_id']) && !in_array((int) $validated['agent_id'], $allowedAgentIds, true)) {
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
}
if (empty($validated['agent_id'])) {
$agents = User::role('agent')->whereIn('id', $allowedAgentIds)->orderBy('name')->get();
return response()->json($agents->map(fn(User $agent) => $this->reportService->agentPerformance(
$agent->id,
$validated['date_from'] ?? null,
$validated['date_to'] ?? null
))->values());
}
return response()->json(
$this->reportService->agentPerformance(
$validated['agent_id'],
$validated['date_from'] ?? null,
$validated['date_to'] ?? null
)
);
}
public function teamPerformance(Request $request): JsonResponse
{
Gate::authorize('view-report-data');
$validated = $request->validate([
'team_id' => 'nullable|exists:teams,id',
'date_from' => 'nullable|date',
'date_to' => 'nullable|date',
]);
$allowedTeamIds = $this->allowedTeamIds();
if (!empty($validated['team_id']) && !in_array((int) $validated['team_id'], $allowedTeamIds, true)) {
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
}
if (empty($validated['team_id'])) {
$teams = Team::whereIn('id', $allowedTeamIds)->orderBy('name')->get();
return response()->json($teams->map(fn(Team $team) => $this->reportService->teamPerformance(
$team->id,
$validated['date_from'] ?? null,
$validated['date_to'] ?? null
))->values());
}
return response()->json(
$this->reportService->teamPerformance(
$validated['team_id'],
$validated['date_from'] ?? null,
$validated['date_to'] ?? null
)
);
}
public function campaignReport(Request $request, int $campaignId): JsonResponse
{
Gate::authorize('view-report-data');
$validated = $request->validate([
'date_from' => 'nullable|date',
'date_to' => 'nullable|date',
]);
$campaign = Campaign::findOrFail($campaignId);
Gate::authorize('view', $campaign);
return response()->json($this->reportService->campaignReport(
$campaignId,
$validated['date_from'] ?? null,
$validated['date_to'] ?? null
));
}
public function conversionReport(Request $request): JsonResponse
{
Gate::authorize('view-report-data');
$validated = $request->validate([
'agent_id' => 'nullable|exists:users,id',
'date_from' => 'nullable|date',
'date_to' => 'nullable|date',
]);
$agentIds = $this->requestedAgentScope($validated['agent_id'] ?? null);
if ($agentIds === false) {
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
}
return response()->json(
$this->reportService->conversionReport(
$validated['date_from'] ?? null,
$validated['date_to'] ?? null,
$validated['agent_id'] ?? null,
$agentIds
)
);
}
public function callReport(Request $request): JsonResponse
{
Gate::authorize('view-report-data');
$validated = $request->validate([
'agent_id' => 'nullable|exists:users,id',
'date_from' => 'nullable|date',
'date_to' => 'nullable|date',
]);
$agentIds = $this->requestedAgentScope($validated['agent_id'] ?? null);
if ($agentIds === false) {
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
}
return response()->json(
$this->reportService->callReport(
$validated['date_from'] ?? null,
$validated['date_to'] ?? null,
$validated['agent_id'] ?? null,
$agentIds
)
);
}
public function followUpReport(Request $request): JsonResponse
{
Gate::authorize('view-report-data');
$validated = $request->validate([
'agent_id' => 'nullable|exists:users,id',
'date_from' => 'nullable|date',
'date_to' => 'nullable|date',
]);
$agentIds = $this->requestedAgentScope($validated['agent_id'] ?? null);
if ($agentIds === false) {
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
}
return response()->json(
$this->reportService->followUpReport(
$validated['date_from'] ?? null,
$validated['date_to'] ?? null,
$validated['agent_id'] ?? null,
$agentIds
)
);
}
public function lostReasonReport(Request $request): JsonResponse
{
Gate::authorize('view-report-data');
$validated = $this->validateScopedReport($request);
$agentIds = $this->requestedAgentScope($validated['agent_id'] ?? null);
if ($agentIds === false) return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
return response()->json($this->reportService->lostReasonReport($validated['date_from'] ?? null, $validated['date_to'] ?? null, $validated['agent_id'] ?? null, $agentIds));
}
public function sourcePerformance(Request $request): JsonResponse
{
Gate::authorize('view-report-data');
$validated = $this->validateScopedReport($request);
$agentIds = $this->requestedAgentScope($validated['agent_id'] ?? null);
if ($agentIds === false) return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
return response()->json($this->reportService->sourcePerformanceReport($validated['date_from'] ?? null, $validated['date_to'] ?? null, $validated['agent_id'] ?? null, $agentIds));
}
public function duplicateLeads(Request $request): JsonResponse
{
Gate::authorize('view-report-data');
$validated = $this->validateScopedReport($request);
$agentIds = $this->requestedAgentScope($validated['agent_id'] ?? null);
if ($agentIds === false) return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
return response()->json($this->reportService->duplicateLeadsReport($validated['date_from'] ?? null, $validated['date_to'] ?? null, $validated['agent_id'] ?? null, $agentIds));
}
public function importQuality(Request $request): JsonResponse
{
Gate::authorize('view-report-data');
$validated = $request->validate(['date_from' => 'nullable|date', 'date_to' => 'nullable|date']);
return response()->json($this->reportService->importQualityReport($validated['date_from'] ?? null, $validated['date_to'] ?? null));
}
public function callQuality(Request $request): JsonResponse
{
Gate::authorize('view-report-data');
$validated = $this->validateScopedReport($request);
$agentIds = $this->requestedAgentScope($validated['agent_id'] ?? null);
if ($agentIds === false) return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
return response()->json($this->reportService->callQualityReport($validated['date_from'] ?? null, $validated['date_to'] ?? null, $validated['agent_id'] ?? null, $agentIds));
}
public function bestContactTime(Request $request): JsonResponse
{
Gate::authorize('view-report-data');
$validated = $this->validateScopedReport($request);
$agentIds = $this->requestedAgentScope($validated['agent_id'] ?? null);
if ($agentIds === false) return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
return response()->json($this->reportService->bestContactTimeReport($validated['date_from'] ?? null, $validated['date_to'] ?? null, $validated['agent_id'] ?? null, $agentIds));
}
public function exportExcel(Request $request): StreamedResponse|JsonResponse
{
Gate::authorize('export-report-data');
$type = $request->type ?? 'agent';
$agentIds = $this->requestedAgentScope($request->integer('agent_id') ?: null);
if ($agentIds === false) {
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
}
if ($request->filled('team_id') && !in_array($request->integer('team_id'), $this->allowedTeamIds(), true)) {
return response()->json(['message' => 'دسترسی غیرمجاز'], 403);
}
ActivityLogger::log('report_exported', "Report export requested: {$type}");
$data = match ($type) {
'agent' => $this->reportService->agentPerformance($request->integer('agent_id') ?: auth()->id(), $request->date_from, $request->date_to),
'team' => $this->reportService->teamPerformance($request->integer('team_id') ?: ($this->allowedTeamIds()[0] ?? 0), $request->date_from, $request->date_to),
'campaign' => $request->integer('campaign_id') ? $this->reportService->campaignReport($request->integer('campaign_id'), $request->date_from, $request->date_to) : [],
'conversion' => $this->reportService->conversionReport($request->date_from, $request->date_to, null, $agentIds),
'call' => $this->reportService->callReport($request->date_from, $request->date_to, null, $agentIds),
'follow_up' => $this->reportService->followUpReport($request->date_from, $request->date_to, null, $agentIds),
'lost_reason' => $this->reportService->lostReasonReport($request->date_from, $request->date_to, null, $agentIds),
'source' => $this->reportService->sourcePerformanceReport($request->date_from, $request->date_to, null, $agentIds),
'duplicate' => $this->reportService->duplicateLeadsReport($request->date_from, $request->date_to, null, $agentIds),
'import_quality' => $this->reportService->importQualityReport($request->date_from, $request->date_to),
'call_quality' => $this->reportService->callQualityReport($request->date_from, $request->date_to, null, $agentIds),
'best_contact_time' => $this->reportService->bestContactTimeReport($request->date_from, $request->date_to, null, $agentIds),
default => [],
};
$rows = $this->flattenForCsv($data);
$filename = 'report-' . $type . '-' . now()->format('Ymd-His') . '.csv';
return response()->streamDownload(function () use ($rows) {
$out = fopen('php://output', 'w');
fwrite($out, "\xEF\xBB\xBF");
if ($rows === []) {
fputcsv($out, ['message'], ',');
fputcsv($out, ['داده‌ای برای خروجی وجود ندارد'], ',');
fclose($out);
return;
}
fputcsv($out, array_keys($rows[0]), ',');
foreach ($rows as $row) {
fputcsv($out, array_map(fn($value) => is_scalar($value) || $value === null ? $value : json_encode($value, JSON_UNESCAPED_UNICODE), $row), ',');
}
fclose($out);
}, $filename, ['Content-Type' => 'text/csv; charset=UTF-8']);
}
private function validateScopedReport(Request $request): array
{
return $request->validate([
'agent_id' => 'nullable|exists:users,id',
'date_from' => 'nullable|date',
'date_to' => 'nullable|date',
]);
}
/**
* @return list<int>
*/
private function allowedAgentIds(): array
{
$user = auth()->user();
if ($user->hasRole('admin')) {
return User::role('agent')->pluck('id')->all();
}
if ($user->hasRole('supervisor')) {
return AccessControl::teamMemberIds($user);
}
return [$user->id];
}
/**
* @return list<int>
*/
private function allowedTeamIds(): array
{
$user = auth()->user();
if ($user->hasRole('admin')) {
return Team::pluck('id')->all();
}
if ($user->hasRole('supervisor')) {
return AccessControl::teamIds($user);
}
return [];
}
/**
* @return list<int>|false
*/
private function requestedAgentScope(?int $agentId): array|false
{
$allowedAgentIds = $this->allowedAgentIds();
if ($agentId) {
return in_array($agentId, $allowedAgentIds, true) ? [$agentId] : false;
}
return auth()->user()->hasRole('admin') ? [] : $allowedAgentIds;
}
private function flattenForCsv(mixed $data): array
{
if ($data instanceof \Illuminate\Support\Collection) {
$data = $data->toArray();
}
if ($data instanceof \Illuminate\Database\Eloquent\Model) {
$data = $data->toArray();
}
if (is_array($data) && array_is_list($data)) {
return array_map(fn($row) => is_array($row) ? $row : ['value' => $row], $data);
}
if (is_array($data)) {
foreach (['agents', 'by_stage', 'by_result', 'items', 'by_reason', 'sources', 'phone_duplicates', 'batches', 'by_agent', 'by_hour'] as $key) {
if (isset($data[$key]) && is_iterable($data[$key])) {
return collect($data[$key])->map(fn($row) => $row instanceof \Illuminate\Database\Eloquent\Model ? $row->toArray() : (array) $row)->values()->all();
}
}
return [$data];
}
return [];
}
}

مشاهده پرونده

@ -0,0 +1,90 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\ActivityLogger;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Spatie\Permission\Models\Role;
use Spatie\Permission\Models\Permission;
class RoleController extends Controller
{
public function index(): JsonResponse
{
$roles = Role::with('permissions')->orderBy('name')->get();
return response()->json($roles);
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'name' => 'required|string|unique:roles,name',
'permissions' => 'sometimes|array',
'permissions.*' => 'exists:permissions,name',
]);
$role = Role::create(['name' => $validated['name'], 'guard_name' => 'web']);
if ($request->permissions) {
$role->syncPermissions($validated['permissions']);
}
ActivityLogger::log('role_created', "Role {$role->name} created");
return response()->json($role->load('permissions'), 201);
}
public function show(Role $role): JsonResponse
{
return response()->json($role->load('permissions'));
}
public function update(Request $request, Role $role): JsonResponse
{
$validated = $request->validate([
'name' => 'sometimes|string|unique:roles,name,' . $role->id,
'permissions' => 'sometimes|array',
'permissions.*' => 'exists:permissions,name',
]);
if (isset($validated['name'])) {
$role->update(['name' => $validated['name']]);
}
if ($request->has('permissions')) {
$role->syncPermissions($validated['permissions']);
}
ActivityLogger::log('role_updated', "Role {$role->name} updated");
return response()->json($role->load('permissions'));
}
public function destroy(Role $role): JsonResponse
{
if ($role->name === 'admin') {
return response()->json(['message' => 'نقش ادمین قابل حذف نیست'], 422);
}
$role->delete();
ActivityLogger::log('role_deleted', "Role {$role->name} deleted");
return response()->json(['message' => 'نقش حذف شد']);
}
public function syncPermissions(Request $request, Role $role): JsonResponse
{
$request->validate(['permissions' => 'required|array', 'permissions.*' => 'exists:permissions,name']);
$role->syncPermissions($request->permissions);
return response()->json($role->load('permissions'));
}
public function permissions(): JsonResponse
{
return response()->json(Permission::orderBy('name')->get());
}
}

مشاهده پرونده

@ -0,0 +1,128 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\SalesScript;
use App\Services\ActivityLogger;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ScriptController extends Controller
{
public function index(): JsonResponse
{
return response()->json(
SalesScript::with('sections', 'campaign:id,name')
->orderBy('created_at', 'desc')
->paginate(15)
);
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'description' => 'nullable|string',
'campaign_id' => 'nullable|exists:campaigns,id',
'product_id' => 'nullable|exists:products,id',
'version' => 'nullable|string|max:20',
'checklist' => 'nullable|array',
'objection_handling' => 'nullable|array',
'sections' => 'nullable|array',
'sections.*.title' => 'required|string|max:255',
'sections.*.content' => 'required|string',
'sections.*.sort_order' => 'nullable|integer',
]);
$script = SalesScript::create([
'title' => $validated['title'],
'description' => $validated['description'] ?? null,
'campaign_id' => $validated['campaign_id'] ?? null,
'product_id' => $validated['product_id'] ?? null,
'version' => $validated['version'] ?? '1.0',
'is_active' => true,
'checklist' => $validated['checklist'] ?? null,
'objection_handling' => $validated['objection_handling'] ?? null,
]);
if ($request->sections) {
foreach ($validated['sections'] as $i => $section) {
$script->sections()->create([
'title' => $section['title'],
'content' => $section['content'],
'sort_order' => $section['sort_order'] ?? $i,
]);
}
}
ActivityLogger::log('script_created', "Script {$script->title} created", $script);
return response()->json($script->load('sections'), 201);
}
public function show(SalesScript $script): JsonResponse
{
return response()->json($script->load('sections', 'campaign'));
}
public function update(Request $request, SalesScript $script): JsonResponse
{
$validated = $request->validate([
'title' => 'sometimes|string|max:255',
'description' => 'nullable|string',
'campaign_id' => 'nullable|exists:campaigns,id',
'product_id' => 'nullable|exists:products,id',
'version' => 'nullable|string|max:20',
'is_active' => 'nullable|boolean',
'checklist' => 'nullable|array',
'objection_handling' => 'nullable|array',
'sections' => 'nullable|array',
'sections.*.id' => 'nullable|exists:script_sections,id',
'sections.*.title' => 'required|string|max:255',
'sections.*.content' => 'required|string',
'sections.*.sort_order' => 'nullable|integer',
]);
$script->update($validated);
if ($request->has('sections')) {
$existingIds = $script->sections->pluck('id')->toArray();
$updatedIds = [];
foreach ($validated['sections'] as $i => $section) {
if (isset($section['id']) && in_array($section['id'], $existingIds)) {
$script->sections()->where('id', $section['id'])->update([
'title' => $section['title'],
'content' => $section['content'],
'sort_order' => $section['sort_order'] ?? $i,
]);
$updatedIds[] = $section['id'];
} else {
$newSection = $script->sections()->create([
'title' => $section['title'],
'content' => $section['content'],
'sort_order' => $section['sort_order'] ?? $i,
]);
$updatedIds[] = $newSection->id;
}
}
$toDelete = array_diff($existingIds, $updatedIds);
if (!empty($toDelete)) {
$script->sections()->whereIn('id', $toDelete)->delete();
}
}
ActivityLogger::log('script_updated', "Script {$script->title} updated", $script);
return response()->json($script->load('sections'));
}
public function destroy(SalesScript $script): JsonResponse
{
$script->delete();
ActivityLogger::log('script_deleted', "Script {$script->title} deleted");
return response()->json(['message' => 'اسکریپت حذف شد']);
}
}

مشاهده پرونده

@ -0,0 +1,178 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Setting;
use App\Services\ActivityLogger;
use App\Services\VoIP\VoIPManager;
use App\Support\SettingsCatalog;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
class SettingController extends Controller
{
public function __construct(private VoIPManager $voipManager) {}
public function index(): JsonResponse
{
Gate::authorize('viewAny', Setting::class);
SettingsCatalog::ensureDefaults();
$settings = Setting::all()
->map(function (Setting $setting) {
$meta = SettingsCatalog::metaFor($setting->key) ?? [];
if ($setting->is_secret) {
$setting->value = $setting->value ? '********' : '';
}
return array_merge($setting->toArray(), [
'label' => $meta['label'] ?? $setting->key,
'hint' => $meta['hint'] ?? $setting->description,
'used_by' => $meta['used_by'] ?? [],
'coming_soon' => $meta['coming_soon'] ?? !$setting->is_runtime_enforced,
]);
})
->groupBy('group');
return response()->json($settings);
}
public function update(Request $request): JsonResponse
{
Gate::authorize('update', Setting::class);
SettingsCatalog::ensureDefaults();
$request->validate([
'settings' => 'required|array',
'settings.*.key' => 'required|string',
'settings.*.value' => 'nullable',
'settings.*.group' => 'nullable|string',
'settings.*.type' => 'nullable|string',
]);
foreach ($request->settings as $setting) {
$existing = Setting::where('key', $setting['key'])->first();
$this->validateSettingValue($setting, $existing);
$oldValue = $existing?->value;
$newValue = ($existing?->is_secret && ($setting['value'] ?? '') === '********')
? $oldValue
: $this->normalizeValue($setting['value'] ?? '', $setting['type'] ?? $existing?->type ?? 'string');
$meta = SettingsCatalog::metaFor($setting['key']);
Setting::updateOrCreate(
['key' => $setting['key']],
[
'value' => $newValue,
'group' => $meta['group'] ?? $setting['group'] ?? $existing?->group ?? 'general',
'type' => $meta['type'] ?? $setting['type'] ?? $existing?->type ?? 'string',
'default_value' => $meta['default_value'] ?? $existing?->default_value,
'allowed_values' => $meta['allowed_values'] ?? $existing?->allowed_values,
'is_secret' => $meta['is_secret'] ?? $existing?->is_secret ?? false,
'is_public' => $meta['is_public'] ?? $existing?->is_public ?? false,
'is_runtime_enforced' => $meta['is_runtime_enforced'] ?? $existing?->is_runtime_enforced ?? true,
'description' => $meta['hint'] ?? $existing?->description,
]
);
if ($this->isSensitiveChange($setting['key'], $meta ?? null)) {
$old = ($existing?->is_secret ?? false) ? '[secret]' : $oldValue;
ActivityLogger::log('sensitive_setting_changed', "Setting {$setting['key']} changed from {$old}");
}
}
ActivityLogger::log('settings_updated', 'System settings updated');
return response()->json(['message' => 'تنظیمات ذخیره شد']);
}
public function public(): JsonResponse
{
SettingsCatalog::ensureDefaults();
$settings = Setting::where('is_public', true)->get()
->filter(fn(Setting $setting) => !$setting->is_secret)
->mapWithKeys(fn(Setting $setting) => [$setting->key => $setting->effectiveValue()]);
return response()->json($settings);
}
public function testVoip(): JsonResponse
{
Gate::authorize('viewAny', Setting::class);
$provider = Setting::where('key', 'voip_provider')->value('value') ?: 'mock';
$missing = [];
if ($provider === 'ami') {
foreach (['voip_ami_host', 'voip_ami_port', 'voip_ami_username', 'voip_ami_secret', 'voip_ami_channel_technology', 'voip_ami_context'] as $key) {
if (!Setting::where('key', $key)->value('value')) $missing[] = $key;
}
}
if ($provider === 'api') {
foreach (['voip_api_base_url', 'voip_api_token'] as $key) {
if (!Setting::where('key', $key)->value('value')) $missing[] = $key;
}
}
if ($provider === 'socket') {
foreach (['voip_socket_host', 'voip_socket_port'] as $key) {
if (!Setting::where('key', $key)->value('value')) $missing[] = $key;
}
}
ActivityLogger::log('voip_connection_tested', "VoIP provider {$provider} tested");
if ($missing === []) {
$result = $this->voipManager->testConnection();
return response()->json([
'ok' => (bool) ($result['ok'] ?? false),
'provider' => $provider,
'message' => $result['message'] ?? 'نتیجه تست اتصال مشخص نیست.',
'missing' => $result['missing'] ?? [],
]);
}
return response()->json([
'ok' => !$missing,
'provider' => $provider,
'message' => $missing ? 'تنظیمات اتصال کامل نیست.' : 'تنظیمات اتصال معتبر است.',
'missing' => $missing,
]);
}
private function validateSettingValue(array $payload, ?Setting $existing): void
{
$type = $payload['type'] ?? $existing?->type ?? 'string';
$value = $payload['value'] ?? '';
$rules = $existing?->validation_rules ?: match ($type) {
'boolean' => ['in:true,false,1,0'],
'integer' => ['integer'],
default => ['nullable', 'string'],
};
if ($type === 'boolean') {
$rules = ['in:true,false,1,0'];
}
if ($existing?->allowed_values) {
$rules[] = 'in:' . implode(',', $existing->allowed_values);
}
Validator::make(['value' => $value], ['value' => $rules])->validate();
}
private function normalizeValue(mixed $value, string $type): string
{
return match ($type) {
'boolean' => filter_var($value, FILTER_VALIDATE_BOOLEAN) ? 'true' : 'false',
'integer' => (string) (int) $value,
default => (string) $value,
};
}
private function isSensitiveChange(string $key, ?array $meta): bool
{
return ($meta['is_secret'] ?? false)
|| in_array($meta['group'] ?? '', ['security', 'import_export', 'voip'], true)
|| str_contains($key, 'password')
|| str_contains($key, 'token')
|| str_contains($key, 'export');
}
}

مشاهده پرونده

@ -0,0 +1,84 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\ActivityLog;
use App\Models\Call;
use App\Models\CallLog;
use App\Models\Company;
use App\Models\Deal;
use App\Models\FollowUp;
use App\Models\Lead;
use App\Models\Note;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class TimelineController extends Controller
{
public function index(Request $request): JsonResponse
{
$validated = $request->validate([
'entity_type' => 'required|in:lead,company,deal',
'entity_id' => 'required|integer',
]);
[$class, $id] = [$this->classFor($validated['entity_type']), $validated['entity_id']];
$items = collect();
ActivityLog::with('user:id,name')->where('subject_type', $class)->where('subject_id', $id)->latest()->limit(100)->get()
->each(fn($log) => $items->push($this->item($log->created_at, $this->label($log->action), $log->description, $log->user?->name, $log->action)));
Note::with('user:id,name')->where('notable_type', $class)->where('notable_id', $id)->latest()->limit(100)->get()
->each(fn($note) => $items->push($this->item($note->created_at, 'یادداشت ثبت شد', $note->content, $note->user?->name, 'note_created')));
if ($class === Lead::class) {
CallLog::with('user:id,name')->where('lead_id', $id)->latest('called_at')->limit(100)->get()
->each(fn($call) => $items->push($this->item($call->called_at, 'تماس ثبت شد', trim(($call->result ? "نتیجه: {$call->result}. " : '') . ($call->notes ?? '')), $call->user?->name, 'call')));
FollowUp::with('user:id,name')->where('lead_id', $id)->latest('scheduled_at')->limit(100)->get()
->each(fn($followUp) => $items->push($this->item($followUp->created_at, 'پیگیری ایجاد شد', 'زمان پیگیری: ' . optional($followUp->scheduled_at)->toDateTimeString(), $followUp->user?->name, 'follow_up_created')));
}
return response()->json([
'data' => $items->sortByDesc('created_at')->values()->take(150)->all(),
]);
}
private function classFor(string $type): string
{
return match ($type) {
'lead' => Lead::class,
'company' => Company::class,
'deal' => Deal::class,
};
}
private function item($date, string $title, ?string $description, ?string $user, string $type): array
{
return [
'type' => $type,
'title' => $title,
'description' => $description,
'user' => $user ?: 'سیستم',
'created_at' => optional($date)->toDateTimeString(),
];
}
private function label(string $action): string
{
return [
'call_result_registered' => 'نتیجه تماس ثبت شد',
'lead_stage_changed' => 'مرحله تغییر کرد',
'lead_assigned' => 'مالک تغییر کرد',
'lead_owner_changed' => 'مالک تغییر کرد',
'company_created' => 'شرکت ایجاد شد',
'company_updated' => 'شرکت ویرایش شد',
'company_merged' => 'شرکت ادغام شد',
'deal_created' => 'فرصت فروش ایجاد شد',
'deal_updated' => 'فرصت فروش ویرایش شد',
'attachment_uploaded' => 'فایل بارگذاری شد',
'attachment_downloaded' => 'فایل دریافت شد',
][$action] ?? 'رویداد ثبت شد';
}
}

مشاهده پرونده

@ -0,0 +1,170 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Models\Team;
use App\Services\ActivityLogger;
use App\Support\PasswordPolicy;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Gate;
class UserController extends Controller
{
public function index(Request $request): JsonResponse
{
Gate::authorize('viewAny', User::class);
$query = User::with('roles', 'teams');
if ($request->search) {
$query->where(function ($q) use ($request) {
$q->where('name', 'like', "%{$request->search}%")
->orWhere('email', 'like', "%{$request->search}%")
->orWhere('phone', 'like', "%{$request->search}%")
->orWhere('voip_extension', 'like', "%{$request->search}%");
});
}
if ($request->role) {
$query->role($request->role);
}
if ($request->team_id) {
$query->whereHas('teams', fn($q) => $q->where('teams.id', $request->team_id));
}
if ($request->has('is_active')) {
$query->where('is_active', filter_var($request->is_active, FILTER_VALIDATE_BOOLEAN));
}
$users = $query->orderBy('created_at', 'desc')->paginate($request->per_page ?? 15);
return response()->json($users);
}
public function agents(): JsonResponse
{
$user = auth()->user();
if ($user?->hasRole('agent')) {
return response()->json([$user->load('roles', 'teams')]);
}
return response()->json(
User::role('agent')
->where('is_active', true)
->with('roles', 'teams')
->orderBy('name')
->get()
);
}
public function store(Request $request): JsonResponse
{
Gate::authorize('create', User::class);
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users',
'password' => ['required', ...PasswordPolicy::rules()],
'phone' => 'nullable|string|max:20',
'voip_extension' => ['nullable', 'string', 'max:20', 'regex:/^[0-9*#]+$/', 'unique:users,voip_extension'],
'role' => 'required|string|exists:roles,name',
]);
$user = User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => Hash::make($validated['password']),
'phone' => $validated['phone'] ?? null,
'voip_extension' => $validated['voip_extension'] ?? null,
]);
$user->assignRole($validated['role']);
ActivityLogger::log('user_created', "User {$user->email} created");
return response()->json($user->load('roles'), 201);
}
public function show(User $user): JsonResponse
{
Gate::authorize('view', $user);
return response()->json($user->load('roles.permissions', 'teams'));
}
public function update(Request $request, User $user): JsonResponse
{
Gate::authorize('update', $user);
$validated = $request->validate([
'name' => 'sometimes|string|max:255',
'email' => 'sometimes|email|unique:users,email,' . $user->id,
'password' => ['sometimes', ...PasswordPolicy::rules()],
'phone' => 'nullable|string|max:20',
'voip_extension' => ['nullable', 'string', 'max:20', 'regex:/^[0-9*#]+$/', 'unique:users,voip_extension,' . $user->id],
'is_active' => 'sometimes|boolean',
]);
if (isset($validated['password'])) {
$validated['password'] = Hash::make($validated['password']);
}
$user->update($validated);
if ($request->role) {
$user->syncRoles([$request->role]);
}
ActivityLogger::log('user_updated', "User {$user->email} updated");
return response()->json($user->load('roles', 'teams'));
}
public function destroy(User $user): JsonResponse
{
Gate::authorize('delete', $user);
$user->delete();
ActivityLogger::log('user_deleted', "User {$user->email} deleted");
return response()->json(['message' => 'کاربر حذف شد']);
}
public function toggleActive(User $user): JsonResponse
{
Gate::authorize('update', $user);
$user->update(['is_active' => !$user->is_active]);
ActivityLogger::log('user_toggled', "User {$user->email} active: {$user->is_active}");
return response()->json($user);
}
public function assignRole(Request $request, User $user): JsonResponse
{
Gate::authorize('update', $user);
$request->validate(['role' => 'required|string|exists:roles,name']);
$user->syncRoles([$request->role]);
return response()->json($user->load('roles'));
}
public function assignTeam(Request $request, User $user): JsonResponse
{
Gate::authorize('update', $user);
$request->validate(['team_ids' => 'required|array', 'team_ids.*' => 'exists:teams,id']);
$user->teams()->sync($request->team_ids);
return response()->json($user->load('teams'));
}
}

مشاهده پرونده

@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

مشاهده پرونده

@ -0,0 +1,31 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use App\Services\ActivityLogger;
class LogActivity
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
if ($request->method() !== 'GET' && auth()->check()) {
$action = $request->method() . ' ' . $request->path();
$description = null;
if (str_contains($request->path(), 'login')) {
$action = 'login';
} elseif (str_contains($request->path(), 'logout')) {
$action = 'logout';
}
ActivityLogger::log($action, $description);
}
return $response;
}
}

مشاهده پرونده

@ -0,0 +1,58 @@
<?php
namespace App\Http\Middleware;
use App\Models\Setting;
use Closure;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class MaskPhoneNumber
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
if (!$response instanceof JsonResponse) {
return $response;
}
$user = auth()->user();
$maskPhones = Setting::where('key', 'phone_mask_enabled')->value('value') !== 'false'
&& (!$user || !$user->can('view_full_phone'));
$maskRecordings = !$user || (!$user->hasRole('admin') && !$user->can('listen_recordings'));
if ($maskPhones || $maskRecordings) {
$data = $response->getData(true);
if (is_array($data)) {
$this->maskInArray($data, $maskPhones, $maskRecordings);
$response->setData($data);
}
}
return $response;
}
private function maskInArray(array &$data, bool $maskPhones, bool $maskRecordings): void
{
foreach ($data as $key => &$value) {
if ($maskPhones && in_array($key, ['phone', 'phone_secondary'], true) && is_string($value)) {
$value = $this->mask($value);
} elseif ($maskRecordings && in_array($key, ['recording_url', 'recordingUrl'], true)) {
$value = null;
} elseif (is_array($value)) {
$this->maskInArray($value, $maskPhones, $maskRecordings);
}
}
}
private function mask(string $phone): string
{
if (Setting::where('key', 'phone_mask_level')->value('value') === 'full') {
return '********';
}
if (strlen($phone) < 7) return $phone;
return substr($phone, 0, 4) . ' *** **' . substr($phone, -2);
}
}

مشاهده پرونده

@ -0,0 +1,39 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class SecurityHeaders
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
header_remove('X-Powered-By');
$headers = [
'X-Content-Type-Options' => 'nosniff',
'X-Frame-Options' => 'DENY',
'Referrer-Policy' => 'strict-origin-when-cross-origin',
'Permissions-Policy' => 'camera=(), microphone=(), geolocation=()',
'Content-Security-Policy' => "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'",
];
foreach ($headers as $name => $value) {
if (!$response->headers->has($name)) {
$response->headers->set($name, $value);
}
}
if ($request->is('api/*') || $request->is('sanctum/*')) {
$response->headers->set('Cache-Control', 'no-store, no-cache, must-revalidate, private');
$response->headers->set('Pragma', 'no-cache');
$response->headers->set('Expires', '0');
}
return $response;
}
}

مشاهده پرونده

@ -0,0 +1,19 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ActivityLog extends Model
{
protected $fillable = [
'user_id', 'action', 'description',
'subject_type', 'subject_id', 'ip_address', 'user_agent',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

مشاهده پرونده

@ -0,0 +1,28 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class Attachment extends Model
{
use SoftDeletes;
protected $fillable = [
'attachable_type', 'attachable_id', 'uploaded_by', 'original_name',
'path', 'mime_type', 'size',
];
public function attachable(): MorphTo
{
return $this->morphTo();
}
public function uploader(): BelongsTo
{
return $this->belongsTo(User::class, 'uploaded_by');
}
}

45
backend/app/Models/Call.php normal فایل
مشاهده پرونده

@ -0,0 +1,45 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
class Call extends Model
{
protected $fillable = [
'lead_id', 'contact_id', 'contact_phone_id', 'user_id', 'direction', 'phone', 'duration',
'result', 'notes', 'provider_call_id', 'recording_url', 'is_manual',
];
protected function casts(): array
{
return ['is_manual' => 'boolean', 'duration' => 'integer'];
}
public function lead(): BelongsTo
{
return $this->belongsTo(Lead::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function contact(): BelongsTo
{
return $this->belongsTo(Contact::class);
}
public function contactPhone(): BelongsTo
{
return $this->belongsTo(ContactPhone::class);
}
public function qualityReview(): HasOne
{
return $this->hasOne(QualityReview::class);
}
}

39
backend/app/Models/CallLog.php normal فایل
مشاهده پرونده

@ -0,0 +1,39 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CallLog extends Model
{
protected $fillable = [
'lead_id', 'contact_id', 'contact_phone_id', 'call_id',
'user_id', 'called_at', 'result', 'notes', 'next_action', 'recording_url',
];
protected function casts(): array
{
return ['called_at' => 'datetime'];
}
public function lead(): BelongsTo
{
return $this->belongsTo(Lead::class);
}
public function contact(): BelongsTo
{
return $this->belongsTo(Contact::class);
}
public function phone(): BelongsTo
{
return $this->belongsTo(ContactPhone::class, 'contact_phone_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

مشاهده پرونده

@ -0,0 +1,36 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class CallResult extends Model
{
protected $fillable = [
'name', 'slug', 'color', 'requires_follow_up', 'is_positive',
'is_negative', 'is_final', 'next_pipeline_stage_id', 'lead_status_id',
'phone_status', 'next_action', 'sort_order', 'is_active',
];
protected function casts(): array
{
return [
'requires_follow_up' => 'boolean',
'is_positive' => 'boolean',
'is_negative' => 'boolean',
'is_final' => 'boolean',
'is_active' => 'boolean',
'sort_order' => 'integer',
];
}
public function nextPipelineStage()
{
return $this->belongsTo(PipelineStage::class, 'next_pipeline_stage_id');
}
public function leadStatus()
{
return $this->belongsTo(LeadStatus::class);
}
}

49
backend/app/Models/Campaign.php normal فایل
مشاهده پرونده

@ -0,0 +1,49 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Campaign extends Model
{
protected $fillable = [
'name',
'description',
'product_service',
'start_date',
'end_date',
'target',
'status',
'sales_script_id',
];
protected function casts(): array
{
return [
'start_date' => 'date',
'end_date' => 'date',
'target' => 'integer',
];
}
public function assignedAgents(): BelongsToMany
{
return $this->belongsToMany(User::class)
->wherePivot('role', 'agent')
->withPivot('role');
}
public function assignedSupervisors(): BelongsToMany
{
return $this->belongsToMany(User::class)
->wherePivot('role', 'supervisor')
->withPivot('role');
}
public function leads(): HasMany
{
return $this->hasMany(Lead::class);
}
}

49
backend/app/Models/Company.php normal فایل
مشاهده پرونده

@ -0,0 +1,49 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Company extends Model
{
use SoftDeletes;
protected $fillable = [
'name', 'normalized_name', 'company_type', 'industry', 'city', 'address',
'website', 'normalized_website', 'description', 'status', 'owner_id', 'created_by',
];
public function owner(): BelongsTo
{
return $this->belongsTo(User::class, 'owner_id');
}
public function contacts(): HasMany
{
return $this->hasMany(Contact::class);
}
public function leads(): HasMany
{
return $this->hasMany(Lead::class);
}
public function deals(): HasMany
{
return $this->hasMany(Deal::class);
}
public function notes(): MorphMany
{
return $this->morphMany(Note::class, 'notable');
}
public function attachments(): MorphMany
{
return $this->morphMany(Attachment::class, 'attachable');
}
}

46
backend/app/Models/Contact.php normal فایل
مشاهده پرونده

@ -0,0 +1,46 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Contact extends Model
{
protected $fillable = [
'lead_id', 'company_id', 'deal_id', 'name', 'first_name', 'last_name',
'role', 'job_title', 'email', 'preferred_channel', 'description', 'status',
'is_primary', 'primary_reason', 'created_by',
];
protected function casts(): array
{
return ['is_primary' => 'boolean'];
}
public function lead(): BelongsTo
{
return $this->belongsTo(Lead::class);
}
public function company(): BelongsTo
{
return $this->belongsTo(Company::class);
}
public function deal(): BelongsTo
{
return $this->belongsTo(Deal::class);
}
public function phones(): HasMany
{
return $this->hasMany(ContactPhone::class);
}
public function callLogs(): HasMany
{
return $this->hasMany(CallLog::class);
}
}

مشاهده پرونده

@ -0,0 +1,41 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class ContactPhone extends Model
{
protected $fillable = [
'contact_id', 'phone', 'type', 'status', 'call_count',
'successful_call_count', 'failed_call_count', 'last_called_at',
'last_call_result', 'last_called_by',
];
protected function casts(): array
{
return [
'call_count' => 'integer',
'successful_call_count' => 'integer',
'failed_call_count' => 'integer',
'last_called_at' => 'datetime',
];
}
public function contact(): BelongsTo
{
return $this->belongsTo(Contact::class);
}
public function lastCaller(): BelongsTo
{
return $this->belongsTo(User::class, 'last_called_by');
}
public function callLogs(): HasMany
{
return $this->hasMany(CallLog::class);
}
}

مشاهده پرونده

@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ContactRelation extends Model
{
protected $fillable = [
'lead_id', 'from_contact_id', 'to_contact_id',
'relation_type', 'description', 'created_by',
];
public function fromContact(): BelongsTo
{
return $this->belongsTo(Contact::class, 'from_contact_id');
}
public function toContact(): BelongsTo
{
return $this->belongsTo(Contact::class, 'to_contact_id');
}
}

63
backend/app/Models/Deal.php normal فایل
مشاهده پرونده

@ -0,0 +1,63 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Deal extends Model
{
use SoftDeletes;
protected $fillable = [
'title', 'company_id', 'lead_id', 'contact_id', 'product_id',
'estimated_value', 'win_probability', 'sales_stage', 'expected_close_date',
'owner_id', 'status', 'won_lost_reason', 'notes', 'created_by',
];
protected function casts(): array
{
return [
'estimated_value' => 'decimal:2',
'win_probability' => 'integer',
'expected_close_date' => 'date',
];
}
public function company(): BelongsTo
{
return $this->belongsTo(Company::class);
}
public function lead(): BelongsTo
{
return $this->belongsTo(Lead::class);
}
public function contact(): BelongsTo
{
return $this->belongsTo(Contact::class);
}
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
public function owner(): BelongsTo
{
return $this->belongsTo(User::class, 'owner_id');
}
public function notes(): MorphMany
{
return $this->morphMany(Note::class, 'notable');
}
public function attachments(): MorphMany
{
return $this->morphMany(Attachment::class, 'attachable');
}
}

38
backend/app/Models/FollowUp.php normal فایل
مشاهده پرونده

@ -0,0 +1,38 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class FollowUp extends Model
{
protected $fillable = [
'lead_id', 'user_id', 'call_id', 'scheduled_at',
'completed_at', 'notes', 'status', 'is_overdue',
];
protected function casts(): array
{
return [
'scheduled_at' => 'datetime',
'completed_at' => 'datetime',
'is_overdue' => 'boolean',
];
}
public function lead(): BelongsTo
{
return $this->belongsTo(Lead::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function call(): BelongsTo
{
return $this->belongsTo(Call::class);
}
}

مشاهده پرونده

@ -0,0 +1,43 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class ImportBatch extends Model
{
protected $fillable = [
'user_id', 'filename', 'total_rows', 'imported_rows',
'skipped_rows', 'failed_rows', 'status', 'column_mapping',
'campaign_id', 'errors', 'can_rollback',
];
protected function casts(): array
{
return [
'column_mapping' => 'array',
'can_rollback' => 'boolean',
'total_rows' => 'integer',
'imported_rows' => 'integer',
'skipped_rows' => 'integer',
'failed_rows' => 'integer',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function campaign(): BelongsTo
{
return $this->belongsTo(Campaign::class);
}
public function rows(): HasMany
{
return $this->hasMany(ImportBatchRow::class);
}
}

مشاهده پرونده

@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ImportBatchRow extends Model
{
protected $fillable = ['import_batch_id', 'lead_id', 'original_data', 'status', 'error'];
protected function casts(): array
{
return ['original_data' => 'array'];
}
public function importBatch(): BelongsTo
{
return $this->belongsTo(ImportBatch::class);
}
public function lead(): BelongsTo
{
return $this->belongsTo(Lead::class);
}
}

166
backend/app/Models/Lead.php normal فایل
مشاهده پرونده

@ -0,0 +1,166 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Lead extends Model
{
use SoftDeletes;
protected $fillable = [
'company_id', 'first_name', 'last_name', 'company', 'phone', 'phone_secondary',
'email', 'city', 'province', 'source', 'product_interest',
'priority', 'lead_score', 'notes', 'tags',
'interest_level', 'call_attempts', 'lost_reason', 'final_result',
'deal_value', 'sold_product', 'contract_date', 'payment_status', 'customer_notes',
'lead_status_id', 'pipeline_stage_id', 'campaign_id',
'assigned_to', 'assigned_by', 'team_id',
'last_call_at', 'last_call_result', 'next_follow_up_at', 'is_unassigned',
];
protected function casts(): array
{
return [
'priority' => 'integer',
'lead_score' => 'integer',
'call_attempts' => 'integer',
'deal_value' => 'decimal:2',
'is_unassigned' => 'boolean',
'last_call_at' => 'datetime',
'next_follow_up_at' => 'datetime',
'contract_date' => 'date',
];
}
public function getFullNameAttribute(): string
{
return $this->first_name . ' ' . $this->last_name;
}
public function getMaskedPhoneAttribute(): string
{
$phone = $this->phone;
if (strlen($phone) < 7) return $phone;
return substr($phone, 0, 4) . ' *** **' . substr($phone, -2);
}
public function leadStatus(): BelongsTo
{
return $this->belongsTo(LeadStatus::class);
}
public function account(): BelongsTo
{
return $this->belongsTo(Company::class, 'company_id');
}
public function pipelineStage(): BelongsTo
{
return $this->belongsTo(PipelineStage::class);
}
public function campaign(): BelongsTo
{
return $this->belongsTo(Campaign::class);
}
public function assignedAgent(): BelongsTo
{
return $this->belongsTo(User::class, 'assigned_to');
}
public function assignedByUser(): BelongsTo
{
return $this->belongsTo(User::class, 'assigned_by');
}
public function team(): BelongsTo
{
return $this->belongsTo(Team::class);
}
public function calls(): HasMany
{
return $this->hasMany(Call::class);
}
public function contacts(): HasMany
{
return $this->hasMany(Contact::class);
}
public function primaryContact(): HasMany
{
return $this->hasMany(Contact::class)->where('is_primary', true);
}
public function contactRelations(): HasMany
{
return $this->hasMany(ContactRelation::class);
}
public function callLogs(): HasMany
{
return $this->hasMany(CallLog::class);
}
public function followUps(): HasMany
{
return $this->hasMany(FollowUp::class);
}
public function notes(): MorphMany
{
return $this->morphMany(Note::class, 'notable');
}
public function attachments(): MorphMany
{
return $this->morphMany(Attachment::class, 'attachable');
}
public function deals(): HasMany
{
return $this->hasMany(Deal::class);
}
public function leadAssignments(): HasMany
{
return $this->hasMany(LeadAssignment::class);
}
public function scopeAssignedTo($query, $userId)
{
return $query->where('assigned_to', $userId);
}
public function scopeUnassigned($query)
{
return $query->where('is_unassigned', true);
}
public function scopeByTeam($query, $teamId)
{
return $query->where('team_id', $teamId);
}
public function scopeByCampaign($query, $campaignId)
{
return $query->where('campaign_id', $campaignId);
}
public function scopeByStatus($query, $statusId)
{
return $query->where('lead_status_id', $statusId);
}
public function scopeByPipelineStage($query, $stageId)
{
return $query->where('pipeline_stage_id', $stageId);
}
}

مشاهده پرونده

@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class LeadAssignment extends Model
{
protected $fillable = ['lead_id', 'user_id', 'assigned_by', 'method'];
public function lead(): BelongsTo
{
return $this->belongsTo(Lead::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function assignedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'assigned_by');
}
}

مشاهده پرونده

@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class LeadStatus extends Model
{
protected $fillable = ['name', 'slug', 'color', 'icon', 'sort_order', 'is_active', 'is_default', 'is_won', 'is_lost'];
protected function casts(): array
{
return ['is_active' => 'boolean', 'is_default' => 'boolean', 'is_won' => 'boolean', 'is_lost' => 'boolean'];
}
public function leads(): HasMany
{
return $this->hasMany(Lead::class);
}
}

مشاهده پرونده

@ -0,0 +1,18 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class LostReason extends Model
{
protected $fillable = ['name', 'slug', 'color', 'sort_order', 'is_active'];
protected function casts(): array
{
return [
'sort_order' => 'integer',
'is_active' => 'boolean',
];
}
}

مشاهده پرونده

@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MergeHistory extends Model
{
protected $fillable = ['entity_type', 'source_id', 'target_id', 'merged_fields', 'merged_by'];
protected function casts(): array
{
return ['merged_fields' => 'array'];
}
public function mergedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'merged_by');
}
}

22
backend/app/Models/Note.php normal فایل
مشاهده پرونده

@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class Note extends Model
{
protected $fillable = ['content', 'user_id'];
public function notable(): MorphTo
{
return $this->morphTo();
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

مشاهده پرونده

@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Notification extends Model
{
protected $fillable = ['user_id', 'title', 'message', 'type', 'data', 'is_read'];
protected $table = 'internal_notifications';
protected function casts(): array
{
return [
'data' => 'array',
'is_read' => 'boolean',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

مشاهده پرونده

@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class PipelineStage extends Model
{
protected $fillable = ['name', 'slug', 'color', 'sort_order', 'is_active', 'is_default', 'is_won', 'is_lost', 'requires_follow_up'];
protected function casts(): array
{
return ['is_active' => 'boolean', 'is_default' => 'boolean', 'is_won' => 'boolean', 'is_lost' => 'boolean', 'requires_follow_up' => 'boolean'];
}
public function leads(): HasMany
{
return $this->hasMany(Lead::class);
}
}

38
backend/app/Models/Product.php normal فایل
مشاهده پرونده

@ -0,0 +1,38 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Product extends Model
{
use SoftDeletes;
protected $fillable = [
'name', 'category', 'base_price', 'description', 'is_active',
'sales_script_id', 'faq', 'objection_handling', 'created_by',
];
protected function casts(): array
{
return [
'base_price' => 'decimal:2',
'is_active' => 'boolean',
'faq' => 'array',
'objection_handling' => 'array',
];
}
public function salesScript(): BelongsTo
{
return $this->belongsTo(SalesScript::class);
}
public function deals(): HasMany
{
return $this->hasMany(Deal::class);
}
}

مشاهده پرونده

@ -0,0 +1,37 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class QualityReview extends Model
{
protected $fillable = [
'call_id', 'reviewer_id', 'agent_id',
'greeting_score', 'product_intro_score', 'needs_discovery_score',
'objection_handling_score', 'closing_score', 'crm_accuracy_score',
'follow_up_quality_score', 'overall_score',
'feedback', 'tag', 'is_shared_with_agent',
];
protected function casts(): array
{
return ['is_shared_with_agent' => 'boolean'];
}
public function call(): BelongsTo
{
return $this->belongsTo(Call::class);
}
public function reviewer(): BelongsTo
{
return $this->belongsTo(User::class, 'reviewer_id');
}
public function agent(): BelongsTo
{
return $this->belongsTo(User::class, 'agent_id');
}
}

مشاهده پرونده

@ -0,0 +1,36 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class SalesScript extends Model
{
protected $fillable = ['title', 'description', 'campaign_id', 'product_id', 'version', 'is_active', 'checklist', 'objection_handling'];
protected function casts(): array
{
return [
'is_active' => 'boolean',
'checklist' => 'array',
'objection_handling' => 'array',
];
}
public function campaign(): BelongsTo
{
return $this->belongsTo(Campaign::class);
}
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
public function sections(): HasMany
{
return $this->hasMany(ScriptSection::class);
}
}

مشاهده پرونده

@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ScriptSection extends Model
{
protected $fillable = ['sales_script_id', 'title', 'content', 'sort_order'];
protected function casts(): array
{
return ['sort_order' => 'integer'];
}
public function salesScript(): BelongsTo
{
return $this->belongsTo(SalesScript::class);
}
}

29
backend/app/Models/Setting.php normal فایل
مشاهده پرونده

@ -0,0 +1,29 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Setting extends Model
{
protected $fillable = [
'key', 'value', 'default_value', 'group', 'type', 'validation_rules',
'allowed_values', 'is_secret', 'is_public', 'is_runtime_enforced', 'description',
];
protected function casts(): array
{
return [
'validation_rules' => 'array',
'allowed_values' => 'array',
'is_secret' => 'boolean',
'is_public' => 'boolean',
'is_runtime_enforced' => 'boolean',
];
}
public function effectiveValue(): mixed
{
return $this->value ?? $this->default_value;
}
}

38
backend/app/Models/Team.php normal فایل
مشاهده پرونده

@ -0,0 +1,38 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Team extends Model
{
protected $fillable = ['name', 'description', 'supervisor_id', 'is_active'];
protected function casts(): array
{
return ['is_active' => 'boolean'];
}
public function supervisor(): BelongsTo
{
return $this->belongsTo(User::class, 'supervisor_id');
}
public function members(): BelongsToMany
{
return $this->belongsToMany(User::class);
}
public function leads(): HasMany
{
return $this->hasMany(Lead::class);
}
public function campaigns(): HasMany
{
return $this->hasMany(Campaign::class);
}
}

95
backend/app/Models/User.php normal فایل
مشاهده پرونده

@ -0,0 +1,95 @@
<?php
namespace App\Models;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\Storage;
use Laravel\Sanctum\HasApiTokens;
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable, HasApiTokens, HasRoles, SoftDeletes;
protected $fillable = [
'name', 'email', 'password', 'phone', 'voip_extension', 'avatar', 'is_active',
'last_login_at', 'last_login_ip', 'current_login_at',
'last_logout_at', 'total_presence_seconds', 'sales_agent_preferred_view',
];
protected $hidden = [
'password', 'remember_token',
];
protected $appends = [
'avatar_url',
];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'is_active' => 'boolean',
'last_login_at' => 'datetime',
'current_login_at' => 'datetime',
'last_logout_at' => 'datetime',
'total_presence_seconds' => 'integer',
];
}
public function teams(): BelongsToMany
{
return $this->belongsToMany(Team::class);
}
public function assignedLeads(): HasMany
{
return $this->hasMany(Lead::class, 'assigned_to');
}
public function calls(): HasMany
{
return $this->hasMany(Call::class);
}
public function followUps(): HasMany
{
return $this->hasMany(FollowUp::class);
}
public function leadAssignments(): HasMany
{
return $this->hasMany(LeadAssignment::class, 'assigned_by');
}
protected function avatarUrl(): Attribute
{
return Attribute::get(function (): ?string {
if (!$this->avatar) {
return null;
}
$path = parse_url($this->avatar, PHP_URL_PATH) ?: $this->avatar;
$path = ltrim($path, '/');
if (str_starts_with($path, 'storage/')) {
$path = substr($path, strlen('storage/'));
}
if (str_starts_with($path, 'public/')) {
$path = substr($path, strlen('public/'));
}
return url(Storage::url($path));
});
}
}

مشاهده پرونده

@ -0,0 +1,36 @@
<?php
namespace App\Policies;
use App\Models\Call;
use App\Models\Lead;
use App\Models\User;
use App\Support\AccessControl;
class CallPolicy
{
public function viewAny(User $user): bool
{
return $user->hasRole('admin') || $user->can('view_all_calls') || $user->can('view_team_calls') || $user->can('view_own_calls');
}
public function view(User $user, Call $call): bool
{
return AccessControl::canAccessCall($user, $call);
}
public function create(User $user, Lead $lead): bool
{
return AccessControl::canAccessLead($user, $lead, allowUnassignedClaim: true);
}
public function registerResult(User $user, Call $call): bool
{
return AccessControl::canAccessCall($user, $call);
}
public function viewRecording(User $user, Call $call): bool
{
return AccessControl::canAccessCall($user, $call) && ($user->hasRole('admin') || $user->can('listen_recordings'));
}
}

مشاهده پرونده

@ -0,0 +1,35 @@
<?php
namespace App\Policies;
use App\Models\Campaign;
use App\Models\User;
use App\Support\AccessControl;
class CampaignPolicy
{
public function viewAny(User $user): bool
{
return $user->hasAnyRole(['admin', 'supervisor', 'agent']);
}
public function view(User $user, Campaign $campaign): bool
{
return AccessControl::canAccessCampaign($user, $campaign);
}
public function create(User $user): bool
{
return $user->hasRole('admin') || $user->can('manage_campaigns');
}
public function update(User $user, Campaign $campaign): bool
{
return AccessControl::canAccessCampaign($user, $campaign) && ($user->hasRole('admin') || $user->can('manage_campaigns'));
}
public function delete(User $user, Campaign $campaign): bool
{
return $this->update($user, $campaign);
}
}

مشاهده پرونده

@ -0,0 +1,31 @@
<?php
namespace App\Policies;
use App\Models\FollowUp;
use App\Models\Lead;
use App\Models\User;
use App\Support\AccessControl;
class FollowUpPolicy
{
public function viewAny(User $user): bool
{
return $user->hasAnyRole(['admin', 'supervisor', 'agent']);
}
public function view(User $user, FollowUp $followUp): bool
{
return AccessControl::canAccessFollowUp($user, $followUp);
}
public function create(User $user, Lead $lead): bool
{
return AccessControl::canAccessLead($user, $lead);
}
public function update(User $user, FollowUp $followUp): bool
{
return AccessControl::canAccessFollowUp($user, $followUp);
}
}

مشاهده پرونده

@ -0,0 +1,29 @@
<?php
namespace App\Policies;
use App\Models\ImportBatch;
use App\Models\User;
class ImportBatchPolicy
{
public function viewAny(User $user): bool
{
return $user->hasRole('admin') || $user->can('import_leads');
}
public function import(User $user): bool
{
return $user->hasRole('admin') || $user->can('import_leads') || $user->hasRole('agent');
}
public function view(User $user, ImportBatch $batch): bool
{
return $user->hasRole('admin') || $batch->user_id === $user->id;
}
public function rollback(User $user, ImportBatch $batch): bool
{
return $user->hasRole('admin') && (bool) $batch->can_rollback;
}
}

مشاهده پرونده

@ -0,0 +1,45 @@
<?php
namespace App\Policies;
use App\Models\Lead;
use App\Models\User;
use App\Support\AccessControl;
class LeadPolicy
{
public function viewAny(User $user): bool
{
return $user->hasAnyRole(['admin', 'supervisor', 'agent']);
}
public function view(User $user, Lead $lead): bool
{
return AccessControl::canAccessLead($user, $lead);
}
public function create(User $user): bool
{
return $user->hasRole('admin') || $user->can('create_leads') || $user->hasRole('supervisor') || $user->hasRole('agent');
}
public function update(User $user, Lead $lead): bool
{
return AccessControl::canAccessLead($user, $lead) && ($user->hasRole('admin') || $user->can('edit_leads') || $user->hasRole('agent'));
}
public function delete(User $user, Lead $lead): bool
{
return AccessControl::canAccessLead($user, $lead) && ($user->hasRole('admin') || $user->can('delete_leads'));
}
public function assign(User $user, Lead $lead): bool
{
return AccessControl::canAccessLead($user, $lead) && ($user->hasRole('admin') || $user->can('assign_leads') || $user->can('reassign_leads'));
}
public function changeStage(User $user, Lead $lead): bool
{
return AccessControl::canAccessLead($user, $lead) && ($user->hasRole('admin') || $user->can('edit_leads') || $user->hasRole('agent'));
}
}

مشاهده پرونده

@ -0,0 +1,18 @@
<?php
namespace App\Policies;
use App\Models\User;
class SettingPolicy
{
public function viewAny(User $user): bool
{
return $user->hasRole('admin') || $user->can('manage_settings');
}
public function update(User $user): bool
{
return $user->hasRole('admin') || $user->can('manage_settings');
}
}

مشاهده پرونده

@ -0,0 +1,33 @@
<?php
namespace App\Policies;
use App\Models\User;
class UserPolicy
{
public function viewAny(User $user): bool
{
return $user->hasRole('admin') || $user->can('manage_users');
}
public function view(User $user, User $target): bool
{
return $user->id === $target->id || $user->hasRole('admin') || $user->can('manage_users');
}
public function create(User $user): bool
{
return $user->hasRole('admin') || $user->can('manage_users');
}
public function update(User $user, User $target): bool
{
return ($user->hasRole('admin') || $user->can('manage_users')) && $target->exists;
}
public function delete(User $user, User $target): bool
{
return ($user->hasRole('admin') || $user->can('manage_users')) && $user->id !== $target->id;
}
}

مشاهده پرونده

@ -0,0 +1,49 @@
<?php
namespace App\Providers;
use App\Models\Call;
use App\Models\Campaign;
use App\Models\FollowUp;
use App\Models\ImportBatch;
use App\Models\Lead;
use App\Models\Setting;
use App\Models\User;
use App\Policies\CallPolicy;
use App\Policies\CampaignPolicy;
use App\Policies\FollowUpPolicy;
use App\Policies\ImportBatchPolicy;
use App\Policies\LeadPolicy;
use App\Policies\SettingPolicy;
use App\Policies\UserPolicy;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
Gate::policy(Lead::class, LeadPolicy::class);
Gate::policy(Call::class, CallPolicy::class);
Gate::policy(FollowUp::class, FollowUpPolicy::class);
Gate::policy(Campaign::class, CampaignPolicy::class);
Gate::policy(User::class, UserPolicy::class);
Gate::policy(Setting::class, SettingPolicy::class);
Gate::policy(ImportBatch::class, ImportBatchPolicy::class);
Gate::define('view-report-data', fn(User $user) => $user->hasRole('admin') || $user->can('view_reports'));
Gate::define('export-report-data', fn(User $user) => $user->hasRole('admin') || $user->can('export_reports'));
Gate::define('export-sensitive-data', fn(User $user) => $user->hasRole('admin') || $user->can('export_leads') || $user->can('export_reports'));
}
}

مشاهده پرونده

@ -0,0 +1,26 @@
<?php
namespace App\Services;
use App\Models\ActivityLog;
use Illuminate\Database\Eloquent\Model;
class ActivityLogger
{
public static function log(string $action, ?string $description = null, ?Model $subject = null): void
{
try {
ActivityLog::create([
'user_id' => auth()->id(),
'action' => $action,
'description' => $description,
'subject_type' => $subject ? get_class($subject) : null,
'subject_id' => $subject?->id,
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
]);
} catch (\Exception $e) {
// Silent fail for logging
}
}
}

مشاهده پرونده

@ -0,0 +1,54 @@
<?php
namespace App\Services;
use App\Models\Lead;
use App\Models\User;
use App\Models\LeadAssignment;
use Illuminate\Support\Facades\DB;
class AssignmentService
{
public function __construct(private LeadService $leadService) {}
public function assignToAgent(int $leadId, int $agentId, int $assignedById): Lead
{
$lead = Lead::findOrFail($leadId);
return $this->leadService->assignLead($lead, $agentId, $assignedById);
}
public function bulkAssign(array $leadIds, int $agentId, int $assignedById): array
{
$results = [];
foreach ($leadIds as $leadId) {
$results[] = $this->assignToAgent($leadId, $agentId, $assignedById);
}
return $results;
}
public function roundRobin(array $leadIds, array $agentIds, int $assignedById): array
{
$leads = Lead::whereIn('id', $leadIds)->get();
return $this->leadService->roundRobinAssign($leads, $agentIds, $assignedById);
}
public function assignByCampaign(int $campaignId, array $agentIds, int $assignedById): array
{
$leads = Lead::where('campaign_id', $campaignId)->where('is_unassigned', true)->get();
return $this->leadService->roundRobinAssign($leads, $agentIds, $assignedById);
}
public function returnToPool(int $leadId): Lead
{
$lead = Lead::findOrFail($leadId);
$lead->update([
'assigned_to' => null,
'assigned_by' => null,
'is_unassigned' => true,
]);
ActivityLogger::log('lead_returned_to_pool', "Lead {$leadId} returned to unassigned pool", $lead);
return $lead->fresh();
}
}

مشاهده پرونده

@ -0,0 +1,310 @@
<?php
namespace App\Services;
use App\Models\Call;
use App\Models\CallLog;
use App\Models\CallResult;
use App\Models\Contact;
use App\Models\ContactPhone;
use App\Models\ContactRelation;
use App\Models\Lead;
use App\Models\LeadStatus;
use App\Models\FollowUp;
use App\Models\PipelineStage;
use App\Models\Setting;
use App\Models\User;
use App\Services\ActivityLogger;
use App\Services\VoIP\VoIPManager;
use App\Support\WorkingHours;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class CallService
{
public function __construct(private VoIPManager $voipManager) {}
public function initiateCall(int $leadId, int $userId, ?int $contactPhoneId = null, bool $includePhone = true): array
{
$lead = Lead::with('contacts.phones')->findOrFail($leadId);
$user = User::findOrFail($userId);
$callerExtension = trim((string) ($user->voip_extension ?: ''));
$providerName = Setting::where('key', 'voip_provider')->value('value') ?: 'mock';
if ($providerName === 'ami' && $callerExtension === '') {
throw ValidationException::withMessages([
'voip_extension' => ['شماره داخلی برای این کاربر ثبت نشده است.'],
]);
}
if (!$lead->assigned_to) {
$lead->update([
'assigned_to' => $userId,
'assigned_by' => $userId,
'is_unassigned' => false,
'pipeline_stage_id' => PipelineStage::where('slug', 'waiting_call')->value('id') ?? $lead->pipeline_stage_id,
]);
\App\Models\LeadAssignment::create([
'lead_id' => $lead->id,
'user_id' => $userId,
'assigned_by' => $userId,
'method' => 'call_claim',
]);
}
$phone = $this->resolvePhone($lead, $contactPhoneId);
$callerId = $callerExtension !== '' ? $callerExtension : (string) $userId;
$providerResult = $this->voipManager->initiateCall($phone->phone, $callerId);
if (($providerResult['success'] ?? false) !== true) {
throw ValidationException::withMessages([
'voip' => [$providerResult['message'] ?? 'درخواست تماس به مرکز تلفن ارسال نشد.'],
]);
}
$providerCallId = $providerResult['provider_call_id'] ?? null;
$recordingEnabled = Setting::where('key', 'call_recording_enabled')->value('value') === 'true';
$call = Call::create([
'lead_id' => $leadId,
'contact_id' => $phone->contact_id,
'contact_phone_id' => $phone->id,
'user_id' => $userId,
'direction' => 'outbound',
'phone' => $phone->phone,
'result' => null,
'provider_call_id' => $providerCallId,
'recording_url' => $recordingEnabled && $providerCallId ? $this->voipManager->getRecordingUrl($providerCallId) : null,
'is_manual' => true,
]);
$phone->increment('call_count');
$phone->update([
'last_called_at' => now(),
'last_called_by' => $userId,
]);
CallLog::create([
'lead_id' => $leadId,
'contact_id' => $phone->contact_id,
'contact_phone_id' => $phone->id,
'call_id' => $call->id,
'user_id' => $userId,
'called_at' => now(),
'recording_url' => $call->recording_url,
]);
$lead->update([
'last_call_at' => now(),
'call_attempts' => $lead->call_attempts + 1,
]);
ActivityLogger::log('call_initiated', "Call initiated for lead {$leadId}", $call);
$payload = [
'call_id' => $call->id,
'contact_id' => $phone->contact_id,
'contact_phone_id' => $phone->id,
'lead_name' => $lead->full_name,
'recording_enabled' => $recordingEnabled,
'recording_url' => $call->recording_url,
'provider_call_id' => $providerCallId,
];
if ($includePhone) {
$payload['phone'] = $phone->phone;
}
return $payload;
}
public function registerResult(int $callId, string $result, ?string $notes = null, ?string $followUpAt = null, ?array $referral = null, array $options = []): Call
{
$call = Call::findOrFail($callId);
return DB::transaction(function () use ($call, $callId, $result, $notes, $followUpAt, $referral, $options) {
$call->update([
'result' => $result,
'notes' => $notes,
]);
$lead = $call->lead;
$lead->update([
'last_call_result' => $result,
'last_call_at' => now(),
]);
$callResult = CallResult::where('name', $result)->orWhere('slug', $result)->first();
$nextAction = $callResult?->next_action ?? ($followUpAt ? 'پیگیری در زمان مشخص‌شده' : null);
if ($callResult?->next_pipeline_stage_id) {
$lead->update(['pipeline_stage_id' => $callResult->next_pipeline_stage_id]);
} elseif ($followUpAt && $followUpStageId = PipelineStage::where('slug', 'follow_up')->value('id')) {
$lead->update(['pipeline_stage_id' => $followUpStageId]);
}
if ($callResult?->lead_status_id) {
$lead->update(['lead_status_id' => $callResult->lead_status_id]);
}
if ($call->contactPhone) {
$success = (bool) $callResult?->is_positive;
$call->contactPhone->increment($success ? 'successful_call_count' : 'failed_call_count');
$call->contactPhone->update([
'last_call_result' => $result,
'last_called_at' => now(),
'last_called_by' => $call->user_id,
'status' => $callResult?->phone_status ?? $call->contactPhone->status,
]);
}
$newContact = null;
if (in_array($result, ['معرفی شماره یا شخص جدید', 'معرفی شماره جدید'], true) && $referral) {
$newContact = $this->createReferralContact($lead, $call, $referral, $notes);
$nextAction = 'تماس با مخاطب معرفی‌شده';
$followUpAt = $referral['next_follow_up_at'] ?? $followUpAt;
}
if ($result === 'شماره اشتباه' && ($options['mark_phone_wrong'] ?? false) && $call->contactPhone) {
$call->contactPhone->update(['status' => 'wrong']);
ActivityLogger::log('contact_phone_marked_wrong', "Contact phone {$call->contactPhone->id} marked wrong from mobile call result", $call->contactPhone);
}
if ($result === 'عدم تمایل') {
$reason = $options['disinterest_reason'] ?? null;
if (($options['close_lead'] ?? false) === true) {
$lead->update([
'final_result' => 'ناموفق',
'lost_reason' => $reason,
'lead_status_id' => LeadStatus::where('slug', 'lost')->value('id') ?? $lead->lead_status_id,
'pipeline_stage_id' => PipelineStage::where('slug', 'closed')->value('id') ?? $lead->pipeline_stage_id,
'next_follow_up_at' => null,
]);
$nextAction = 'لید بسته شد';
ActivityLogger::log('lead_closed_from_mobile_call_result', "Lead {$lead->id} closed after disinterest: {$reason}", $lead);
} elseif ($call->contact) {
$call->contact->update(['status' => 'inactive']);
$nextAction = 'مخاطب غیرفعال شد';
ActivityLogger::log('contact_marked_inactive_from_mobile_call_result', "Contact {$call->contact->id} marked inactive after disinterest: {$reason}", $call->contact);
}
}
if ($followUpAt) {
if (!WorkingHours::followUpAllowed($followUpAt)) {
throw \Illuminate\Validation\ValidationException::withMessages([
'next_follow_up_at' => ['زمان پیگیری خارج از روز یا ساعت کاری مجاز است.'],
]);
}
$lead->update(['next_follow_up_at' => $followUpAt]);
FollowUp::create([
'lead_id' => $lead->id,
'user_id' => $call->user_id,
'call_id' => $call->id,
'scheduled_at' => $followUpAt,
'status' => 'pending',
'notes' => $newContact ? "پیگیری مخاطب معرفی‌شده: {$newContact->name}" : $notes,
]);
NotificationService::notifyFollowUpReminder($call->user_id, $lead->full_name ?: ($lead->company ?? "لید {$lead->id}"));
}
CallLog::updateOrCreate(
['call_id' => $call->id],
[
'lead_id' => $lead->id,
'contact_id' => $call->contact_id,
'contact_phone_id' => $call->contact_phone_id,
'user_id' => $call->user_id,
'called_at' => $call->created_at ?? now(),
'result' => $result,
'notes' => $notes,
'next_action' => $nextAction,
'recording_url' => $call->recording_url,
]
);
ActivityLogger::log('call_result_registered', "Call {$callId} result: {$result}", $call);
return $call->fresh(['lead', 'contact', 'contactPhone']);
});
}
private function resolvePhone(Lead $lead, ?int $contactPhoneId): ContactPhone
{
if ($contactPhoneId) {
return ContactPhone::whereHas('contact', fn($query) => $query->where('lead_id', $lead->id))
->findOrFail($contactPhoneId);
}
$contact = $lead->contacts->firstWhere('is_primary', true) ?? $lead->contacts->first();
if ($contact) {
$phone = $contact->phones->firstWhere('status', 'active') ?? $contact->phones->first();
if ($phone) {
return $phone;
}
}
$contact = Contact::create([
'lead_id' => $lead->id,
'name' => $lead->full_name ?: ($lead->company ?? 'مخاطب اولیه'),
'role' => 'رابط',
'status' => 'active',
'is_primary' => true,
'primary_reason' => 'مخاطب اولیه لید',
'created_by' => $lead->assigned_to,
]);
return ContactPhone::create([
'contact_id' => $contact->id,
'phone' => $lead->phone,
'type' => 'mobile',
'status' => 'active',
]);
}
private function createReferralContact(Lead $lead, Call $call, array $referral, ?string $notes): Contact
{
$makePrimary = (bool) ($referral['make_primary'] ?? false);
if ($makePrimary) {
Contact::where('lead_id', $lead->id)->update(['is_primary' => false]);
}
$fromContact = $call->contact;
$primaryReason = $fromContact
? "معرفی‌شده توسط {$fromContact->name}"
: 'معرفی‌شده در تماس';
$contact = Contact::create([
'lead_id' => $lead->id,
'name' => $referral['name'],
'role' => $referral['role'] ?? null,
'description' => $referral['description'] ?? $notes,
'status' => 'active',
'is_primary' => $makePrimary,
'primary_reason' => $makePrimary ? $primaryReason : null,
'created_by' => $call->user_id,
]);
ContactPhone::create([
'contact_id' => $contact->id,
'phone' => $referral['phone'],
'type' => $referral['phone_type'] ?? 'mobile',
'status' => 'active',
]);
ContactRelation::create([
'lead_id' => $lead->id,
'from_contact_id' => $call->contact_id,
'to_contact_id' => $contact->id,
'relation_type' => 'introduced',
'description' => $referral['relation_description'] ?? null,
'created_by' => $call->user_id,
]);
if ($makePrimary) {
ActivityLogger::log('primary_contact_changed', "Primary contact changed to {$contact->name}: {$primaryReason}", $lead);
}
return $contact;
}
}

مشاهده پرونده

@ -0,0 +1,419 @@
<?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 'تماس بعدی پیشنهادی را از صف تماس بردارید.';
}
}

مشاهده پرونده

@ -0,0 +1,110 @@
<?php
namespace App\Services;
use App\Models\Company;
use App\Models\Contact;
use App\Models\Lead;
use App\Models\MergeHistory;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class DuplicateService
{
public static function normalizePhone(?string $phone): ?string
{
if (!$phone) return null;
$digits = preg_replace('/\D+/', '', strtr($phone, ['۰'=>'0','۱'=>'1','۲'=>'2','۳'=>'3','۴'=>'4','۵'=>'5','۶'=>'6','۷'=>'7','۸'=>'8','۹'=>'9','٠'=>'0','١'=>'1','٢'=>'2','٣'=>'3','٤'=>'4','٥'=>'5','٦'=>'6','٧'=>'7','٨'=>'8','٩'=>'9']));
if (!$digits) return null;
if (str_starts_with($digits, '0098')) $digits = '0' . substr($digits, 4);
if (str_starts_with($digits, '98')) $digits = '0' . substr($digits, 2);
return $digits;
}
public static function normalizeWebsite(?string $website): ?string
{
if (!$website) return null;
$value = strtolower(trim($website));
$value = preg_replace('#^https?://#', '', $value);
$value = preg_replace('#^www\.#', '', $value);
return rtrim($value, '/');
}
public static function normalizeName(?string $name): ?string
{
return $name ? Str::of($name)->lower()->squish()->toString() : null;
}
public function companySuggestions(array $payload, ?int $excludeId = null): array
{
$name = self::normalizeName($payload['name'] ?? null);
$website = self::normalizeWebsite($payload['website'] ?? null);
$email = strtolower((string) ($payload['email'] ?? ''));
$phone = self::normalizePhone($payload['phone'] ?? null);
$query = Company::query()->with('owner:id,name')->limit(10);
if ($excludeId) $query->whereKeyNot($excludeId);
$query->where(function ($q) use ($name, $website, $email, $phone) {
if ($name) $q->orWhere('normalized_name', $name);
if ($website) $q->orWhere('normalized_website', $website);
if ($email) $q->orWhereHas('contacts', fn($contact) => $contact->where('email', $email));
if ($phone) {
$q->orWhereHas('contacts.phones', fn($phoneQuery) => $phoneQuery->where('phone', 'like', "%{$phone}%"));
}
});
return $query->get()->map(fn(Company $company) => [
'id' => $company->id,
'type' => 'company',
'title' => $company->name,
'reason' => 'شباهت در نام شرکت، وب‌سایت، ایمیل یا شماره تماس',
])->values()->all();
}
public function leadSuggestions(array $payload, ?int $excludeId = null): array
{
$phone = self::normalizePhone($payload['phone'] ?? null);
$email = strtolower((string) ($payload['email'] ?? ''));
$company = self::normalizeName($payload['company'] ?? null);
$query = Lead::query()->limit(10);
if ($excludeId) $query->whereKeyNot($excludeId);
$query->where(function ($q) use ($phone, $email, $company) {
if ($phone) $q->orWhere('phone', 'like', "%{$phone}%")->orWhere('phone_secondary', 'like', "%{$phone}%");
if ($email) $q->orWhere('email', $email);
if ($company) $q->orWhereRaw('LOWER(company) = ?', [$company]);
});
return $query->get()->map(fn(Lead $lead) => [
'id' => $lead->id,
'type' => 'lead',
'title' => $lead->company ?: $lead->full_name,
'reason' => 'شباهت در تلفن، ایمیل یا نام شرکت',
])->values()->all();
}
public function mergeCompanies(Company $source, Company $target, int $userId): Company
{
return DB::transaction(function () use ($source, $target, $userId) {
Contact::where('company_id', $source->id)->update(['company_id' => $target->id]);
Lead::where('company_id', $source->id)->update(['company_id' => $target->id]);
\App\Models\Deal::where('company_id', $source->id)->update(['company_id' => $target->id]);
\App\Models\Attachment::where('attachable_type', Company::class)->where('attachable_id', $source->id)->update(['attachable_id' => $target->id]);
\App\Models\Note::where('notable_type', Company::class)->where('notable_id', $source->id)->update(['notable_id' => $target->id]);
MergeHistory::create([
'entity_type' => 'company',
'source_id' => $source->id,
'target_id' => $target->id,
'merged_fields' => $source->only(['name', 'website', 'city', 'industry']),
'merged_by' => $userId,
]);
$source->delete();
ActivityLogger::log('company_merged', "Company {$source->id} merged into {$target->id}", $target);
return $target->fresh(['contacts.phones', 'leads', 'deals']);
});
}
}

مشاهده پرونده

@ -0,0 +1,194 @@
<?php
namespace App\Services;
use App\Models\ImportBatch;
use App\Models\ImportBatchRow;
use App\Models\Contact;
use App\Models\ContactPhone;
use App\Models\Lead;
use App\Models\LeadStatus;
use App\Models\PipelineStage;
use App\Models\Setting;
use App\Services\ActivityLogger;
use App\Services\NotificationService;
use Maatwebsite\Excel\Facades\Excel;
use Illuminate\Support\Facades\DB;
class ImportService
{
public function preview(string $filePath): array
{
$rows = Excel::toArray([], $filePath)[0] ?? [];
if (empty($rows)) {
throw new \Exception('فایل اکسل خالی است');
}
$headers = $rows[0];
$dataRows = array_slice($rows, 1);
return [
'headers' => $headers,
'total_rows' => count($dataRows),
'preview' => array_slice($dataRows, 0, 5),
];
}
public function confirm(int $batchId, array $columnMapping, ?int $campaignId = null, ?array $agentIds = null, ?int $assignedById = null): ImportBatch
{
$batch = ImportBatch::with('rows')->findOrFail($batchId);
$defaultStatus = LeadStatus::where('is_default', true)->first();
$defaultStage = PipelineStage::where('is_default', true)->first();
$assignedStage = PipelineStage::where('slug', 'waiting_call')->first();
$duplicatePolicy = Setting::where('key', 'duplicate_phone_policy')->value('value') ?? 'warn';
DB::transaction(function () use ($batch, $columnMapping, $campaignId, $agentIds, $assignedById, $defaultStatus, $defaultStage, $assignedStage, $duplicatePolicy) {
$agentIndex = 0;
$agentCount = count($agentIds ?? []);
foreach ($batch->rows as $row) {
if ($row->status !== 'pending') continue;
$data = $row->original_data;
$leadData = $this->mapColumns($data, $columnMapping);
if (!$leadData['company']) {
$row->update(['status' => 'failed', 'error' => 'نام کسب‌وکار الزامی است']);
$batch->increment('failed_rows');
continue;
}
if (!$leadData['phone']) {
$row->update(['status' => 'failed', 'error' => 'شماره تلفن الزامی است']);
$batch->increment('failed_rows');
continue;
}
$existing = Lead::where('phone', $leadData['phone'])->first();
if ($existing) {
if ($duplicatePolicy === 'allow') {
// Continue and create a new lead below.
} elseif ($duplicatePolicy === 'block') {
$row->update(['status' => 'failed', 'lead_id' => $existing->id, 'error' => 'شماره تلفن تکراری است']);
$batch->increment('failed_rows');
continue;
} else {
$row->update(['status' => 'skipped', 'lead_id' => $existing->id, 'error' => 'شماره تلفن تکراری است؛ نیازمند بررسی یا ادغام']);
$batch->increment('skipped_rows');
continue;
}
}
$leadData['first_name'] = $leadData['first_name'] ?: $leadData['company'];
$leadData['last_name'] = $leadData['last_name'] ?: 'رابط';
$leadData['lead_status_id'] = $defaultStatus?->id;
$leadData['pipeline_stage_id'] = $defaultStage?->id;
$leadData['campaign_id'] = $campaignId;
$leadData['priority'] = isset($leadData['priority']) && $leadData['priority'] !== '' ? (int) $leadData['priority'] : 0;
$leadData['lead_score'] = isset($leadData['lead_score']) && $leadData['lead_score'] !== '' ? (int) $leadData['lead_score'] : 0;
$leadData['interest_level'] = $leadData['interest_level'] ?: 'cold';
if ($agentCount > 0) {
$leadData['assigned_to'] = $agentIds[$agentIndex % $agentCount];
$leadData['assigned_by'] = $assignedById;
$leadData['is_unassigned'] = false;
$leadData['pipeline_stage_id'] = $assignedStage?->id ?? $leadData['pipeline_stage_id'];
$agentIndex++;
}
$lead = Lead::create($leadData);
$this->createPrimaryContact($lead, $assignedById);
if ($leadData['assigned_to'] ?? null) {
\App\Models\LeadAssignment::create([
'lead_id' => $lead->id,
'user_id' => $leadData['assigned_to'],
'assigned_by' => $assignedById,
'method' => 'import',
]);
NotificationService::notifyAssignment((int) $leadData['assigned_to'], $lead->full_name ?: ($lead->company ?? "لید {$lead->id}"));
}
$row->update(['status' => 'imported', 'lead_id' => $lead->id]);
$batch->increment('imported_rows');
}
$batch->update([
'campaign_id' => $campaignId,
'column_mapping' => $columnMapping,
'status' => 'completed',
]);
});
ActivityLogger::log('import_completed', "Import batch {$batchId} completed", $batch);
return $batch->fresh();
}
public function rollback(int $batchId): void
{
$batch = ImportBatch::with('rows')->findOrFail($batchId);
DB::transaction(function () use ($batch) {
foreach ($batch->rows as $row) {
if ($row->status === 'imported' && $row->lead_id) {
$row->lead?->delete();
$row->update(['status' => 'rolled_back']);
}
}
$batch->update(['status' => 'rolled_back', 'can_rollback' => false]);
});
ActivityLogger::log('import_rolled_back', "Import batch {$batchId} rolled back", $batch);
}
private function mapColumns(array $data, array $mapping): array
{
$leadData = [
'company' => null,
'first_name' => null,
'last_name' => null,
'phone' => null,
'phone_secondary' => null,
'email' => null,
'city' => null,
'province' => null,
'source' => null,
'product_interest' => null,
'priority' => null,
'lead_score' => null,
'interest_level' => null,
'notes' => null,
'tags' => null,
];
foreach ($mapping as $field => $columnIndex) {
if (isset($data[$columnIndex])) {
$leadData[$field] = is_string($data[$columnIndex]) ? trim($data[$columnIndex]) : $data[$columnIndex];
}
}
return $leadData;
}
private function createPrimaryContact(Lead $lead, ?int $createdById): void
{
$contact = Contact::create([
'lead_id' => $lead->id,
'name' => trim(($lead->first_name ?? '') . ' ' . ($lead->last_name ?? '')) ?: ($lead->company ?? 'مخاطب اولیه'),
'role' => 'رابط',
'description' => 'مخاطب اولیه import',
'status' => 'active',
'is_primary' => true,
'primary_reason' => 'مخاطب اولیه import',
'created_by' => $createdById,
]);
foreach (array_filter([$lead->phone, $lead->phone_secondary]) as $phone) {
ContactPhone::create([
'contact_id' => $contact->id,
'phone' => $phone,
'type' => 'mobile',
'status' => 'active',
]);
}
}
}

مشاهده پرونده

@ -0,0 +1,216 @@
<?php
namespace App\Services;
use App\Models\Lead;
use App\Models\PipelineStage;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
class LeadService
{
public function getFilteredLeads(array $filters): LengthAwarePaginator
{
$query = $this->getFilteredLeadQuery($filters);
$perPage = min(max((int) ($filters['per_page'] ?? 15), 1), 100);
return $query->paginate($perPage);
}
public function getFilteredLeadQuery(array $filters): Builder
{
$filters = $this->normalizeFilters($filters);
$query = Lead::with([
'leadStatus',
'pipelineStage',
'campaign',
'assignedAgent',
'team',
'contacts.phones.lastCaller:id,name',
]);
if (!empty($filters['search'])) {
$search = $filters['search'];
$query->where(function ($q) use ($search) {
$q->where('first_name', 'like', "%{$search}%")
->orWhere('last_name', 'like', "%{$search}%")
->orWhere('phone', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%")
->orWhere('company', 'like', "%{$search}%");
});
}
if (isset($filters['status_id'])) {
$query->where('lead_status_id', $filters['status_id']);
}
if (isset($filters['pipeline_stage_id'])) {
$query->where('pipeline_stage_id', $filters['pipeline_stage_id']);
}
if (!empty($filters['include_unassigned_for_agent']) && isset($filters['assigned_to'])) {
$query->where(function ($q) use ($filters) {
$q->where('assigned_to', $filters['assigned_to'])
->orWhere(function ($inner) {
$inner->whereNull('assigned_to')->orWhere('is_unassigned', true);
});
});
} elseif (isset($filters['assigned_to'])) {
$query->where('assigned_to', $filters['assigned_to']);
}
if (isset($filters['team_id'])) {
$query->where('team_id', $filters['team_id']);
}
if (isset($filters['campaign_id'])) {
$query->where('campaign_id', $filters['campaign_id']);
}
if (isset($filters['call_result'])) {
$query->where('last_call_result', $filters['call_result']);
}
if (isset($filters['interest_level'])) {
$query->where('interest_level', $filters['interest_level']);
}
if (isset($filters['overdue_follow_up']) && filter_var($filters['overdue_follow_up'], FILTER_VALIDATE_BOOLEAN)) {
$query->whereNotNull('next_follow_up_at')->where('next_follow_up_at', '<', now());
}
if (isset($filters['source'])) {
$query->where('source', $filters['source']);
}
if (isset($filters['priority'])) {
$query->where('priority', $filters['priority']);
}
if (isset($filters['is_unassigned'])) {
$query->where('is_unassigned', filter_var($filters['is_unassigned'], FILTER_VALIDATE_BOOLEAN));
}
if (isset($filters['date_from'])) {
$query->whereDate('created_at', '>=', $filters['date_from']);
}
if (isset($filters['date_to'])) {
$query->whereDate('created_at', '<=', $filters['date_to']);
}
if (isset($filters['has_follow_up'])) {
if (filter_var($filters['has_follow_up'], FILTER_VALIDATE_BOOLEAN)) {
$query->whereNotNull('next_follow_up_at');
} else {
$query->whereNull('next_follow_up_at');
}
}
$allowedSortFields = [
'created_at', 'updated_at', 'first_name', 'last_name', 'company',
'priority', 'lead_score', 'last_call_at', 'next_follow_up_at',
];
$sortField = in_array($filters['sort_field'] ?? null, $allowedSortFields, true)
? $filters['sort_field']
: 'created_at';
$sortDirection = strtolower((string) ($filters['sort_direction'] ?? 'desc')) === 'asc' ? 'asc' : 'desc';
$query->orderBy($sortField, $sortDirection);
return $query;
}
public function getGroupedFunnel(array $filters, int $perStage = 25): array
{
$perStage = min(max($perStage, 1), 100);
$baseQuery = $this->getFilteredLeadQuery($filters);
$counts = (clone $baseQuery)
->reorder()
->selectRaw('pipeline_stage_id, count(*) as aggregate')
->groupBy('pipeline_stage_id')
->pluck('aggregate', 'pipeline_stage_id');
$stages = PipelineStage::orderBy('sort_order')->get();
return [
'total' => (int) $counts->sum(),
'stages' => $stages->map(function (PipelineStage $stage) use ($baseQuery, $counts, $perStage) {
$page = (clone $baseQuery)
->where('pipeline_stage_id', $stage->id)
->paginate($perStage, ['*'], "stage_{$stage->id}_page");
return [
'stage' => $stage,
'count' => (int) ($counts[$stage->id] ?? 0),
'leads' => $page->items(),
'pagination' => [
'current_page' => $page->currentPage(),
'last_page' => $page->lastPage(),
'per_page' => $page->perPage(),
'total' => $page->total(),
],
];
})->values(),
];
}
private function normalizeFilters(array $filters): array
{
$aliases = [
'status' => 'status_id',
'stage' => 'pipeline_stage_id',
'agent' => 'assigned_to',
'sort' => 'sort_field',
'direction' => 'sort_direction',
];
foreach ($aliases as $alias => $canonical) {
if (!isset($filters[$canonical]) && isset($filters[$alias])) {
$filters[$canonical] = $filters[$alias];
}
}
return array_filter($filters, fn($value) => $value !== '' && $value !== null);
}
public function assignLead(Lead $lead, int $agentId, int $assignedById, string $method = 'manual'): Lead
{
$lead->update([
'assigned_to' => $agentId,
'assigned_by' => $assignedById,
'is_unassigned' => false,
'pipeline_stage_id' => PipelineStage::where('slug', 'waiting_call')->value('id') ?? $lead->pipeline_stage_id,
]);
\App\Models\LeadAssignment::create([
'lead_id' => $lead->id,
'user_id' => $agentId,
'assigned_by' => $assignedById,
'method' => $method,
]);
NotificationService::notifyAssignment($agentId, $lead->full_name ?: ($lead->company ?? "لید {$lead->id}"));
ActivityLogger::log('lead_assigned', "Lead {$lead->id} assigned to user {$agentId}", $lead);
return $lead->fresh();
}
public function reassignLead(Lead $lead, int $newAgentId, int $assignedById): Lead
{
return $this->assignLead($lead, $newAgentId, $assignedById, 'reassign');
}
public function roundRobinAssign($leads, array $agentIds, int $assignedById): array
{
$results = [];
$index = 0;
foreach ($leads as $lead) {
$agentId = $agentIds[$index % count($agentIds)];
$results[] = $this->assignLead($lead, $agentId, $assignedById, 'round_robin');
$index++;
}
return $results;
}
}

مشاهده پرونده

@ -0,0 +1,79 @@
<?php
namespace App\Services;
use App\Models\Notification as NotificationModel;
use App\Models\Setting;
use App\Models\User;
class NotificationService
{
public static function send(int $userId, string $title, string $message, string $type = 'info', ?array $data = null): NotificationModel
{
if (!self::enabled($type)) {
return new NotificationModel();
}
return NotificationModel::create([
'user_id' => $userId,
'title' => $title,
'message' => $message,
'type' => $type,
'data' => $data,
'is_read' => false,
]);
}
public static function sendOnce(int $userId, string $title, string $message, string $type = 'info', ?array $data = null): ?NotificationModel
{
$exists = NotificationModel::where('user_id', $userId)
->where('type', $type)
->where('data->follow_up_id', $data['follow_up_id'] ?? null)
->whereDate('created_at', now()->toDateString())
->exists();
return $exists ? null : self::send($userId, $title, $message, $type, $data);
}
public static function notifyAssignment(int $userId, string $leadName): void
{
self::send($userId, 'لید جدید', "لید {$leadName} به شما اختصاص داده شد", 'assignment');
}
public static function notifyFollowUpReminder(int $userId, string $leadName): void
{
self::send($userId, 'یادآوری پیگیری', "موعد پیگیری لید {$leadName} رسیده است", 'follow_up');
}
public static function notifyFeedback(int $userId, string $agentName): void
{
self::send($userId, 'بازخورد جدید', "بازخورد جدیدی برای شما ثبت شده است", 'feedback');
}
public static function notifyReassignment(int $userId, string $leadName): void
{
self::send($userId, 'تغییر وضعیت لید', "لید {$leadName} دوباره به شما اختصاص داده شد", 'reassignment');
}
public static function notifyToAllAgents(string $title, string $message, string $type = 'system'): void
{
$agents = User::role('agent')->get();
foreach ($agents as $agent) {
self::send($agent->id, $title, $message, $type);
}
}
private static function enabled(string $type): bool
{
if (Setting::where('key', 'in_app_notifications_enabled')->value('value') === 'false') {
return false;
}
return match ($type) {
'assignment', 'reassignment' => Setting::where('key', 'assigned_lead_notification_enabled')->value('value') !== 'false',
'follow_up' => Setting::where('key', 'follow_up_notification_enabled')->value('value') !== 'false',
'overdue_follow_up' => Setting::where('key', 'overdue_notification_enabled')->value('value') !== 'false',
default => true,
};
}
}

مشاهده پرونده

@ -0,0 +1,416 @@
<?php
namespace App\Services;
use App\Models\Lead;
use App\Models\Call;
use App\Models\FollowUp;
use App\Models\ImportBatch;
use App\Models\MergeHistory;
use App\Models\QualityReview;
use App\Models\User;
use App\Models\Team;
use App\Models\CallResult;
use Carbon\Carbon;
class ReportService
{
public function agentPerformance(int $agentId, ?string $dateFrom = null, ?string $dateTo = null): array
{
$agent = User::findOrFail($agentId);
$callsQuery = Call::where('user_id', $agentId);
$leadsQuery = Lead::where('assigned_to', $agentId);
if ($dateFrom) {
$callsQuery->whereDate('created_at', '>=', $dateFrom);
$leadsQuery->whereDate('created_at', '>=', $dateFrom);
}
if ($dateTo) {
$callsQuery->whereDate('created_at', '<=', $dateTo);
$leadsQuery->whereDate('created_at', '<=', $dateTo);
}
$totalCalls = (clone $callsQuery)->count();
$answeredCalls = (clone $callsQuery)->whereIn('result', $this->successfulResultNames())->count();
$totalLeads = (clone $leadsQuery)->count();
$wonLeads = (clone $leadsQuery)->where('final_result', 'موفق')->count();
return [
'agent' => $agent->only(['id', 'name', 'email']),
'total_calls' => $totalCalls,
'answered_calls' => $answeredCalls,
'no_answer_calls' => $totalCalls - $answeredCalls,
'answer_rate' => $totalCalls > 0 ? round(($answeredCalls / $totalCalls) * 100, 1) : 0,
'total_leads_assigned' => $totalLeads,
'won_sales' => $wonLeads,
'conversion_rate' => $totalLeads > 0 ? round(($wonLeads / $totalLeads) * 100, 1) : 0,
'avg_call_duration' => (clone $callsQuery)->avg('duration') ?? 0,
];
}
public function teamPerformance(int $teamId, ?string $dateFrom = null, ?string $dateTo = null): array
{
$team = Team::with('members')->findOrFail($teamId);
$agentIds = $team->members->pluck('id')->toArray();
$totalCalls = Call::whereIn('user_id', $agentIds);
$totalLeads = Lead::whereIn('assigned_to', $agentIds);
if ($dateFrom) {
$totalCalls->whereDate('created_at', '>=', $dateFrom);
$totalLeads->whereDate('created_at', '>=', $dateFrom);
}
if ($dateTo) {
$totalCalls->whereDate('created_at', '<=', $dateTo);
$totalLeads->whereDate('created_at', '<=', $dateTo);
}
$wonLeads = (clone $totalLeads)->where('final_result', 'موفق')->count();
$agentPerformance = [];
foreach ($team->members as $member) {
$agentPerformance[] = $this->agentPerformance($member->id, $dateFrom, $dateTo);
}
return [
'team' => $team->only(['id', 'name']),
'member_count' => count($agentIds),
'total_calls' => $totalCalls->count(),
'total_leads' => $totalLeads->count(),
'won_sales' => $wonLeads,
'conversion_rate' => $totalLeads->count() > 0 ? round(($wonLeads / $totalLeads->count()) * 100, 1) : 0,
'agents' => $agentPerformance,
];
}
public function campaignReport(int $campaignId, ?string $dateFrom = null, ?string $dateTo = null): array
{
$campaign = \App\Models\Campaign::with('assignedAgents')->findOrFail($campaignId);
$leads = Lead::where('campaign_id', $campaignId);
if ($dateFrom) {
$leads->whereDate('created_at', '>=', $dateFrom);
}
if ($dateTo) {
$leads->whereDate('created_at', '<=', $dateTo);
}
$leadIds = (clone $leads)->pluck('id');
$calls = Call::whereIn('lead_id', $leadIds);
if ($dateFrom) {
$calls->whereDate('created_at', '>=', $dateFrom);
}
if ($dateTo) {
$calls->whereDate('created_at', '<=', $dateTo);
}
$wonLeads = (clone $leads)->where('final_result', 'موفق')->count();
$totalLeads = (clone $leads)->count();
return [
'campaign' => $campaign->only(['id', 'name', 'target', 'start_date', 'end_date', 'status']),
'total_leads' => $totalLeads,
'assigned_leads' => (clone $leads)->where('is_unassigned', false)->count(),
'total_calls' => (clone $calls)->count(),
'won_sales' => $wonLeads,
'target_progress' => $campaign->target > 0 ? round(($wonLeads / $campaign->target) * 100, 1) : 0,
'conversion_rate' => $totalLeads > 0 ? round(($wonLeads / $totalLeads) * 100, 1) : 0,
];
}
public function conversionReport(?string $dateFrom = null, ?string $dateTo = null, ?int $agentId = null, ?array $agentIds = null): array
{
$leadsQuery = Lead::query();
if ($dateFrom) $leadsQuery->whereDate('created_at', '>=', $dateFrom);
if ($dateTo) $leadsQuery->whereDate('created_at', '<=', $dateTo);
if ($agentId) $leadsQuery->where('assigned_to', $agentId);
elseif (is_array($agentIds) && $agentIds !== []) $leadsQuery->whereIn('assigned_to', $agentIds);
elseif (is_array($agentIds)) $leadsQuery->whereRaw('1 = 0');
$total = (clone $leadsQuery)->count();
$won = (clone $leadsQuery)->where('final_result', 'موفق')->count();
$lost = (clone $leadsQuery)->where('final_result', 'ناموفق')->count();
$today = Carbon::today();
$now = Carbon::now();
$stages = \App\Models\PipelineStage::orderBy('sort_order')->get();
$byStage = [];
foreach ($stages as $stage) {
$count = (clone $leadsQuery)->where('pipeline_stage_id', $stage->id)->count();
$byStage[] = [
'name' => $stage->name,
'color' => $stage->color,
'count' => $count,
'percentage' => $total > 0 ? round(($count / $total) * 100, 1) : 0,
];
}
return [
'total_leads' => $total,
'by_stage' => $byStage,
'conversion_rate' => $total > 0 ? round(($won / $total) * 100, 1) : 0,
'won_leads' => $won,
'lost_leads' => $lost,
'follow_ups_due_today' => $this->filterFollowUpsByAgent(
FollowUp::whereDate('scheduled_at', $today)->where('status', 'pending'),
$agentId,
$agentIds
)->count(),
'overdue_follow_ups' => $this->filterFollowUpsByAgent(
FollowUp::where('scheduled_at', '<', $now)->where('status', 'pending'),
$agentId,
$agentIds
)->count(),
'lost_reason_analysis' => (clone $leadsQuery)
->where('final_result', 'ناموفق')
->whereNotNull('lost_reason')
->selectRaw('lost_reason as name, count(*) as count')
->groupBy('lost_reason')
->orderByDesc('count')
->get(),
'proposal_sent_not_closed' => (clone $leadsQuery)
->whereHas('pipelineStage', fn($q) => $q->where('slug', 'proposal_sent'))
->whereNull('final_result')
->count(),
];
}
public function callReport(?string $dateFrom = null, ?string $dateTo = null, ?int $agentId = null, ?array $agentIds = null): array
{
$query = Call::query();
if ($dateFrom) $query->whereDate('created_at', '>=', $dateFrom);
if ($dateTo) $query->whereDate('created_at', '<=', $dateTo);
if ($agentId) $query->where('user_id', $agentId);
elseif (is_array($agentIds) && $agentIds !== []) $query->whereIn('user_id', $agentIds);
elseif (is_array($agentIds)) $query->whereRaw('1 = 0');
$total = $query->count();
$results = \App\Models\CallResult::all();
$byResult = [];
foreach ($results as $result) {
$count = (clone $query)->where('result', $result->name)->count();
$byResult[] = [
'name' => $result->name,
'color' => $result->color,
'count' => $count,
'percentage' => $total > 0 ? round(($count / $total) * 100, 1) : 0,
];
}
return [
'total_calls' => $total,
'avg_duration' => $query->avg('duration') ?? 0,
'by_result' => $byResult,
];
}
public function followUpReport(?string $dateFrom = null, ?string $dateTo = null, ?int $agentId = null, ?array $agentIds = null): array
{
$query = FollowUp::query();
if ($dateFrom) $query->whereDate('scheduled_at', '>=', $dateFrom);
if ($dateTo) $query->whereDate('scheduled_at', '<=', $dateTo);
if ($agentId) $query->where('user_id', $agentId);
elseif (is_array($agentIds) && $agentIds !== []) $query->whereIn('user_id', $agentIds);
elseif (is_array($agentIds)) $query->whereRaw('1 = 0');
return [
'total' => $query->count(),
'pending' => (clone $query)->where('status', 'pending')->count(),
'completed' => (clone $query)->where('status', 'completed')->count(),
'overdue' => (clone $query)->where('is_overdue', true)->count(),
'items' => (clone $query)->with(['user:id,name', 'lead:id,company,first_name,last_name'])
->where(fn($q) => $q->where('is_overdue', true)->orWhere('scheduled_at', '<', Carbon::now()))
->latest('scheduled_at')
->limit(50)
->get(),
];
}
public function lostReasonReport(?string $dateFrom = null, ?string $dateTo = null, ?int $agentId = null, ?array $agentIds = null): array
{
$query = $this->scopedLeads($dateFrom, $dateTo, $agentId, $agentIds)
->where('final_result', 'ناموفق')
->whereNotNull('lost_reason');
$total = (clone $query)->count();
return [
'total_lost' => $total,
'by_reason' => (clone $query)->selectRaw('lost_reason as name, count(*) as count')
->groupBy('lost_reason')
->orderByDesc('count')
->get()
->map(fn($row) => [
'name' => $row->name,
'count' => (int) $row->count,
'percentage' => $total > 0 ? round(($row->count / $total) * 100, 1) : 0,
])->values(),
];
}
public function sourcePerformanceReport(?string $dateFrom = null, ?string $dateTo = null, ?int $agentId = null, ?array $agentIds = null): array
{
$query = $this->scopedLeads($dateFrom, $dateTo, $agentId, $agentIds);
return [
'sources' => (clone $query)
->selectRaw("COALESCE(source, 'نامشخص') as source, count(*) as total, sum(case when final_result = 'موفق' then 1 else 0 end) as won, sum(case when final_result = 'ناموفق' then 1 else 0 end) as lost")
->groupBy('source')
->orderByDesc('total')
->get()
->map(fn($row) => [
'source' => $row->source,
'total' => (int) $row->total,
'won' => (int) $row->won,
'lost' => (int) $row->lost,
'conversion_rate' => $row->total > 0 ? round(($row->won / $row->total) * 100, 1) : 0,
])->values(),
];
}
public function duplicateLeadsReport(?string $dateFrom = null, ?string $dateTo = null, ?int $agentId = null, ?array $agentIds = null): array
{
$query = $this->scopedLeads($dateFrom, $dateTo, $agentId, $agentIds);
$duplicatePhones = (clone $query)
->whereNotNull('phone')
->selectRaw('phone, count(*) as count')
->groupBy('phone')
->havingRaw('count(*) > 1')
->orderByDesc('count')
->limit(50)
->get();
$duplicateEmails = (clone $query)
->whereNotNull('email')
->selectRaw('email, count(*) as count')
->groupBy('email')
->havingRaw('count(*) > 1')
->orderByDesc('count')
->limit(50)
->get();
$duplicateCompanies = (clone $query)
->whereNotNull('company')
->selectRaw('company, count(*) as count')
->groupBy('company')
->havingRaw('count(*) > 1')
->orderByDesc('count')
->limit(50)
->get();
return [
'phone_duplicates' => $duplicatePhones,
'email_duplicates' => $duplicateEmails,
'company_duplicates' => $duplicateCompanies,
'merge_history_count' => MergeHistory::where('entity_type', 'like', '%Lead%')->count(),
];
}
public function importQualityReport(?string $dateFrom = null, ?string $dateTo = null): array
{
$query = ImportBatch::with(['user:id,name', 'campaign:id,name']);
if ($dateFrom) $query->whereDate('created_at', '>=', $dateFrom);
if ($dateTo) $query->whereDate('created_at', '<=', $dateTo);
$batches = $query->latest()->limit(50)->get();
$totalRows = max($batches->sum('total_rows'), 1);
return [
'total_batches' => $batches->count(),
'total_rows' => $batches->sum('total_rows'),
'imported_rows' => $batches->sum('imported_rows'),
'failed_rows' => $batches->sum('failed_rows'),
'skipped_rows' => $batches->sum('skipped_rows'),
'success_rate' => round(($batches->sum('imported_rows') / $totalRows) * 100, 1),
'batches' => $batches,
];
}
public function callQualityReport(?string $dateFrom = null, ?string $dateTo = null, ?int $agentId = null, ?array $agentIds = null): array
{
$query = QualityReview::with(['agent:id,name', 'reviewer:id,name']);
if ($dateFrom) $query->whereDate('created_at', '>=', $dateFrom);
if ($dateTo) $query->whereDate('created_at', '<=', $dateTo);
if ($agentId) $query->where('agent_id', $agentId);
elseif (is_array($agentIds) && $agentIds !== []) $query->whereIn('agent_id', $agentIds);
elseif (is_array($agentIds)) $query->whereRaw('1 = 0');
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(),
'by_agent' => (clone $query)->selectRaw('agent_id, count(*) as reviews, avg(overall_score) as average_score')
->groupBy('agent_id')
->with('agent:id,name')
->get(),
'recent_reviews' => (clone $query)->latest()->limit(20)->get(),
];
}
public function bestContactTimeReport(?string $dateFrom = null, ?string $dateTo = null, ?int $agentId = null, ?array $agentIds = null): array
{
$query = Call::query();
if ($dateFrom) $query->whereDate('created_at', '>=', $dateFrom);
if ($dateTo) $query->whereDate('created_at', '<=', $dateTo);
if ($agentId) $query->where('user_id', $agentId);
elseif (is_array($agentIds) && $agentIds !== []) $query->whereIn('user_id', $agentIds);
elseif (is_array($agentIds)) $query->whereRaw('1 = 0');
$total = (clone $query)->count();
if ($total < 20) {
return [
'enough_data' => false,
'message' => 'برای تحلیل بهترین زمان تماس، حداقل ۲۰ تماس ثبت‌شده لازم است.',
'total_calls' => $total,
'by_hour' => [],
];
}
return [
'enough_data' => true,
'total_calls' => $total,
'by_hour' => (clone $query)->get(['created_at', 'result'])
->groupBy(fn(Call $call) => $call->created_at->hour)
->sortKeys()
->map(function ($calls, int $hour) {
$successful = $calls->whereIn('result', $this->successfulResultNames())->count();
return [
'hour' => $hour,
'total' => $calls->count(),
'successful' => $successful,
'success_rate' => $calls->count() > 0 ? round(($successful / $calls->count()) * 100, 1) : 0,
];
})->values(),
];
}
private function filterFollowUpsByAgent($query, ?int $agentId, ?array $agentIds = null)
{
if ($agentId) {
$query->where('user_id', $agentId);
} elseif (is_array($agentIds) && $agentIds !== []) {
$query->whereIn('user_id', $agentIds);
} elseif (is_array($agentIds)) {
$query->whereRaw('1 = 0');
}
return $query;
}
private function scopedLeads(?string $dateFrom, ?string $dateTo, ?int $agentId, ?array $agentIds)
{
$query = Lead::query();
if ($dateFrom) $query->whereDate('created_at', '>=', $dateFrom);
if ($dateTo) $query->whereDate('created_at', '<=', $dateTo);
if ($agentId) $query->where('assigned_to', $agentId);
elseif (is_array($agentIds) && $agentIds !== []) $query->whereIn('assigned_to', $agentIds);
elseif (is_array($agentIds)) $query->whereRaw('1 = 0');
return $query;
}
private function successfulResultNames(): array
{
return CallResult::where('is_positive', true)->pluck('name')->all();
}
}

مشاهده پرونده

@ -0,0 +1,222 @@
<?php
namespace App\Services\VoIP;
use App\Models\Setting;
class AmiProvider implements VoIPProviderInterface
{
public function initiateCall(string $phone, string $callerId = null): array
{
$extension = trim((string) $callerId);
if ($extension === '') {
return [
'success' => false,
'provider_call_id' => null,
'message' => 'شماره داخلی برای این کاربر ثبت نشده است.',
'status' => 'missing_extension',
];
}
$config = $this->config();
$missing = $this->missingConfig($config);
if ($missing !== []) {
return [
'success' => false,
'provider_call_id' => null,
'message' => 'تنظیمات AMI کامل نیست.',
'status' => 'configuration_error',
'missing' => $missing,
];
}
$connection = $this->connect($config, $error);
if (!$connection) {
return [
'success' => false,
'provider_call_id' => null,
'message' => $error ?: 'اتصال به مرکز تلفن برقرار نشد.',
'status' => 'connection_failed',
];
}
$login = $this->login($connection, $config);
if (!$this->isSuccess($login)) {
fclose($connection);
return [
'success' => false,
'provider_call_id' => null,
'message' => $login['Message'] ?? 'ورود به AMI انجام نشد.',
'status' => 'login_failed',
'raw' => $login,
];
}
$providerCallId = 'ami_' . uniqid();
$channel = "{$config['technology']}/{$extension}";
$response = $this->sendAction($connection, [
'Action' => 'Originate',
'ActionID' => $providerCallId,
'Channel' => $channel,
'Context' => $config['context'],
'Exten' => $phone,
'Priority' => '1',
'CallerID' => $this->callerId($extension),
'Timeout' => (string) ((int) $config['originate_timeout'] * 1000),
'Async' => 'true',
'Variable' => "CRM_USER_EXTENSION={$extension}",
]);
$this->logout($connection);
fclose($connection);
$ok = $this->isSuccess($response);
return [
'success' => $ok,
'provider_call_id' => $providerCallId,
'message' => $ok ? 'درخواست تماس به مرکز تلفن ارسال شد.' : ($response['Message'] ?? 'درخواست تماس به مرکز تلفن ارسال نشد.'),
'status' => $ok ? 'initiated' : 'failed',
'raw' => $response,
'channel' => $channel,
];
}
public function getCallStatus(string $providerCallId): array
{
return [
'status' => 'pending',
'provider_call_id' => $providerCallId,
];
}
public function getRecordingUrl(string $providerCallId): ?string
{
$template = Setting::where('key', 'voip_ami_recording_url')->value('value');
return $template ? str_replace('{id}', $providerCallId, $template) : null;
}
public function testConnection(): array
{
$config = $this->config();
$missing = $this->missingConfig($config);
if ($missing !== []) {
return [
'ok' => false,
'message' => 'تنظیمات AMI کامل نیست.',
'missing' => $missing,
];
}
$connection = $this->connect($config, $error);
if (!$connection) {
return [
'ok' => false,
'message' => $error ?: 'اتصال به AMI برقرار نشد.',
'missing' => [],
];
}
$login = $this->login($connection, $config);
$this->logout($connection);
fclose($connection);
return [
'ok' => $this->isSuccess($login),
'message' => $this->isSuccess($login) ? 'اتصال AMI موفق بود.' : ($login['Message'] ?? 'ورود به AMI انجام نشد.'),
'missing' => [],
];
}
private function config(): array
{
return [
'host' => trim((string) Setting::where('key', 'voip_ami_host')->value('value')),
'port' => (int) (Setting::where('key', 'voip_ami_port')->value('value') ?: 5038),
'username' => trim((string) Setting::where('key', 'voip_ami_username')->value('value')),
'secret' => (string) Setting::where('key', 'voip_ami_secret')->value('value'),
'technology' => trim((string) (Setting::where('key', 'voip_ami_channel_technology')->value('value') ?: 'SIP')),
'context' => trim((string) (Setting::where('key', 'voip_ami_context')->value('value') ?: 'from-internal')),
'timeout' => (float) (Setting::where('key', 'voip_ami_timeout')->value('value') ?: 5),
'originate_timeout' => (int) (Setting::where('key', 'voip_ami_originate_timeout')->value('value') ?: 30),
];
}
private function missingConfig(array $config): array
{
return array_values(array_filter(['host', 'port', 'username', 'secret', 'technology', 'context'], fn(string $key) => empty($config[$key])));
}
private function connect(array $config, ?string &$error): mixed
{
$error = null;
$connection = @stream_socket_client(
"tcp://{$config['host']}:{$config['port']}",
$errorCode,
$errorMessage,
$config['timeout']
);
if (!$connection) {
$error = $errorMessage ?: "خطای اتصال AMI ({$errorCode})";
return false;
}
stream_set_timeout($connection, (int) ceil($config['timeout']));
fgets($connection);
return $connection;
}
private function login(mixed $connection, array $config): array
{
return $this->sendAction($connection, [
'Action' => 'Login',
'Username' => $config['username'],
'Secret' => $config['secret'],
'Events' => 'off',
]);
}
private function logout(mixed $connection): void
{
$this->sendAction($connection, ['Action' => 'Logoff']);
}
private function sendAction(mixed $connection, array $headers): array
{
foreach ($headers as $key => $value) {
fwrite($connection, "{$key}: {$value}\r\n");
}
fwrite($connection, "\r\n");
return $this->readResponse($connection);
}
private function readResponse(mixed $connection): array
{
$response = [];
while (!feof($connection)) {
$line = fgets($connection);
if ($line === false || trim($line) === '') {
break;
}
if (str_contains($line, ':')) {
[$key, $value] = explode(':', $line, 2);
$response[trim($key)] = trim($value);
}
}
return $response;
}
private function isSuccess(array $response): bool
{
return strtolower((string) ($response['Response'] ?? '')) === 'success';
}
private function callerId(string $extension): string
{
$template = Setting::where('key', 'voip_ami_caller_id_template')->value('value') ?: 'CRM <{extension}>';
return str_replace('{extension}', $extension, $template);
}
}

مشاهده پرونده

@ -0,0 +1,90 @@
<?php
namespace App\Services\VoIP;
use App\Models\Setting;
use Illuminate\Support\Facades\Http;
class ApiProvider implements VoIPProviderInterface
{
public function initiateCall(string $phone, string $callerId = null): array
{
$baseUrl = rtrim((string) Setting::where('key', 'voip_api_base_url')->value('value'), '/');
$token = Setting::where('key', 'voip_api_token')->value('value');
$callPath = Setting::where('key', 'voip_api_call_path')->value('value') ?: '/calls';
if (!$baseUrl) {
return [
'success' => false,
'provider_call_id' => null,
'message' => 'نشانی ای‌پی‌آی تماس تنظیم نشده است',
'status' => 'configuration_error',
];
}
$request = Http::acceptJson()->timeout(10);
if ($token) {
$request = $request->withToken($token);
}
$response = $request->post($baseUrl . '/' . ltrim($callPath, '/'), [
'phone' => $phone,
'caller_id' => $callerId,
]);
$body = $response->json() ?: [];
return [
'success' => $response->successful(),
'provider_call_id' => $body['provider_call_id'] ?? $body['call_id'] ?? null,
'message' => $body['message'] ?? ($response->successful() ? 'تماس از راه ای‌پی‌آی ارسال شد' : 'خطا در ارسال تماس از راه ای‌پی‌آی'),
'status' => $body['status'] ?? ($response->successful() ? 'initiated' : 'failed'),
'raw' => $body,
];
}
public function getCallStatus(string $providerCallId): array
{
$baseUrl = rtrim((string) Setting::where('key', 'voip_api_base_url')->value('value'), '/');
$token = Setting::where('key', 'voip_api_token')->value('value');
$statusPath = Setting::where('key', 'voip_api_status_path')->value('value') ?: '/calls/{id}';
if (!$baseUrl) {
return ['status' => 'configuration_error'];
}
$request = Http::acceptJson()->timeout(10);
if ($token) {
$request = $request->withToken($token);
}
$url = $baseUrl . '/' . ltrim(str_replace('{id}', $providerCallId, $statusPath), '/');
$response = $request->get($url);
return $response->json() ?: [
'status' => $response->successful() ? 'unknown' : 'failed',
];
}
public function getRecordingUrl(string $providerCallId): ?string
{
$template = Setting::where('key', 'voip_api_recording_url')->value('value');
return $template ? str_replace('{id}', $providerCallId, $template) : null;
}
public function testConnection(): array
{
$missing = [];
foreach (['voip_api_base_url', 'voip_api_token'] as $key) {
if (!Setting::where('key', $key)->value('value')) {
$missing[] = $key;
}
}
return [
'ok' => $missing === [],
'message' => $missing ? 'تنظیمات اتصال API کامل نیست.' : 'تنظیمات اتصال API معتبر است.',
'missing' => $missing,
];
}
}

مشاهده پرونده

@ -0,0 +1,39 @@
<?php
namespace App\Services\VoIP;
class MockProvider implements VoIPProviderInterface
{
public function initiateCall(string $phone, string $callerId = null): array
{
return [
'success' => true,
'provider_call_id' => 'mock_' . uniqid(),
'message' => "تماس با {$phone} در حال انجام است",
'status' => 'initiated',
];
}
public function getCallStatus(string $providerCallId): array
{
return [
'status' => 'completed',
'duration' => rand(30, 300),
'answered' => true,
];
}
public function getRecordingUrl(string $providerCallId): ?string
{
return "/recordings/{$providerCallId}.mp3";
}
public function testConnection(): array
{
return [
'ok' => true,
'message' => 'اتصال شبیه‌ساز آماده است.',
'missing' => [],
];
}
}

مشاهده پرونده

@ -0,0 +1,90 @@
<?php
namespace App\Services\VoIP;
use App\Models\Setting;
class SocketProvider implements VoIPProviderInterface
{
public function initiateCall(string $phone, string $callerId = null): array
{
$host = Setting::where('key', 'voip_socket_host')->value('value');
$port = (int) (Setting::where('key', 'voip_socket_port')->value('value') ?: 0);
$token = Setting::where('key', 'voip_socket_token')->value('value');
$timeout = (float) (Setting::where('key', 'voip_socket_timeout')->value('value') ?: 5);
if (!$host || !$port) {
return [
'success' => false,
'provider_call_id' => null,
'message' => 'میزبان یا پورت سوکت تنظیم نشده است',
'status' => 'configuration_error',
];
}
$connection = @stream_socket_client("tcp://{$host}:{$port}", $errorCode, $errorMessage, $timeout);
if (!$connection) {
return [
'success' => false,
'provider_call_id' => null,
'message' => $errorMessage ?: 'اتصال سوکت برقرار نشد',
'status' => 'connection_failed',
'error_code' => $errorCode,
];
}
stream_set_timeout($connection, (int) ceil($timeout));
$providerCallId = 'socket_' . uniqid();
$payload = [
'type' => 'call.initiate',
'provider_call_id' => $providerCallId,
'phone' => $phone,
'caller_id' => $callerId,
'token' => $token,
];
fwrite($connection, json_encode($payload, JSON_UNESCAPED_UNICODE) . "\n");
$line = fgets($connection);
fclose($connection);
$response = $line ? json_decode($line, true) : [];
return [
'success' => ($response['success'] ?? true) === true,
'provider_call_id' => $response['provider_call_id'] ?? $providerCallId,
'message' => $response['message'] ?? 'درخواست تماس از راه سوکت ارسال شد',
'status' => $response['status'] ?? 'initiated',
'raw' => $response,
];
}
public function getCallStatus(string $providerCallId): array
{
return [
'status' => 'pending',
'provider_call_id' => $providerCallId,
];
}
public function getRecordingUrl(string $providerCallId): ?string
{
$template = Setting::where('key', 'voip_socket_recording_url')->value('value');
return $template ? str_replace('{id}', $providerCallId, $template) : null;
}
public function testConnection(): array
{
$missing = [];
foreach (['voip_socket_host', 'voip_socket_port'] as $key) {
if (!Setting::where('key', $key)->value('value')) {
$missing[] = $key;
}
}
return [
'ok' => $missing === [],
'message' => $missing ? 'تنظیمات اتصال سوکت کامل نیست.' : 'تنظیمات اتصال سوکت معتبر است.',
'missing' => $missing,
];
}
}

مشاهده پرونده

@ -0,0 +1,44 @@
<?php
namespace App\Services\VoIP;
use App\Models\Setting;
class VoIPManager
{
private ?VoIPProviderInterface $provider = null;
public function provider(): VoIPProviderInterface
{
if ($this->provider === null) {
$providerName = Setting::where('key', 'voip_provider')->value('value') ?? 'mock';
$this->provider = match ($providerName) {
'ami' => new AmiProvider(),
'api' => new ApiProvider(),
'socket' => new SocketProvider(),
default => new MockProvider(),
};
}
return $this->provider;
}
public function initiateCall(string $phone, string $callerId = null): array
{
return $this->provider()->initiateCall($phone, $callerId);
}
public function getCallStatus(string $providerCallId): array
{
return $this->provider()->getCallStatus($providerCallId);
}
public function getRecordingUrl(string $providerCallId): ?string
{
return $this->provider()->getRecordingUrl($providerCallId);
}
public function testConnection(): array
{
return $this->provider()->testConnection();
}
}

مشاهده پرونده

@ -0,0 +1,11 @@
<?php
namespace App\Services\VoIP;
interface VoIPProviderInterface
{
public function initiateCall(string $phone, string $callerId = null): array;
public function getCallStatus(string $providerCallId): array;
public function getRecordingUrl(string $providerCallId): ?string;
public function testConnection(): array;
}

مشاهده پرونده

@ -0,0 +1,207 @@
<?php
namespace App\Support;
use App\Models\Campaign;
use App\Models\Call;
use App\Models\FollowUp;
use App\Models\Lead;
use App\Models\Team;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
class AccessControl
{
public static function canAccessLead(User $user, Lead $lead, bool $allowUnassignedClaim = false): bool
{
if ($user->hasRole('admin')) {
return true;
}
if ($user->hasRole('agent')) {
return $lead->assigned_to === $user->id
|| ($allowUnassignedClaim && is_null($lead->assigned_to) && (bool) $lead->is_unassigned);
}
if ($user->hasRole('supervisor')) {
return $lead->assigned_to === $user->id
|| in_array($lead->team_id, self::teamIds($user), true)
|| (is_null($lead->team_id) && is_null($lead->assigned_to));
}
return false;
}
public static function canAccessCall(User $user, Call $call): bool
{
if ($user->hasRole('admin')) {
return true;
}
if ($user->hasRole('agent')) {
return $call->user_id === $user->id;
}
if ($user->hasRole('supervisor')) {
return in_array($call->user_id, self::teamMemberIds($user), true)
|| ($call->lead && self::canAccessLead($user, $call->lead));
}
return false;
}
public static function canAccessFollowUp(User $user, FollowUp $followUp): bool
{
if ($user->hasRole('admin')) {
return true;
}
if ($user->hasRole('agent')) {
return $followUp->user_id === $user->id;
}
if ($user->hasRole('supervisor')) {
return in_array($followUp->user_id, self::teamMemberIds($user), true)
|| ($followUp->lead && self::canAccessLead($user, $followUp->lead));
}
return false;
}
public static function canAccessCampaign(User $user, Campaign $campaign): bool
{
if ($user->hasRole('admin')) {
return true;
}
if ($user->hasRole('agent')) {
return $campaign->assignedAgents()->whereKey($user->id)->exists()
|| $campaign->leads()->where('assigned_to', $user->id)->exists();
}
if ($user->hasRole('supervisor')) {
$teamIds = self::teamIds($user);
return $campaign->assignedSupervisors()->whereKey($user->id)->exists()
|| $campaign->leads()->whereIn('team_id', $teamIds)->exists();
}
return false;
}
public static function scopeLeads(Builder $query, User $user, bool $allowUnassignedForSupervisor = true): Builder
{
if ($user->hasRole('admin')) {
return $query;
}
if ($user->hasRole('agent')) {
return $query->where('assigned_to', $user->id);
}
if ($user->hasRole('supervisor')) {
$teamIds = self::teamIds($user);
return $query->where(function (Builder $q) use ($user, $teamIds, $allowUnassignedForSupervisor): void {
$q->whereIn('team_id', $teamIds)
->orWhere('assigned_to', $user->id);
if ($allowUnassignedForSupervisor) {
$q->orWhere(function (Builder $unassigned): void {
$unassigned->whereNull('team_id')->whereNull('assigned_to');
});
}
});
}
return $query->whereRaw('1 = 0');
}
public static function scopeCalls(Builder $query, User $user): Builder
{
if ($user->hasRole('admin')) {
return $query;
}
if ($user->hasRole('agent')) {
return $query->where('user_id', $user->id);
}
if ($user->hasRole('supervisor')) {
return $query->where(function (Builder $q) use ($user): void {
$q->whereIn('user_id', self::teamMemberIds($user))
->orWhereHas('lead', fn(Builder $leadQuery) => self::scopeLeads($leadQuery, $user));
});
}
return $query->whereRaw('1 = 0');
}
public static function scopeFollowUps(Builder $query, User $user): Builder
{
if ($user->hasRole('admin')) {
return $query;
}
if ($user->hasRole('agent')) {
return $query->where('user_id', $user->id);
}
if ($user->hasRole('supervisor')) {
return $query->where(function (Builder $q) use ($user): void {
$q->whereIn('user_id', self::teamMemberIds($user))
->orWhereHas('lead', fn(Builder $leadQuery) => self::scopeLeads($leadQuery, $user));
});
}
return $query->whereRaw('1 = 0');
}
public static function scopeCampaigns(Builder $query, User $user): Builder
{
if ($user->hasRole('admin')) {
return $query;
}
if ($user->hasRole('agent')) {
return $query->where(function (Builder $q) use ($user): void {
$q->whereHas('assignedAgents', fn(Builder $agentQuery) => $agentQuery->whereKey($user->id))
->orWhereHas('leads', fn(Builder $leadQuery) => $leadQuery->where('assigned_to', $user->id));
});
}
if ($user->hasRole('supervisor')) {
$teamIds = self::teamIds($user);
return $query->where(function (Builder $q) use ($user, $teamIds): void {
$q->whereHas('assignedSupervisors', fn(Builder $supervisorQuery) => $supervisorQuery->whereKey($user->id))
->orWhereHas('leads', fn(Builder $leadQuery) => $leadQuery->whereIn('team_id', $teamIds));
});
}
return $query->whereRaw('1 = 0');
}
public static function teamIds(User $user): array
{
return $user->teams()->pluck('teams.id')->all();
}
public static function teamMemberIds(User $user): array
{
$teamIds = self::teamIds($user);
if (!$teamIds) {
return [];
}
return Team::whereIn('id', $teamIds)
->with('members:id')
->get()
->pluck('members.*.id')
->flatten()
->unique()
->values()
->all();
}
}

مشاهده پرونده

@ -0,0 +1,27 @@
<?php
namespace App\Support;
use App\Models\Setting;
use Illuminate\Validation\Rules\Password;
class PasswordPolicy
{
public static function rules(): array
{
$min = max(6, (int) (Setting::where('key', 'password_min_length')->value('value') ?: 8));
$rule = Password::min($min);
if (Setting::where('key', 'password_require_mixed_case')->value('value') === 'true') {
$rule->mixedCase();
}
if (Setting::where('key', 'password_require_numbers')->value('value') === 'true') {
$rule->numbers();
}
if (Setting::where('key', 'password_require_symbols')->value('value') === 'true') {
$rule->symbols();
}
return ['string', $rule];
}
}

مشاهده پرونده

@ -0,0 +1,182 @@
<?php
namespace App\Support;
use App\Models\Setting;
class SettingsCatalog
{
public static function definitions(): array
{
return [
self::d('company_name', 'general', 'string', 'CRM فروش', 'نام سامانه', 'در هدر، عنوان صفحه و پاسخ عمومی تنظیمات استفاده می‌شود.', true, true, false, null, ['Header', 'document.title']),
self::d('logo_path', 'general', 'string', '', 'لوگو', 'بارگذاری فایل لوگو هنوز در همین صفحه پیاده‌سازی نشده است.', false, false, false, null, [], true),
self::d('brand_color', 'general', 'string', '#2563eb', 'رنگ اصلی برند', 'در پاسخ عمومی تنظیمات منتشر می‌شود؛ اعمال کامل روی theme در مرحله بعدی انجام می‌شود.', true, true, false, null, ['public settings']),
self::d('favicon_path', 'general', 'string', '', 'فاوآیکن', 'بارگذاری مستقیم فاوآیکن به‌زودی اضافه می‌شود.', false, false, false, null, [], true),
self::d('pwa_icon_path', 'general', 'string', '', 'آیکن PWA', 'بارگذاری مستقیم آیکن PWA به‌زودی اضافه می‌شود.', false, false, false, null, [], true),
self::d('default_language', 'general', 'string', 'fa', 'زبان پیش‌فرض', 'فعلاً رابط فارسی است؛ چندزبانه‌سازی کامل به‌زودی.', false, true, false, ['fa', 'en'], [], true),
self::d('timezone', 'general', 'string', 'Asia/Tehran', 'منطقه زمانی', 'برای نمایش و اعتبارسنجی زمان‌ها نگهداری می‌شود.', true, true, false, ['Asia/Tehran', 'UTC'], ['follow-up validation']),
self::d('datetime_format', 'general', 'string', 'jalali_datetime', 'قالب تاریخ و زمان', 'در رابط کاربری فارسی استفاده می‌شود.', false, true, false, ['jalali_datetime', 'gregorian_datetime'], [], true),
self::d('jalali_calendar_enabled', 'general', 'boolean', 'true', 'تقویم جلالی', 'ورودی‌های تاریخ فعلی بر مبنای جلالی هستند.', true, true, false, null, ['PersianDateInput']),
self::d('currency', 'general', 'string', 'IRR', 'واحد پول', 'برای فرصت‌های فروش و گزارش‌ها نگهداری می‌شود.', true, true, false, ['IRR', 'IRT', 'USD'], ['deals UI']),
self::d('working_days', 'general', 'json', 'sat,sun,mon,tue,wed', 'روزهای کاری', 'برای کنترل زمان‌بندی پیگیری استفاده می‌شود.', true, false, false, null, ['FollowUpController', 'CallService']),
self::d('working_hours_start', 'general', 'string', '09:00', 'شروع ساعت کاری', 'اگر محدودیت ساعت کاری فعال باشد، پیگیری خارج از این بازه رد می‌شود.', true, false, false, null, ['FollowUpController', 'CallService']),
self::d('working_hours_end', 'general', 'string', '18:00', 'پایان ساعت کاری', 'اگر محدودیت ساعت کاری فعال باشد، پیگیری خارج از این بازه رد می‌شود.', true, false, false, null, ['FollowUpController', 'CallService']),
self::d('custom_holidays', 'general', 'json', '', 'تعطیلات اختصاصی', 'برای جلوگیری از زمان‌بندی پیگیری در تعطیلات استفاده می‌شود.', true, false, false, null, ['FollowUpController']),
self::d('phone_mask_enabled', 'security', 'boolean', 'true', 'مخفی‌سازی شماره', 'در خروجی API برای کاربران بدون view_full_phone اعمال می‌شود.', true, true, false, null, ['MaskPhoneNumber']),
self::d('phone_mask_level', 'security', 'string', 'partial', 'سطح مخفی‌سازی شماره', 'partial فقط بخشی از شماره را مخفی می‌کند؛ full کل شماره را مخفی می‌کند.', true, false, false, ['partial', 'full'], ['MaskPhoneNumber']),
self::d('password_min_length', 'security', 'integer', '8', 'حداقل طول رمز', 'در ساخت کاربر، تغییر کاربر و پروفایل اعمال می‌شود.', true, false, false, null, ['AuthController', 'UserController']),
self::d('password_require_mixed_case', 'security', 'boolean', 'false', 'حروف بزرگ و کوچک در رمز', 'در اعتبارسنجی رمز اعمال می‌شود.', true, false, false, null, ['PasswordPolicy']),
self::d('password_require_numbers', 'security', 'boolean', 'false', 'عدد در رمز', 'در اعتبارسنجی رمز اعمال می‌شود.', true, false, false, null, ['PasswordPolicy']),
self::d('password_require_symbols', 'security', 'boolean', 'false', 'نماد در رمز', 'در اعتبارسنجی رمز اعمال می‌شود.', true, false, false, null, ['PasswordPolicy']),
self::d('login_attempt_limit', 'security', 'integer', '5', 'حداکثر تلاش ورود', 'مسیر ورود هنوز از throttle ثابت لاراول استفاده می‌کند.', false, false, false, null, [], true),
self::d('session_timeout_minutes', 'security', 'integer', '120', 'انقضای نشست', 'فعلاً از تنظیمات session لاراول پیروی می‌کند.', false, false, false, null, [], true),
self::d('two_factor_enabled', 'security', 'boolean', 'false', 'ورود دو مرحله‌ای', 'زیرساخت 2FA هنوز اضافه نشده است.', false, false, false, null, [], true),
self::d('active_device_management_enabled', 'security', 'boolean', 'false', 'مدیریت دستگاه‌های فعال', 'به‌زودی.', false, false, false, null, [], true),
self::d('audit_log_retention_days', 'security', 'integer', '365', 'نگهداری لاگ امنیتی', 'مقدار نگهداری می‌شود؛ پاکسازی زمان‌بندی‌شده به‌زودی.', false, false, false, null, [], true),
self::d('export_protection_enabled', 'security', 'boolean', 'true', 'محافظت خروجی', 'خروجی لید به مجوز export_leads و view_full_phone وابسته است.', true, false, false, null, ['LeadController@export']),
self::d('ip_allowlist', 'security', 'string', '', 'IP Allowlist', 'اعمال محدودیت IP به‌زودی.', false, false, false, null, [], true),
self::d('full_phone_visibility_permission', 'users_roles', 'string', 'view_full_phone', 'مجوز مشاهده کامل شماره', 'از permission matrix نقش‌ها مدیریت می‌شود و در API masking اعمال می‌شود.', true, false, false, null, ['MaskPhoneNumber']),
self::d('export_permission', 'users_roles', 'string', 'export_leads', 'مجوز خروجی گرفتن', 'از نقش‌ها مدیریت می‌شود و در خروجی لید اعمال می‌شود.', true, false, false, null, ['LeadController@export']),
self::d('import_permission', 'users_roles', 'string', 'import_leads', 'مجوز ورود داده', 'از نقش‌ها مدیریت می‌شود و در ورود داده اعمال می‌شود.', true, false, false, null, ['ImportBatchPolicy']),
self::d('delete_merge_permission', 'users_roles', 'string', 'delete_leads,merge_duplicates', 'مجوز حذف/ادغام', 'حذف لید و merge شرکت‌ها به مجوز وابسته است.', true, false, false, null, ['LeadPolicy', 'CompanyController@merge']),
self::d('recording_visibility_permission', 'users_roles', 'string', 'listen_recordings', 'مجوز شنیدن ضبط', 'در نمایش recording_url اعمال می‌شود.', true, false, false, null, ['MaskPhoneNumber', 'CallPolicy']),
self::d('supervisor_scope_own_team', 'users_roles', 'boolean', 'true', 'محدودیت سرپرست به تیم خود', 'در AccessControl برای لید، تماس، پیگیری و گزارش اعمال می‌شود.', true, false, false, null, ['AccessControl']),
self::d('force_password_change_supported', 'users_roles', 'boolean', 'false', 'اجبار تغییر رمز بعدی', 'ستون و جریان تغییر اجباری رمز هنوز اضافه نشده است.', false, false, false, null, [], true),
self::d('duplicate_phone_policy', 'lead', 'string', 'warn', 'سیاست تکراری تلفن', 'در ایجاد و ورود لید اعمال می‌شود.', true, false, false, ['allow', 'warn', 'block', 'merge_suggestion'], ['LeadController', 'ImportService']),
self::d('manage_pipeline_stages', 'lead', 'string', '/pipeline-stages', 'مدیریت مراحل قیف', 'API مدیریت مراحل وجود دارد و از نقش admin استفاده می‌کند.', true, false, false, null, ['PipelineStageController']),
self::d('manage_lead_statuses', 'lead', 'string', '/lead-statuses', 'مدیریت وضعیت‌های لید', 'API مدیریت وضعیت‌ها وجود دارد و از نقش admin استفاده می‌کند.', true, false, false, null, ['LeadStatusController']),
self::d('manage_call_results', 'lead', 'string', 'call_results', 'مدیریت نتایج تماس', 'نتایج تماس دیتابیسی هستند؛ UI مدیریت کامل به‌زودی.', false, false, false, null, ['CallController@results'], true),
self::d('manage_lost_reasons', 'lead', 'string', '/lost-reasons', 'مدیریت علت‌های شکست', 'API علت‌های شکست موجود است.', true, false, false, null, ['LostReasonController']),
self::d('assignment_strategy', 'lead', 'string', 'manual', 'استراتژی تخصیص', 'در ایجاد لید بدون مسئول استفاده می‌شود.', true, false, false, ['manual', 'round_robin', 'least_busy', 'rule_based'], ['LeadController']),
self::d('first_call_sla_hours', 'lead', 'integer', '4', 'SLA اولین تماس', 'مقدار برای گزارش و هشدار نگهداری می‌شود؛ هشدار خودکار به‌زودی.', false, false, false, null, [], true),
self::d('follow_up_sla_hours', 'lead', 'integer', '24', 'SLA پیگیری', 'در زمان پیش‌فرض پیگیری استفاده می‌شود.', true, false, false, null, ['CallService']),
self::d('max_stage_age_hours', 'lead', 'integer', '72', 'حداکثر زمان در مرحله', 'هشدار مرحله به‌زودی.', false, false, false, null, [], true),
self::d('lead_sources', 'lead', 'json', 'وب‌سایت,معرفی,نمایشگاه,کمپین', 'منابع لید', 'برای لیست‌های انتخابی آینده نگهداری می‌شود.', false, false, false, null, [], true),
self::d('lead_tags', 'lead', 'json', '', 'برچسب‌ها', 'مدیریت ساختاری برچسب‌ها به‌زودی.', false, false, false, null, [], true),
self::d('voip_provider', 'voip', 'string', 'mock', 'ارائه‌دهنده', 'در مدیریت تلفن اینترنتی برای انتخاب ارائه‌دهنده استفاده می‌شود.', true, false, false, ['mock', 'ami', 'api', 'socket'], ['VoIPManager']),
self::d('voip_ami_host', 'voip', 'string', '', 'نشانی AMI الستیکس', 'میزبان Asterisk/Elastix AMI، مثلا 192.168.1.10.', true, false, false, null, ['AmiProvider']),
self::d('voip_ami_port', 'voip', 'integer', '5038', 'پورت AMI', 'پورت پیش‌فرض AMI معمولا 5038 است.', true, false, false, null, ['AmiProvider']),
self::d('voip_ami_username', 'voip', 'string', '', 'نام کاربری AMI', 'نام کاربری مرکزی AMI؛ برای هر کاربر CRM رمز جداگانه ذخیره نمی‌شود.', true, false, false, null, ['AmiProvider']),
self::d('voip_ami_secret', 'voip', 'string', '', 'رمز AMI', 'رمز مرکزی AMI به صورت محرمانه نگهداری می‌شود.', true, false, true, null, ['AmiProvider']),
self::d('voip_ami_channel_technology', 'voip', 'string', 'SIP', 'نوع داخلی', 'برای الستیکس‌های قدیمی معمولا SIP و برای نسخه‌های جدیدتر PJSIP است.', true, false, false, ['SIP', 'PJSIP'], ['AmiProvider']),
self::d('voip_ami_context', 'voip', 'string', 'from-internal', 'Context تماس', 'Context مقصد برای Originate در AMI.', true, false, false, null, ['AmiProvider']),
self::d('voip_ami_caller_id_template', 'voip', 'string', 'CRM <{extension}>', 'قالب Caller ID', 'عبارت {extension} با شماره داخلی کاربر جایگزین می‌شود.', true, false, false, null, ['AmiProvider']),
self::d('voip_ami_timeout', 'voip', 'integer', '5', 'مهلت اتصال AMI', 'حداکثر زمان انتظار برای اتصال socket به AMI.', true, false, false, null, ['AmiProvider']),
self::d('voip_ami_originate_timeout', 'voip', 'integer', '30', 'مهلت زنگ خوردن داخلی', 'مهلت Originate بر حسب ثانیه.', true, false, false, null, ['AmiProvider']),
self::d('voip_ami_recording_url', 'voip', 'string', '', 'قالب لینک ضبط AMI', 'اگر لینک ضبط از الگو ساخته می‌شود، {id} با شناسه تماس جایگزین می‌شود.', true, false, false, null, ['AmiProvider']),
self::d('voip_api_base_url', 'voip', 'string', '', 'Base URL', 'در provider API استفاده می‌شود.', true, false, false, null, ['ApiProvider']),
self::d('voip_api_token', 'voip', 'string', '', 'Token', 'به صورت secret نگهداری و در API/Socket استفاده می‌شود.', true, false, true, null, ['ApiProvider']),
self::d('voip_api_call_path', 'voip', 'string', '/calls', 'مسیر API تماس', 'در provider API استفاده می‌شود.', true, false, false, null, ['ApiProvider']),
self::d('voip_api_status_path', 'voip', 'string', '/calls/{id}', 'Status endpoint', 'در provider API استفاده می‌شود.', true, false, false, null, ['ApiProvider']),
self::d('voip_api_recording_url', 'voip', 'string', '', 'Recording endpoint', 'برای ساخت لینک ضبط استفاده می‌شود.', true, false, false, null, ['ApiProvider']),
self::d('voip_webhook_path', 'voip', 'string', '/api/voip/webhook', 'Webhook endpoint', 'دریافت webhook به‌زودی.', false, false, false, null, [], true),
self::d('voip_socket_timeout', 'voip', 'integer', '5', 'Timeout', 'در اتصال socket استفاده می‌شود.', true, false, false, null, ['SocketProvider']),
self::d('call_recording_enabled', 'voip', 'boolean', 'false', 'ضبط تماس', 'در ساخت تماس و نمایش recording اعمال می‌شود.', true, false, false, null, ['CallService']),
self::d('recording_retention_days', 'voip', 'integer', '90', 'نگهداری ضبط', 'پاکسازی خودکار ضبط‌ها به‌زودی.', false, false, false, null, [], true),
self::d('minimum_call_duration_seconds', 'voip', 'integer', '10', 'حداقل مدت تماس', 'اعتبارسنجی duration به‌زودی.', false, false, false, null, [], true),
self::d('call_result_required', 'voip', 'boolean', 'true', 'ثبت نتیجه تماس الزامی', 'فرم‌های ثبت نتیجه در UI/API الزام نتیجه دارند.', true, false, false, null, ['CallController']),
self::d('default_follow_up_hours', 'follow_up', 'integer', '24', 'زمان پیش‌فرض پیگیری', 'برای ایجاد پیگیری پیش‌فرض استفاده می‌شود.', true, false, false, null, ['CallService']),
self::d('reminder_before_due_minutes', 'follow_up', 'integer', '30', 'یادآوری قبل از موعد', 'نوتیفیکیشن زمان‌بندی‌شده به‌زودی.', false, false, false, null, [], true),
self::d('overdue_alert_hours', 'follow_up', 'integer', '2', 'هشدار تأخیر', 'در گزارش عقب‌افتادگی نگهداری می‌شود.', false, false, false, null, [], true),
self::d('escalate_overdue_to_supervisor', 'follow_up', 'boolean', 'false', 'ارجاع تأخیر به سرپرست', 'به‌زودی.', false, false, false, null, [], true),
self::d('auto_follow_up_call_results', 'follow_up', 'json', 'بعداً تماس بگیرید,نیازمند پیگیری,معرفی شماره جدید', 'نتایج تماس نیازمند پیگیری', 'با requires_follow_up در call_results همگام می‌شود.', true, false, false, null, ['CallController']),
self::d('prevent_follow_up_outside_working_hours', 'follow_up', 'boolean', 'false', 'جلوگیری خارج از ساعت کاری', 'در ساخت پیگیری دستی و بعد از تماس اعمال می‌شود.', true, false, false, null, ['FollowUpController', 'CallService']),
self::d('in_app_notifications_enabled', 'notification', 'boolean', 'true', 'اعلان داخل برنامه', 'سیستم اعلان داخلی فعال است.', true, false, false, null, ['NotificationService']),
self::d('browser_notifications_enabled', 'notification', 'boolean', 'false', 'اعلان مرورگر/PWA', 'اجازه push/browser به‌زودی.', false, false, false, null, [], true),
self::d('new_lead_notification_enabled', 'notification', 'boolean', 'true', 'اعلان لید جدید', 'در تخصیص‌ها و آینده ایجاد لید استفاده می‌شود.', true, false, false, null, ['NotificationService']),
self::d('assigned_lead_notification_enabled', 'notification', 'boolean', 'true', 'اعلان تخصیص لید', 'در LeadService و ImportService استفاده می‌شود.', true, false, false, null, ['LeadService', 'ImportService']),
self::d('follow_up_notification_enabled', 'notification', 'boolean', 'true', 'اعلان پیگیری', 'در FollowUpController و CallService استفاده می‌شود.', true, false, false, null, ['FollowUpController', 'CallService']),
self::d('overdue_notification_enabled', 'notification', 'boolean', 'true', 'اعلان پیگیری عقب‌افتاده', 'در endpoint پیگیری‌های عقب‌افتاده استفاده می‌شود.', true, false, false, null, ['FollowUpController@overdue']),
self::d('quiet_hours_start', 'notification', 'string', '22:00', 'شروع سکوت اعلان', 'اعمال quiet hours به‌زودی.', false, false, false, null, [], true),
self::d('quiet_hours_end', 'notification', 'string', '08:00', 'پایان سکوت اعلان', 'اعمال quiet hours به‌زودی.', false, false, false, null, [], true),
self::d('import_required_fields', 'import_export', 'json', 'company,phone', 'فیلدهای الزامی ورود داده', 'در ورود فعلی company و phone الزامی هستند.', true, false, false, null, ['ImportController']),
self::d('import_max_file_size_mb', 'import_export', 'integer', '10', 'حداکثر حجم ورود داده', 'در بارگذاری فایل ورود داده اعمال می‌شود.', true, false, false, null, ['ImportController']),
self::d('import_allowed_file_types', 'import_export', 'json', 'xlsx,xls,csv', 'نوع فایل ورود داده', 'در بارگذاری فایل ورود داده اعمال می‌شود.', true, false, false, null, ['ImportController']),
self::d('export_phone_mode', 'import_export', 'string', 'permission_based', 'حالت شماره در خروجی', 'full فقط با view_full_phone؛ masked برای بقیه.', true, false, false, ['permission_based', 'masked_only'], ['LeadController@export']),
self::d('import_rollback_allowed', 'import_export', 'boolean', 'true', 'بازگردانی ورود داده', 'در ساخت دسته ورود و بازگردانی ورود داده استفاده می‌شود.', true, false, false, null, ['ImportController']),
self::d('mapping_presets', 'import_export', 'json', '', 'الگوهای نگاشت', 'الگوهای نگاشت به‌زودی.', false, false, false, null, [], true),
self::d('attachment_max_file_size_mb', 'import_export', 'integer', '10', 'حداکثر حجم فایل پیوست', 'در بارگذاری فایل‌های لید/شرکت/فرصت اعمال می‌شود.', true, false, false, null, ['AttachmentController']),
self::d('attachment_allowed_file_types', 'import_export', 'json', 'pdf,jpg,jpeg,png,doc,docx,xls,xlsx,txt', 'نوع فایل پیوست', 'در بارگذاری فایل‌های پیوست اعمال می‌شود.', true, false, false, null, ['AttachmentController']),
self::d('daily_call_target', 'reports', 'integer', '40', 'هدف تماس روزانه', 'در داشبورد کارشناس و هشدارهای سرپرست/مدیریت استفاده می‌شود.', true, false, false, null, ['DashboardService']),
self::d('successful_call_target', 'reports', 'integer', '15', 'هدف تماس موفق', 'در پیشرفت KPI کارشناس استفاده می‌شود.', true, false, false, null, ['DashboardService']),
self::d('follow_up_target', 'reports', 'integer', '20', 'هدف پیگیری', 'در پیشرفت KPI کارشناس استفاده می‌شود.', true, false, false, null, ['DashboardService']),
self::d('conversion_target_percent', 'reports', 'integer', '20', 'هدف تبدیل', 'برای مقایسه مدیریتی نرخ تبدیل نگهداری و در گزارش‌ها قابل استفاده است.', true, false, false, null, ['ReportService', 'DashboardService']),
self::d('default_report_range', 'reports', 'string', 'month', 'بازه پیش‌فرض گزارش', 'اعمال پیش‌فرض گزارش به‌زودی.', false, false, false, ['today', 'week', 'month', 'quarter'], [], true),
self::d('pwa_enabled', 'pwa', 'boolean', 'true', 'فعال بودن PWA', 'manifest/service worker موجود است؛ خاموش کردن runtime به‌زودی.', false, true, false, null, [], true),
self::d('pwa_install_banner_text', 'pwa', 'string', 'نصب CRM فروش', 'متن بنر نصب PWA', 'در بنر نصب PWA نمایش داده می‌شود.', false, true, false, null, [], true),
self::d('pwa_theme_color', 'pwa', 'string', '#2563eb', 'رنگ theme', 'در پاسخ عمومی منتشر می‌شود؛ تزریق manifest به‌زودی.', false, true, false, null, [], true),
self::d('pwa_status_bar_color', 'pwa', 'string', '#ffffff', 'رنگ نوار وضعیت', 'اعمال کامل به‌زودی.', false, true, false, null, [], true),
self::d('ui_density', 'pwa', 'string', 'comfortable', 'حالت نمایش', 'compact/comfortable در UI آینده اعمال می‌شود.', false, true, false, ['compact', 'comfortable'], [], true),
self::d('default_landing_page', 'pwa', 'string', '/', 'صفحه پیش‌فرض', 'redirect پویا به‌زودی.', false, true, false, ['/', '/leads', '/crm', '/agent-mobile/today'], [], true),
self::d('offline_cache_duration_hours', 'pwa', 'integer', '24', 'مدت cache آفلاین', 'service worker پویا به‌زودی.', false, false, false, null, [], true),
self::d('push_permission_text', 'pwa', 'string', 'برای دریافت یادآوری‌ها اعلان‌ها را فعال کنید.', 'متن اجازه push', 'push notification به‌زودی.', false, true, false, null, [], true),
];
}
public static function ensureDefaults(): void
{
foreach (self::definitions() as $definition) {
$setting = Setting::firstOrNew(['key' => $definition['key']]);
$setting->fill([
'value' => $setting->exists ? $setting->value : $definition['default_value'],
'default_value' => $definition['default_value'],
'group' => $definition['group'],
'type' => $definition['type'],
'allowed_values' => $definition['allowed_values'],
'is_secret' => $definition['is_secret'],
'is_public' => $definition['is_public'],
'is_runtime_enforced' => $definition['is_runtime_enforced'],
'description' => $definition['hint'],
'validation_rules' => self::rulesFor($definition),
])->save();
}
}
public static function metaFor(string $key): ?array
{
foreach (self::definitions() as $definition) {
if ($definition['key'] === $key) {
return $definition;
}
}
return null;
}
private static function d(string $key, string $group, string $type, string $default, string $label, string $hint, bool $enforced, bool $public = false, bool $secret = false, ?array $allowed = null, array $usedBy = [], bool $comingSoon = false): array
{
return [
'key' => $key,
'group' => $group,
'type' => $type,
'default_value' => $default,
'label' => $label,
'hint' => $hint,
'allowed_values' => $allowed,
'is_runtime_enforced' => $enforced,
'is_public' => $public,
'is_secret' => $secret,
'used_by' => $usedBy,
'coming_soon' => $comingSoon,
];
}
private static function rulesFor(array $definition): array
{
return match ($definition['type']) {
'boolean' => ['in:true,false,1,0'],
'integer' => ['integer'],
'json' => ['nullable', 'string'],
default => ['nullable', 'string'],
};
}
}

مشاهده پرونده

@ -0,0 +1,34 @@
<?php
namespace App\Support;
use App\Models\Setting;
use Carbon\Carbon;
class WorkingHours
{
public static function followUpAllowed(string $dateTime): bool
{
if (Setting::where('key', 'prevent_follow_up_outside_working_hours')->value('value') !== 'true') {
return true;
}
$at = Carbon::parse($dateTime);
$dayMap = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
$workingDays = array_filter(array_map('trim', explode(',', Setting::where('key', 'working_days')->value('value') ?: 'sat,sun,mon,tue,wed')));
if (!in_array($dayMap[$at->dayOfWeek], $workingDays, true)) {
return false;
}
$holidays = array_filter(array_map('trim', explode(',', Setting::where('key', 'custom_holidays')->value('value') ?: '')));
if (in_array($at->toDateString(), $holidays, true)) {
return false;
}
$start = Setting::where('key', 'working_hours_start')->value('value') ?: '09:00';
$end = Setting::where('key', 'working_hours_end')->value('value') ?: '18:00';
$time = $at->format('H:i');
return $time >= $start && $time <= $end;
}
}

18
backend/artisan normal فایل
مشاهده پرونده

@ -0,0 +1,18 @@
#!/usr/bin/env php
<?php
use Illuminate\Foundation\Application;
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true));
// Register the Composer autoloader...
require __DIR__.'/vendor/autoload.php';
// Bootstrap Laravel and handle the command...
/** @var Application $app */
$app = require_once __DIR__.'/bootstrap/app.php';
$status = $app->handleCommand(new ArgvInput);
exit($status);

43
backend/bootstrap/app.php normal فایل
مشاهده پرونده

@ -0,0 +1,43 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
use Illuminate\Auth\AuthenticationException;
use App\Http\Middleware\MaskPhoneNumber;
use App\Http\Middleware\SecurityHeaders;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__ . '/../routes/web.php',
api: __DIR__ . '/../routes/api.php',
commands: __DIR__ . '/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->alias([
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
'mask_phone' => MaskPhoneNumber::class,
]);
$middleware->api(prepend: [
\Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
]);
$middleware->api(append: [
MaskPhoneNumber::class,
]);
$middleware->redirectGuestsTo(fn (Request $request) => $request->is('api/*') || $request->expectsJson() ? null : '/login');
$middleware->append(SecurityHeaders::class);
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->render(function (AuthenticationException $e, Request $request) {
if ($request->is('api/*')) {
return response()->json(['message' => 'Unauthenticated.'], 401);
}
});
})->create();

2
backend/bootstrap/cache/.gitignore فروخته شده normal فایل
مشاهده پرونده

@ -0,0 +1,2 @@
*
!.gitignore

مشاهده پرونده

@ -0,0 +1,7 @@
<?php
use App\Providers\AppServiceProvider;
return [
AppServiceProvider::class,
];

90
backend/composer.json normal فایل
مشاهده پرونده

@ -0,0 +1,90 @@
{
"$schema": "https://getcomposer.org/schema.json",
"name": "laravel/laravel",
"type": "project",
"description": "The skeleton application for the Laravel framework.",
"keywords": ["laravel", "framework"],
"license": "MIT",
"require": {
"php": "^8.2",
"laravel/framework": "^12.0",
"laravel/sanctum": "^4.3",
"laravel/tinker": "^2.10.1",
"maatwebsite/excel": "^3.1",
"spatie/laravel-permission": "6.25"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/pail": "^1.2.2",
"laravel/pint": "^1.24",
"laravel/sail": "^1.41",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"phpunit/phpunit": "^11.5.50"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"setup": [
"composer install",
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
"@php artisan key:generate",
"@php artisan migrate --force",
"npm install",
"npm run build"
],
"dev": [
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve --host=127.0.0.1 --port=8800\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
],
"serve:dev": "php artisan serve --host=127.0.0.1 --port=8800",
"test": [
"@php artisan config:clear --ansi",
"@php artisan test"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi",
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
"@php artisan migrate --graceful --ansi"
],
"pre-package-uninstall": [
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
]
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true
}
},
"minimum-stability": "stable",
"prefer-stable": true
}

برخی از فایل ها نشان داده نشدند زیرا تعداد زیادی فایل در این تفاوت تغییر کرده اند نمایش بیشتر