first commit
مخزن کامیت contained در
کامیت
b246b94754
|
|
@ -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/
|
||||
|
|
@ -0,0 +1 @@
|
|||
|
||||
|
|
@ -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
|
||||
|
|
@ -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}"
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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();
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitignore
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Providers\AppServiceProvider;
|
||||
|
||||
return [
|
||||
AppServiceProvider::class,
|
||||
];
|
||||
|
|
@ -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
|
||||
}
|
||||
برخی از فایل ها نشان داده نشدند زیرا تعداد زیادی فایل در این تفاوت تغییر کرده اند نمایش بیشتر
بارگذاری…
مرجع در شماره جدید