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()); } }