190 خطوط
8.1 KiB
PHP
190 خطوط
8.1 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\Call;
|
|
use App\Models\QualityReview;
|
|
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 QualityReviewController extends Controller
|
|
{
|
|
private const SCORE_FIELDS = [
|
|
'greeting_score',
|
|
'product_intro_score',
|
|
'needs_discovery_score',
|
|
'objection_handling_score',
|
|
'closing_score',
|
|
'crm_accuracy_score',
|
|
'follow_up_quality_score',
|
|
];
|
|
|
|
public function index(Request $request): JsonResponse
|
|
{
|
|
Gate::authorize('viewAny', QualityReview::class);
|
|
|
|
if ($request->has('current_only')) {
|
|
$request->merge([
|
|
'current_only' => filter_var(
|
|
$request->input('current_only'),
|
|
FILTER_VALIDATE_BOOLEAN,
|
|
FILTER_NULL_ON_FAILURE,
|
|
),
|
|
]);
|
|
}
|
|
|
|
$filters = $request->validate([
|
|
'agent_id' => 'nullable|integer|exists:users,id',
|
|
'score_min' => 'nullable|integer|min:0|max:100',
|
|
'score_max' => 'nullable|integer|min:0|max:100|gte:score_min',
|
|
'status' => 'nullable|string|max:30',
|
|
'shared' => 'nullable|boolean',
|
|
'date_from' => 'nullable|date',
|
|
'date_to' => 'nullable|date|after_or_equal:date_from',
|
|
'result' => 'nullable|string|max:50',
|
|
'has_recording' => 'nullable|boolean',
|
|
'weak_section' => 'nullable|in:greeting_score,product_intro_score,needs_discovery_score,objection_handling_score,closing_score,crm_accuracy_score,follow_up_quality_score',
|
|
'weak_threshold' => 'nullable|integer|min:0|max:100',
|
|
'current_only' => 'nullable|boolean',
|
|
'page' => 'nullable|integer|min:1',
|
|
'per_page' => 'nullable|integer|min:1|max:100',
|
|
]);
|
|
$user = $request->user();
|
|
$query = QualityReview::with('call:id,lead_id,user_id,result,recording_url,created_at', 'agent:id,name', 'reviewer:id,name');
|
|
|
|
if ($user->hasRole('agent')) {
|
|
$query->where('agent_id', $user->id)->where('is_shared_with_agent', true);
|
|
} elseif ($user->hasRole('supervisor')) {
|
|
$query->whereHas('call', fn ($callQuery) => AccessControl::scopeCalls($callQuery, $user));
|
|
}
|
|
|
|
if ($request->boolean('current_only', true)) {
|
|
$query->where('is_current', true);
|
|
}
|
|
if (! empty($filters['agent_id'])) {
|
|
$query->where('agent_id', $filters['agent_id']);
|
|
}
|
|
if (isset($filters['score_min'])) {
|
|
$query->where('overall_score', '>=', $filters['score_min']);
|
|
}
|
|
if (isset($filters['score_max'])) {
|
|
$query->where('overall_score', '<=', $filters['score_max']);
|
|
}
|
|
if (! empty($filters['status'])) {
|
|
$query->where('status', $filters['status']);
|
|
}
|
|
if (array_key_exists('shared', $filters)) {
|
|
$query->where('is_shared_with_agent', (bool) $filters['shared']);
|
|
}
|
|
if (! empty($filters['date_from'])) {
|
|
$query->whereDate('created_at', '>=', $filters['date_from']);
|
|
}
|
|
if (! empty($filters['date_to'])) {
|
|
$query->whereDate('created_at', '<=', $filters['date_to']);
|
|
}
|
|
if (! empty($filters['result'])) {
|
|
$query->whereHas('call', fn ($call) => $call->where('result', $filters['result']));
|
|
}
|
|
if (array_key_exists('has_recording', $filters)) {
|
|
$query->whereHas('call', fn ($call) => (bool) $filters['has_recording']
|
|
? $call->whereNotNull('recording_url')
|
|
: $call->whereNull('recording_url'));
|
|
}
|
|
if (! empty($filters['weak_section'])) {
|
|
$query->where($filters['weak_section'], '<', $filters['weak_threshold'] ?? 60);
|
|
}
|
|
|
|
return response()->json($query->latest()->paginate(min((int) $request->get('per_page', 15), 100)));
|
|
}
|
|
|
|
public function store(Request $request): JsonResponse
|
|
{
|
|
$validated = $request->validate(array_merge([
|
|
'call_id' => 'required|exists:calls,id',
|
|
'feedback' => 'nullable|string',
|
|
'tag' => 'nullable|string|max:30',
|
|
'is_shared_with_agent' => 'nullable|boolean',
|
|
'strengths' => 'nullable|array|max:20',
|
|
'improvement_areas' => 'nullable|array|max:20',
|
|
], $this->scoreRules()));
|
|
|
|
$call = Call::findOrFail($validated['call_id']);
|
|
Gate::authorize('create', [QualityReview::class, $call]);
|
|
|
|
$review = DB::transaction(function () use ($validated, $call, $request): QualityReview {
|
|
$latestVersion = QualityReview::where('call_id', $call->id)->lockForUpdate()->max('version') ?? 0;
|
|
QualityReview::where('call_id', $call->id)->update(['is_current' => false]);
|
|
|
|
return QualityReview::create($validated + [
|
|
'reviewer_id' => $request->user()->id,
|
|
'agent_id' => $call->user_id,
|
|
'version' => $latestVersion + 1,
|
|
'is_current' => true,
|
|
'is_shared_with_agent' => $validated['is_shared_with_agent'] ?? false,
|
|
'overall_score' => $this->overallScore($validated),
|
|
]);
|
|
});
|
|
|
|
ActivityLogger::log('quality_review_created', "Quality review for call {$call->id} created", $review);
|
|
|
|
return response()->json($review->load('call:id,lead_id,user_id,created_at', 'agent:id,name', 'reviewer:id,name'), 201);
|
|
}
|
|
|
|
public function show(QualityReview $qualityReview): JsonResponse
|
|
{
|
|
Gate::authorize('view', $qualityReview);
|
|
|
|
return response()->json($qualityReview->load('call.lead', 'agent:id,name', 'reviewer:id,name'));
|
|
}
|
|
|
|
public function update(Request $request, QualityReview $qualityReview): JsonResponse
|
|
{
|
|
Gate::authorize('update', $qualityReview);
|
|
$validated = $request->validate(array_merge([
|
|
'feedback' => 'nullable|string',
|
|
'tag' => 'nullable|string|max:30',
|
|
'is_shared_with_agent' => 'nullable|boolean',
|
|
'strengths' => 'nullable|array|max:20',
|
|
'improvement_areas' => 'nullable|array|max:20',
|
|
], $this->scoreRules(required: false)));
|
|
|
|
if (collect(self::SCORE_FIELDS)->contains(fn (string $field) => array_key_exists($field, $validated))) {
|
|
$validated['overall_score'] = $this->overallScore(array_merge($qualityReview->only(self::SCORE_FIELDS), $validated));
|
|
}
|
|
|
|
$qualityReview->update($validated);
|
|
ActivityLogger::log('quality_review_updated', "Quality review {$qualityReview->id} updated", $qualityReview);
|
|
|
|
return response()->json($qualityReview->fresh(['call:id,lead_id,user_id,created_at', 'agent:id,name', 'reviewer:id,name']));
|
|
}
|
|
|
|
public function acknowledge(Request $request, QualityReview $qualityReview): JsonResponse
|
|
{
|
|
abort_unless($request->user()->can('acknowledge_quality_reviews'), 403);
|
|
Gate::authorize('view', $qualityReview);
|
|
abort_unless($qualityReview->agent_id === $request->user()->id, 403, 'فقط کارشناس ارزیابیشده میتواند تأیید کند.');
|
|
$data = $request->validate(['agent_response' => 'nullable|string|max:2000']);
|
|
$qualityReview->update(['status' => 'acknowledged', 'acknowledged_at' => now(), 'agent_response' => $data['agent_response'] ?? null]);
|
|
ActivityLogger::log('quality_review_acknowledged', "Quality review {$qualityReview->id} acknowledged", $qualityReview);
|
|
|
|
return response()->json($qualityReview->fresh());
|
|
}
|
|
|
|
private function scoreRules(bool $required = true): array
|
|
{
|
|
return collect(self::SCORE_FIELDS)->mapWithKeys(
|
|
fn (string $field): array => [$field => ($required ? 'required' : 'sometimes').'|integer|min:0|max:100']
|
|
)->all();
|
|
}
|
|
|
|
private function overallScore(array $data): int
|
|
{
|
|
return (int) round(collect(self::SCORE_FIELDS)->map(fn (string $field) => (int) $data[$field])->avg());
|
|
}
|
|
}
|