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