|
|
@@ -0,0 +1,284 @@
|
|
|
+<?php
|
|
|
+
|
|
|
+namespace App\Services;
|
|
|
+
|
|
|
+use App\Enums\DefaultStatusEnum;
|
|
|
+use App\Enums\UserTypeEnum;
|
|
|
+use App\Models\Evaluation;
|
|
|
+use App\Models\EvaluationCampaign;
|
|
|
+use App\Models\User;
|
|
|
+use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
|
|
+use Illuminate\Support\Facades\Auth;
|
|
|
+use Illuminate\Support\Facades\DB;
|
|
|
+
|
|
|
+class EvaluationCampaignService
|
|
|
+{
|
|
|
+ public function getAllPaginated(int $perPage = 12): LengthAwarePaginator
|
|
|
+ {
|
|
|
+ return EvaluationCampaign::with('createdBy')
|
|
|
+ ->withCount('evaluations')
|
|
|
+ ->withAvg('evaluations', 'rating')
|
|
|
+ ->orderByRaw("CASE WHEN status = 'ACTIVE' THEN 0 ELSE 1 END")
|
|
|
+ ->orderBy('created_at', 'desc')
|
|
|
+ ->paginate($perPage);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function findById(int $id): ?EvaluationCampaign
|
|
|
+ {
|
|
|
+ return EvaluationCampaign::with('createdBy')
|
|
|
+ ->withCount('evaluations')
|
|
|
+ ->withAvg('evaluations', 'rating')
|
|
|
+ ->find($id);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Toda campanha nasce ativa: a que estiver no ar é encerrada no mesmo instante.
|
|
|
+ */
|
|
|
+ public function create(array $data): EvaluationCampaign
|
|
|
+ {
|
|
|
+ return DB::transaction(function () use ($data) {
|
|
|
+ $this->finishActiveCampaigns();
|
|
|
+
|
|
|
+ $campaign = EvaluationCampaign::create([
|
|
|
+ 'title' => $data['title'],
|
|
|
+ 'description' => $data['description'] ?? null,
|
|
|
+ 'target' => $data['target'],
|
|
|
+ 'status' => DefaultStatusEnum::ACTIVE->value,
|
|
|
+ 'started_at' => now(),
|
|
|
+ 'created_by' => Auth::id(),
|
|
|
+ ]);
|
|
|
+
|
|
|
+ return $campaign->load('createdBy');
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Apenas título e descrição — alvo e ciclo de vida não mudam depois da criação.
|
|
|
+ */
|
|
|
+ public function update(int $id, array $data): ?EvaluationCampaign
|
|
|
+ {
|
|
|
+ $campaign = EvaluationCampaign::find($id);
|
|
|
+
|
|
|
+ if (!$campaign) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ $campaign->update(array_intersect_key($data, array_flip(['title', 'description'])));
|
|
|
+
|
|
|
+ return $campaign->fresh('createdBy');
|
|
|
+ }
|
|
|
+
|
|
|
+ public function delete(int $id): bool
|
|
|
+ {
|
|
|
+ $campaign = EvaluationCampaign::find($id);
|
|
|
+
|
|
|
+ if (!$campaign) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ return (bool) $campaign->delete();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Encerra a campanha: registra o fim e tira do ar. Não há reabertura.
|
|
|
+ *
|
|
|
+ * @return array{error?: string, campaign?: EvaluationCampaign}
|
|
|
+ */
|
|
|
+ public function finish(int $id): array
|
|
|
+ {
|
|
|
+ $campaign = EvaluationCampaign::find($id);
|
|
|
+
|
|
|
+ if (!$campaign) {
|
|
|
+ return ['error' => 'not_found'];
|
|
|
+ }
|
|
|
+
|
|
|
+ if ($campaign->isFinished()) {
|
|
|
+ return ['error' => 'already_finished'];
|
|
|
+ }
|
|
|
+
|
|
|
+ $campaign->update([
|
|
|
+ 'status' => DefaultStatusEnum::INACTIVE->value,
|
|
|
+ 'finished_at' => now(),
|
|
|
+ ]);
|
|
|
+
|
|
|
+ return ['campaign' => $campaign->fresh('createdBy')];
|
|
|
+ }
|
|
|
+
|
|
|
+ private function finishActiveCampaigns(): void
|
|
|
+ {
|
|
|
+ EvaluationCampaign::where('status', DefaultStatusEnum::ACTIVE->value)
|
|
|
+ ->update([
|
|
|
+ 'status' => DefaultStatusEnum::INACTIVE->value,
|
|
|
+ 'finished_at' => now(),
|
|
|
+ 'updated_at' => now(),
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function getActiveCampaign(): ?EvaluationCampaign
|
|
|
+ {
|
|
|
+ return EvaluationCampaign::where('status', DefaultStatusEnum::ACTIVE->value)->first();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Campanha que o usuário ainda precisa responder, ou null se não houver pendência.
|
|
|
+ */
|
|
|
+ public function getPendingForUser(User $user): ?EvaluationCampaign
|
|
|
+ {
|
|
|
+ $campaign = $this->getActiveCampaign();
|
|
|
+
|
|
|
+ if (!$campaign) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!$campaign->target->accepts($user->type)) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ $alreadyAnswered = Evaluation::where('evaluation_campaign_id', $campaign->id)
|
|
|
+ ->where('user_id', $user->id)
|
|
|
+ ->exists();
|
|
|
+
|
|
|
+ return $alreadyAnswered ? null : $campaign;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Registra a avaliação do usuário autenticado na campanha ativa.
|
|
|
+ *
|
|
|
+ * @return array{error?: string, evaluation?: Evaluation}
|
|
|
+ */
|
|
|
+ public function submit(User $user, int $campaignId, array $data): array
|
|
|
+ {
|
|
|
+ $campaign = EvaluationCampaign::find($campaignId);
|
|
|
+
|
|
|
+ if (!$campaign) {
|
|
|
+ return ['error' => 'not_found'];
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!$campaign->isActive()) {
|
|
|
+ return ['error' => 'inactive'];
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!$campaign->target->accepts($user->type)) {
|
|
|
+ return ['error' => 'not_eligible'];
|
|
|
+ }
|
|
|
+
|
|
|
+ $exists = Evaluation::where('evaluation_campaign_id', $campaign->id)
|
|
|
+ ->where('user_id', $user->id)
|
|
|
+ ->exists();
|
|
|
+
|
|
|
+ if ($exists) {
|
|
|
+ return ['error' => 'already_answered'];
|
|
|
+ }
|
|
|
+
|
|
|
+ $evaluation = Evaluation::create([
|
|
|
+ 'evaluation_campaign_id' => $campaign->id,
|
|
|
+ 'user_id' => $user->id,
|
|
|
+ 'rating' => $data['rating'],
|
|
|
+ 'comment' => $data['comment'] ?? null,
|
|
|
+ ]);
|
|
|
+
|
|
|
+ return ['evaluation' => $evaluation->load('user')];
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Avaliações de uma campanha, com filtros de origem, faixa de nota e busca por nome.
|
|
|
+ */
|
|
|
+ public function getEvaluationsPaginated(int $campaignId, array $filters = [], int $perPage = 12): LengthAwarePaginator
|
|
|
+ {
|
|
|
+ $query = Evaluation::with('user')
|
|
|
+ ->where('evaluation_campaign_id', $campaignId);
|
|
|
+
|
|
|
+ if (!empty($filters['source'])) {
|
|
|
+ $query->whereHas('user', fn($q) => $q->where('type', $filters['source']));
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!empty($filters['min_rating'])) {
|
|
|
+ $query->where('rating', '>=', (int) $filters['min_rating']);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!empty($filters['max_rating'])) {
|
|
|
+ $query->where('rating', '<=', (int) $filters['max_rating']);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!empty($filters['search'])) {
|
|
|
+ $term = '%' . mb_strtolower($filters['search']) . '%';
|
|
|
+ $query->where(function ($q) use ($term) {
|
|
|
+ $q->whereHas('user', fn($u) => $u->whereRaw('UNACCENT(LOWER(name)) LIKE UNACCENT(?)', [$term]))
|
|
|
+ ->orWhereRaw('UNACCENT(LOWER(comment)) LIKE UNACCENT(?)', [$term]);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ return $query->orderBy('created_at', 'desc')->paginate($perPage);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Blocos de estatística da campanha: total, média, adesão, distribuição e quebra por origem.
|
|
|
+ */
|
|
|
+ public function getStats(int $campaignId): ?array
|
|
|
+ {
|
|
|
+ $campaign = EvaluationCampaign::find($campaignId);
|
|
|
+
|
|
|
+ if (!$campaign) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ $totals = Evaluation::where('evaluation_campaign_id', $campaignId)
|
|
|
+ ->selectRaw('COUNT(*) as total, AVG(rating) as average')
|
|
|
+ ->first();
|
|
|
+
|
|
|
+ $total = (int) ($totals->total ?? 0);
|
|
|
+ $average = $totals->average !== null ? round((float) $totals->average, 2) : null;
|
|
|
+
|
|
|
+ $distributionRaw = Evaluation::where('evaluation_campaign_id', $campaignId)
|
|
|
+ ->selectRaw('rating, COUNT(*) as total')
|
|
|
+ ->groupBy('rating')
|
|
|
+ ->pluck('total', 'rating');
|
|
|
+
|
|
|
+ $distribution = [];
|
|
|
+ for ($rating = 1; $rating <= 10; $rating++) {
|
|
|
+ $distribution[] = [
|
|
|
+ 'rating' => $rating,
|
|
|
+ 'total' => (int) ($distributionRaw[$rating] ?? 0),
|
|
|
+ ];
|
|
|
+ }
|
|
|
+
|
|
|
+ $bySourceRaw = Evaluation::where('evaluation_campaign_id', $campaignId)
|
|
|
+ ->join('users', 'users.id', '=', 'evaluations.user_id')
|
|
|
+ ->selectRaw('users.type as source, COUNT(*) as total, AVG(evaluations.rating) as average')
|
|
|
+ ->groupBy('users.type')
|
|
|
+ ->get()
|
|
|
+ ->keyBy('source');
|
|
|
+
|
|
|
+ $bySource = [];
|
|
|
+ foreach ($campaign->target->userTypes() as $userType) {
|
|
|
+ $row = $bySourceRaw->get($userType->value);
|
|
|
+
|
|
|
+ $bySource[] = [
|
|
|
+ 'source' => $userType->value,
|
|
|
+ 'total' => (int) ($row->total ?? 0),
|
|
|
+ 'average' => isset($row->average) ? round((float) $row->average, 2) : null,
|
|
|
+ ];
|
|
|
+ }
|
|
|
+
|
|
|
+ $eligible = $this->countEligibleUsers($campaign);
|
|
|
+
|
|
|
+ return [
|
|
|
+ 'campaign_id' => $campaign->id,
|
|
|
+ 'total' => $total,
|
|
|
+ 'average' => $average,
|
|
|
+ 'eligible_total' => $eligible,
|
|
|
+ 'adherence' => $eligible > 0 ? round(($total / $eligible) * 100, 1) : 0.0,
|
|
|
+ 'distribution' => $distribution,
|
|
|
+ 'by_source' => $bySource,
|
|
|
+ ];
|
|
|
+ }
|
|
|
+
|
|
|
+ private function countEligibleUsers(EvaluationCampaign $campaign): int
|
|
|
+ {
|
|
|
+ $types = array_map(fn(UserTypeEnum $type) => $type->value, $campaign->target->userTypes());
|
|
|
+
|
|
|
+ return User::whereIn('type', $types)
|
|
|
+ ->whereNull('excluded_at')
|
|
|
+ ->count();
|
|
|
+ }
|
|
|
+}
|