| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676 |
- <?php
- namespace Models;
- use Libs\Database;
- class AnalyticsSentimentDashboardModel
- {
- private \PDO $pdo;
- public function __construct()
- {
- $this->pdo = Database::pdo();
- }
- public function getDashboardData(int $companyId, array $queryParams): array
- {
- $filters = $this->normalizeFilters($queryParams);
- $summaryRange = $this->resolveRange($filters);
- return [
- 'summaryCards' => $this->getSummaryCards($companyId, $filters, $summaryRange),
- 'alerts' => $this->getAlerts($companyId, $filters, $summaryRange),
- 'timelineViews' => [
- 'day' => $this->getDayTimeline($companyId, $filters),
- 'week' => $this->getWeekTimeline($companyId, $filters),
- 'month' => $this->getMonthTimeline($companyId, $filters),
- ],
- 'aspects' => $this->getAspects($companyId, $filters, $summaryRange),
- ];
- }
- private function normalizeFilters(array $queryParams): array
- {
- $timeframe = strtolower(trim((string) ($queryParams['timeframe'] ?? 'week')));
- if (!in_array($timeframe, ['day', 'week', 'month', 'custom'], true)) {
- $timeframe = 'week';
- }
- // Intervalo personalizado: aceita start_date/end_date no formato YYYY-MM-DD.
- // Se as datas forem válidas, o timeframe é tratado como "custom" mesmo que
- // não tenha sido enviado explicitamente.
- $customStart = $this->parseDate((string) ($queryParams['start_date'] ?? ''));
- $customEnd = $this->parseDate((string) ($queryParams['end_date'] ?? ''));
- if ($customStart !== null && $customEnd !== null) {
- $timeframe = 'custom';
- if ($customEnd < $customStart) {
- [$customStart, $customEnd] = [$customEnd, $customStart];
- }
- } elseif ($timeframe === 'custom') {
- // "custom" sem datas válidas cai para o padrão semanal.
- $timeframe = 'week';
- }
- $sentiment = strtolower(trim((string) ($queryParams['sentiment'] ?? 'all')));
- if (!in_array($sentiment, ['all', 'positive', 'neutral', 'negative'], true)) {
- $sentiment = 'all';
- }
- $aspect = trim((string) ($queryParams['aspect'] ?? ''));
- if ($aspect === '' || strtolower($aspect) === 'all') {
- $aspect = null;
- }
- return [
- 'timeframe' => $timeframe,
- 'sentiment' => $sentiment,
- 'aspect' => $aspect !== null ? mb_strtolower($aspect) : null,
- 'custom_start' => $customStart,
- 'custom_end' => $customEnd,
- ];
- }
- private function parseDate(string $value): ?\DateTimeImmutable
- {
- $value = trim($value);
- if ($value === '') {
- return null;
- }
- $date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value);
- $errors = \DateTimeImmutable::getLastErrors();
- if ($date === false || ($errors !== false && ($errors['warning_count'] > 0 || $errors['error_count'] > 0))) {
- return null;
- }
- return $date;
- }
- private function resolveRange(array $filters): array
- {
- $today = new \DateTimeImmutable('today');
- $timeframe = $filters['timeframe'];
- if ($timeframe === 'custom' && $filters['custom_start'] !== null && $filters['custom_end'] !== null) {
- $start = $filters['custom_start'];
- $end = $filters['custom_end'];
- } else {
- if ($timeframe === 'day') {
- $start = $today;
- } elseif ($timeframe === 'month') {
- $start = $today->modify('-29 days');
- } else {
- $start = $today->modify('-6 days');
- }
- $end = $today;
- }
- return [
- 'start_datetime' => $start->format('Y-m-d 00:00:00'),
- 'end_exclusive_datetime' => $end->modify('+1 day')->format('Y-m-d 00:00:00'),
- ];
- }
- private function getSummaryCards(int $companyId, array $filters, array $range): array
- {
- $atRiskClients = $this->getAlertClientCountByTypes(
- $companyId,
- ['churn_risk', 'frustration', 'risk', 'at_risk'],
- $range
- );
- $opportunities = $this->getAlertClientCountByTypes(
- $companyId,
- ['buying_intent', 'opportunity', 'upsell', 'cross_sell'],
- $range
- );
- $recentInteractions = $this->getRecentInteractionsCount($companyId, $filters, $range);
- $netTrend = $this->getNetTrend($companyId, $filters, $range);
- return [
- [
- 'id' => 'atRiskClients',
- 'label' => 'Clientes em risco',
- 'value' => $atRiskClients,
- 'image' => '/images/sentiment/risk.svg',
- ],
- [
- 'id' => 'opportunities',
- 'label' => 'Oportunidades',
- 'value' => $opportunities,
- 'image' => '/images/sentiment/opportunity.svg',
- ],
- [
- 'id' => 'recentInteractions',
- 'label' => 'Interacoes recentes',
- 'value' => $recentInteractions,
- 'image' => '/images/sentiment/interactions.svg',
- ],
- [
- 'id' => 'netTrend',
- 'label' => 'Tendencia liquida',
- 'value' => $netTrend,
- 'image' => '/images/sentiment/trend.svg',
- ],
- ];
- }
- private function getAlertClientCountByTypes(int $companyId, array $types, array $range): int
- {
- $placeholders = [];
- $params = [
- 'company_id' => $companyId,
- 'start_datetime' => $range['start_datetime'],
- 'end_exclusive_datetime' => $range['end_exclusive_datetime'],
- ];
- foreach ($types as $index => $type) {
- $key = 'type_' . $index;
- $placeholders[] = ':' . $key;
- $params[$key] = $type;
- }
- $stmt = $this->pdo->prepare(
- "SELECT COUNT(DISTINCT client_id)
- FROM alert
- WHERE company_id = :company_id
- AND alert_deleted_at = 'infinity'
- AND alert_is_resolved = FALSE
- AND alert_created_at >= :start_datetime
- AND alert_created_at < :end_exclusive_datetime
- AND lower(alert_type) IN (" . implode(', ', $placeholders) . ')'
- );
- $stmt->execute($params);
- return (int) $stmt->fetchColumn();
- }
- private function getRecentInteractionsCount(int $companyId, array $filters, array $range): int
- {
- $sql = "SELECT COUNT(DISTINCT c.conversation_id)
- FROM conversation c
- LEFT JOIN conversation_analysis ca
- ON ca.conversation_id = c.conversation_id
- AND ca.conversation_analysis_deleted_at = 'infinity'
- WHERE c.company_id = :company_id
- AND c.conversation_deleted_at = 'infinity'
- AND c.conversation_last_message_at >= :start_datetime
- AND c.conversation_last_message_at < :end_exclusive_datetime";
- $params = [
- 'company_id' => $companyId,
- 'start_datetime' => $range['start_datetime'],
- 'end_exclusive_datetime' => $range['end_exclusive_datetime'],
- ];
- $this->appendConversationAnalysisFilters($sql, $params, 'ca', $filters);
- $stmt = $this->pdo->prepare($sql);
- $stmt->execute($params);
- return (int) $stmt->fetchColumn();
- }
- private function getNetTrend(int $companyId, array $filters, array $range): string
- {
- $sql = "SELECT
- COALESCE(SUM(CASE WHEN po.opinion_is_positive THEN 1 ELSE 0 END), 0) AS gains,
- COALESCE(SUM(CASE WHEN po.opinion_is_positive THEN 0 ELSE 1 END), 0) AS losses
- FROM public_opinion po
- INNER JOIN conversation c
- ON c.conversation_id = po.conversation_id
- AND c.conversation_deleted_at = 'infinity'
- LEFT JOIN conversation_analysis ca
- ON ca.conversation_id = po.conversation_id
- AND ca.conversation_analysis_deleted_at = 'infinity'
- WHERE po.company_id = :company_id
- AND po.opinion_deleted_at = 'infinity'
- AND po.opinion_classified_at >= :start_datetime
- AND po.opinion_classified_at < :end_exclusive_datetime";
- $params = [
- 'company_id' => $companyId,
- 'start_datetime' => $range['start_datetime'],
- 'end_exclusive_datetime' => $range['end_exclusive_datetime'],
- ];
- $this->appendConversationAnalysisFilters($sql, $params, 'ca', $filters);
- $stmt = $this->pdo->prepare($sql);
- $stmt->execute($params);
- $row = $stmt->fetch(\PDO::FETCH_ASSOC) ?: [];
- $gains = (int) ($row['gains'] ?? 0);
- $losses = (int) ($row['losses'] ?? 0);
- $total = $gains + $losses;
- if ($total === 0) {
- return '0%';
- }
- $percentage = (int) round((($gains - $losses) / $total) * 100);
- if ($percentage > 0) {
- return '+' . $percentage . '%';
- }
- return $percentage . '%';
- }
- private function getAlerts(int $companyId, array $filters, array $range): array
- {
- $sql = "SELECT
- a.alert_id,
- a.client_id,
- cl.client_name,
- a.alert_title,
- a.alert_description,
- a.alert_priority,
- a.alert_type,
- a.alert_created_at
- FROM alert a
- INNER JOIN client cl
- ON cl.client_id = a.client_id
- AND cl.client_deleted_at = 'infinity'
- WHERE a.company_id = :company_id
- AND a.alert_deleted_at = 'infinity'
- AND a.alert_is_resolved = FALSE
- AND a.alert_created_at >= :start_datetime
- AND a.alert_created_at < :end_exclusive_datetime";
- $params = [
- 'company_id' => $companyId,
- 'start_datetime' => $range['start_datetime'],
- 'end_exclusive_datetime' => $range['end_exclusive_datetime'],
- ];
- if ($filters['aspect'] !== null || $filters['sentiment'] !== 'all') {
- $sql .= "
- AND EXISTS (
- SELECT 1
- FROM conversation c
- INNER JOIN conversation_analysis ca
- ON ca.conversation_id = c.conversation_id
- AND ca.conversation_analysis_deleted_at = 'infinity'
- WHERE c.company_id = a.company_id
- AND c.client_id = a.client_id
- AND c.conversation_deleted_at = 'infinity'
- AND c.conversation_last_message_at >= :filter_start_datetime
- AND c.conversation_last_message_at < :filter_end_exclusive_datetime";
- $params['filter_start_datetime'] = $range['start_datetime'];
- $params['filter_end_exclusive_datetime'] = $range['end_exclusive_datetime'];
- $this->appendConversationAnalysisFilters($sql, $params, 'ca', $filters);
- $sql .= "
- )";
- }
- $sql .= "
- ORDER BY
- CASE lower(a.alert_priority)
- WHEN 'high' THEN 1
- WHEN 'medium' THEN 2
- ELSE 3
- END,
- a.alert_created_at DESC
- LIMIT 10";
- $stmt = $this->pdo->prepare($sql);
- $stmt->execute($params);
- $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
- return array_map(function (array $row): array {
- $priority = $this->normalizeAlertPriority((string) ($row['alert_priority'] ?? 'low'));
- return [
- 'id' => 'alert-' . (int) $row['alert_id'],
- 'clientId' => (int) ($row['client_id'] ?? 0),
- 'clientName' => $row['client_name'] ?? '',
- 'title' => $row['alert_title'] ?? '',
- 'description' => $row['alert_description'] ?? '',
- 'priority' => $priority,
- 'priorityLabel' => $this->getPriorityLabel($priority),
- 'category' => $this->normalizeAlertCategory((string) ($row['alert_type'] ?? '')),
- ];
- }, $rows);
- }
- private function getDayTimeline(int $companyId, array $filters): array
- {
- $today = new \DateTimeImmutable('today');
- $start = $today->modify('-6 days');
- $range = [
- 'start_datetime' => $start->format('Y-m-d 00:00:00'),
- 'end_exclusive_datetime' => $today->modify('+1 day')->format('Y-m-d 00:00:00'),
- ];
- $stats = $this->getOpinionBuckets(
- $companyId,
- $filters,
- $range,
- "TO_CHAR(DATE(po.opinion_classified_at), 'YYYY-MM-DD')"
- );
- $items = [];
- for ($i = 0; $i < 7; $i++) {
- $bucketDate = $start->modify('+' . $i . ' days')->format('Y-m-d');
- $items[] = [
- 'period' => 'Dia ' . ($i + 1),
- 'gains' => (int) ($stats[$bucketDate]['gains'] ?? 0),
- 'losses' => (int) ($stats[$bucketDate]['losses'] ?? 0),
- ];
- }
- return $items;
- }
- private function getWeekTimeline(int $companyId, array $filters): array
- {
- $currentWeek = new \DateTimeImmutable('monday this week');
- $start = $currentWeek->modify('-5 weeks');
- $range = [
- 'start_datetime' => $start->format('Y-m-d 00:00:00'),
- 'end_exclusive_datetime' => $currentWeek->modify('+1 week')->format('Y-m-d 00:00:00'),
- ];
- $stats = $this->getOpinionBuckets(
- $companyId,
- $filters,
- $range,
- "TO_CHAR(DATE_TRUNC('week', po.opinion_classified_at), 'YYYY-MM-DD')"
- );
- $items = [];
- for ($i = 0; $i < 6; $i++) {
- $bucketDate = $start->modify('+' . $i . ' weeks')->format('Y-m-d');
- $items[] = [
- 'period' => 'Sem ' . ($i + 1),
- 'gains' => (int) ($stats[$bucketDate]['gains'] ?? 0),
- 'losses' => (int) ($stats[$bucketDate]['losses'] ?? 0),
- ];
- }
- return $items;
- }
- private function getMonthTimeline(int $companyId, array $filters): array
- {
- $currentMonth = new \DateTimeImmutable('first day of this month');
- $start = $currentMonth->modify('-5 months');
- $range = [
- 'start_datetime' => $start->format('Y-m-d 00:00:00'),
- 'end_exclusive_datetime' => $currentMonth->modify('+1 month')->format('Y-m-d 00:00:00'),
- ];
- $stats = $this->getOpinionBuckets(
- $companyId,
- $filters,
- $range,
- "TO_CHAR(DATE_TRUNC('month', po.opinion_classified_at), 'YYYY-MM-DD')"
- );
- $items = [];
- for ($i = 0; $i < 6; $i++) {
- $bucketDate = $start->modify('+' . $i . ' months')->format('Y-m-d');
- $items[] = [
- 'period' => $this->formatMonthLabel($start->modify('+' . $i . ' months')),
- 'gains' => (int) ($stats[$bucketDate]['gains'] ?? 0),
- 'losses' => (int) ($stats[$bucketDate]['losses'] ?? 0),
- ];
- }
- return $items;
- }
- private function getOpinionBuckets(int $companyId, array $filters, array $range, string $bucketSql): array
- {
- $sql = "SELECT
- {$bucketSql} AS bucket_key,
- COALESCE(SUM(CASE WHEN po.opinion_is_positive THEN 1 ELSE 0 END), 0) AS gains,
- COALESCE(SUM(CASE WHEN po.opinion_is_positive THEN 0 ELSE 1 END), 0) AS losses
- FROM public_opinion po
- INNER JOIN conversation c
- ON c.conversation_id = po.conversation_id
- AND c.conversation_deleted_at = 'infinity'
- LEFT JOIN conversation_analysis ca
- ON ca.conversation_id = po.conversation_id
- AND ca.conversation_analysis_deleted_at = 'infinity'
- WHERE po.company_id = :company_id
- AND po.opinion_deleted_at = 'infinity'
- AND po.opinion_classified_at >= :start_datetime
- AND po.opinion_classified_at < :end_exclusive_datetime";
- $params = [
- 'company_id' => $companyId,
- 'start_datetime' => $range['start_datetime'],
- 'end_exclusive_datetime' => $range['end_exclusive_datetime'],
- ];
- $this->appendConversationAnalysisFilters($sql, $params, 'ca', $filters);
- $sql .= "
- GROUP BY bucket_key
- ORDER BY bucket_key ASC";
- $stmt = $this->pdo->prepare($sql);
- $stmt->execute($params);
- $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
- $mapped = [];
- foreach ($rows as $row) {
- $mapped[$row['bucket_key']] = [
- 'gains' => (int) ($row['gains'] ?? 0),
- 'losses' => (int) ($row['losses'] ?? 0),
- ];
- }
- return $mapped;
- }
- private function getAspects(int $companyId, array $filters, array $range): array
- {
- $sql = "SELECT
- af.aspect_feedback_aspect,
- af.aspect_feedback_sentiment,
- af.aspect_feedback_text,
- cl.client_name,
- cl.client_phone,
- af.aspect_feedback_created_at
- FROM aspect_feedback af
- INNER JOIN conversation c
- ON c.conversation_id = af.conversation_id
- AND c.conversation_deleted_at = 'infinity'
- INNER JOIN client cl
- ON cl.client_id = c.client_id
- AND cl.client_deleted_at = 'infinity'
- WHERE af.company_id = :company_id
- AND af.aspect_feedback_deleted_at = 'infinity'
- AND af.aspect_feedback_created_at >= :start_datetime
- AND af.aspect_feedback_created_at < :end_exclusive_datetime";
- $params = [
- 'company_id' => $companyId,
- 'start_datetime' => $range['start_datetime'],
- 'end_exclusive_datetime' => $range['end_exclusive_datetime'],
- ];
- if ($filters['aspect'] !== null) {
- $sql .= " AND lower(af.aspect_feedback_aspect) = :aspect";
- $params['aspect'] = $filters['aspect'];
- }
- if ($filters['sentiment'] !== 'all') {
- $sql .= ' AND ' . $this->getAspectSentimentWhereClause('af', $filters['sentiment']);
- }
- $sql .= "
- ORDER BY af.aspect_feedback_aspect ASC, af.aspect_feedback_created_at DESC";
- $stmt = $this->pdo->prepare($sql);
- $stmt->execute($params);
- $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
- $grouped = [];
- foreach ($rows as $row) {
- $name = (string) ($row['aspect_feedback_aspect'] ?? '');
- $id = $this->slugify($name);
- if (!isset($grouped[$id])) {
- $grouped[$id] = [
- 'id' => $id,
- 'name' => $name,
- 'volume' => 0,
- 'positive' => [],
- 'neutral' => [],
- 'negative' => [],
- ];
- }
- $sentiment = $this->normalizeAspectSentiment((string) ($row['aspect_feedback_sentiment'] ?? 'neutral'));
- $grouped[$id]['volume']++;
- $grouped[$id][$sentiment][] = [
- 'text' => $row['aspect_feedback_text'] ?? '',
- 'client' => $row['client_name'] !== '' ? $row['client_name'] : ($row['client_phone'] ?? ''),
- ];
- }
- usort($grouped, static function (array $left, array $right): int {
- return $right['volume'] <=> $left['volume'];
- });
- return array_values($grouped);
- }
- private function appendConversationAnalysisFilters(string &$sql, array &$params, string $alias, array $filters): void
- {
- if ($filters['aspect'] !== null) {
- $sql .= " AND lower(COALESCE({$alias}.conversation_analysis_aspect, '')) = :aspect";
- $params['aspect'] = $filters['aspect'];
- }
- if ($filters['sentiment'] !== 'all') {
- $sql .= ' AND ' . $this->getConversationSentimentWhereClause($alias, $filters['sentiment']);
- }
- }
- private function getConversationSentimentWhereClause(string $alias, string $sentiment): string
- {
- if ($sentiment === 'positive') {
- return "(
- lower(COALESCE({$alias}.conversation_analysis_sentiment, '')) IN ('positive', 'positivo')
- OR {$alias}.conversation_analysis_sentiment_score >= 0.15
- )";
- }
- if ($sentiment === 'negative') {
- return "(
- lower(COALESCE({$alias}.conversation_analysis_sentiment, '')) IN ('negative', 'negativo')
- OR {$alias}.conversation_analysis_sentiment_score <= -0.15
- )";
- }
- return "(
- {$alias}.conversation_id IS NOT NULL
- AND lower(COALESCE({$alias}.conversation_analysis_sentiment, '')) NOT IN ('positive', 'positivo', 'negative', 'negativo')
- AND {$alias}.conversation_analysis_sentiment_score > -0.15
- AND {$alias}.conversation_analysis_sentiment_score < 0.15
- )";
- }
- private function getAspectSentimentWhereClause(string $alias, string $sentiment): string
- {
- if ($sentiment === 'positive') {
- return "lower({$alias}.aspect_feedback_sentiment) IN ('positive', 'positivo')";
- }
- if ($sentiment === 'negative') {
- return "lower({$alias}.aspect_feedback_sentiment) IN ('negative', 'negativo')";
- }
- return "lower({$alias}.aspect_feedback_sentiment) NOT IN ('positive', 'positivo', 'negative', 'negativo')";
- }
- private function normalizeAspectSentiment(string $sentiment): string
- {
- $normalized = mb_strtolower(trim($sentiment));
- if (in_array($normalized, ['positive', 'positivo'], true)) {
- return 'positive';
- }
- if (in_array($normalized, ['negative', 'negativo'], true)) {
- return 'negative';
- }
- return 'neutral';
- }
- private function normalizeAlertPriority(string $priority): string
- {
- $normalized = mb_strtolower(trim($priority));
- if (in_array($normalized, ['high', 'alta'], true)) {
- return 'high';
- }
- if (in_array($normalized, ['medium', 'media', 'média'], true)) {
- return 'medium';
- }
- return 'low';
- }
- private function getPriorityLabel(string $priority): string
- {
- if ($priority === 'high') {
- return 'Alta prioridade';
- }
- if ($priority === 'medium') {
- return 'Media prioridade';
- }
- return 'Baixa prioridade';
- }
- private function normalizeAlertCategory(string $type): string
- {
- $normalized = mb_strtolower(trim($type));
- if (in_array($normalized, ['buying_intent', 'opportunity', 'upsell', 'cross_sell'], true)) {
- return 'buying_intent';
- }
- if (in_array($normalized, ['frustration', 'frustracao', 'frustração'], true)) {
- return 'frustration';
- }
- return 'churn_risk';
- }
- private function formatMonthLabel(\DateTimeImmutable $date): string
- {
- $labels = [
- '01' => 'Jan',
- '02' => 'Fev',
- '03' => 'Mar',
- '04' => 'Abr',
- '05' => 'Mai',
- '06' => 'Jun',
- '07' => 'Jul',
- '08' => 'Ago',
- '09' => 'Set',
- '10' => 'Out',
- '11' => 'Nov',
- '12' => 'Dez',
- ];
- return $labels[$date->format('m')] ?? $date->format('m');
- }
- private function slugify(string $value): string
- {
- $normalized = trim($value);
- if ($normalized === '') {
- return 'aspect';
- }
- if (function_exists('iconv')) {
- $converted = iconv('UTF-8', 'ASCII//TRANSLIT', $normalized);
- if (is_string($converted) && $converted !== '') {
- $normalized = $converted;
- }
- }
- $normalized = mb_strtolower($normalized);
- $normalized = preg_replace('/[^a-z0-9]+/', '-', $normalized) ?? 'aspect';
- $normalized = trim($normalized, '-');
- return $normalized !== '' ? $normalized : 'aspect';
- }
- }
|