DashboardOverviewModel.php 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531
  1. <?php
  2. namespace Models;
  3. use Libs\Database;
  4. class DashboardOverviewModel
  5. {
  6. private \PDO $pdo;
  7. public function __construct()
  8. {
  9. $this->pdo = Database::pdo();
  10. }
  11. public function getOverviewData(int $companyId, array $queryParams): array
  12. {
  13. $filters = $this->normalizeFilters($queryParams);
  14. $range = $this->resolveDateRange($filters['period']);
  15. return [
  16. 'kpis' => $this->getKpis($companyId, $filters, $range),
  17. 'priorityQueue' => $this->getPriorityQueue($companyId, $filters, $range),
  18. 'radarData' => $this->getRadarData($companyId, $range),
  19. 'volumeData' => $this->getVolumeData($companyId, $range),
  20. 'aspectsData' => $this->getAspectsData($companyId, $filters, $range),
  21. 'aspectsDrilldown' => $this->getAspectsDrilldown($companyId, $filters, $range),
  22. ];
  23. }
  24. private function normalizeFilters(array $queryParams): array
  25. {
  26. $period = strtolower((string) ($queryParams['period'] ?? 'week'));
  27. $unit = strtolower((string) ($queryParams['unit'] ?? 'all'));
  28. $area = strtolower((string) ($queryParams['area'] ?? 'all'));
  29. $sentiment = strtolower((string) ($queryParams['sentiment'] ?? 'all'));
  30. $volumeView = strtolower((string) ($queryParams['volume_view'] ?? 'day'));
  31. if (!in_array($period, ['today', 'yesterday', 'week'], true)) {
  32. $period = 'week';
  33. }
  34. if (!in_array($sentiment, ['all', 'positive', 'neutral', 'negative'], true)) {
  35. $sentiment = 'all';
  36. }
  37. if (!in_array($volumeView, ['hour', 'day'], true)) {
  38. $volumeView = 'day';
  39. }
  40. return [
  41. 'period' => $period,
  42. 'unit' => $unit,
  43. 'area' => $area,
  44. 'sentiment' => $sentiment,
  45. 'volume_view' => $volumeView,
  46. ];
  47. }
  48. private function resolveDateRange(string $period): array
  49. {
  50. $today = new \DateTimeImmutable('today');
  51. if ($period === 'today') {
  52. $start = $today;
  53. $end = $today;
  54. } elseif ($period === 'yesterday') {
  55. $start = $today->modify('-1 day');
  56. $end = $start;
  57. } else {
  58. $start = $today->modify('-6 days');
  59. $end = $today;
  60. }
  61. return [
  62. 'start_date' => $start->format('Y-m-d'),
  63. 'end_date' => $end->format('Y-m-d'),
  64. 'start_datetime' => $start->format('Y-m-d 00:00:00'),
  65. 'end_exclusive_datetime' => $end->modify('+1 day')->format('Y-m-d 00:00:00'),
  66. ];
  67. }
  68. private function getKpis(int $companyId, array $filters, array $range): array
  69. {
  70. $registeredStmt = $this->pdo->prepare(
  71. "SELECT COUNT(*)
  72. FROM client
  73. WHERE company_id = :company_id
  74. AND client_deleted_at = 'infinity'
  75. AND client_is_registered = TRUE"
  76. );
  77. $registeredStmt->execute(['company_id' => $companyId]);
  78. $unregisteredStmt = $this->pdo->prepare(
  79. "SELECT COUNT(*)
  80. FROM client
  81. WHERE company_id = :company_id
  82. AND client_deleted_at = 'infinity'
  83. AND client_is_registered = FALSE"
  84. );
  85. $unregisteredStmt->execute(['company_id' => $companyId]);
  86. $activeOperatorsSql = "SELECT COUNT(*)
  87. FROM operator
  88. WHERE company_id = :company_id
  89. AND operator_deleted_at = 'infinity'
  90. AND lower(operator_status) <> 'inativo'";
  91. $activeOperatorsParams = ['company_id' => $companyId];
  92. if ($filters['area'] !== 'all') {
  93. $activeOperatorsSql .= " AND lower(operator_department) = :area";
  94. $activeOperatorsParams['area'] = $filters['area'];
  95. }
  96. $activeOperatorsStmt = $this->pdo->prepare($activeOperatorsSql);
  97. $activeOperatorsStmt->execute($activeOperatorsParams);
  98. $conversationSql = "SELECT
  99. COUNT(DISTINCT c.conversation_id) AS total_conversations,
  100. COALESCE(AVG(ca.conversation_analysis_sentiment_score), 0) AS general_sentiment_score
  101. FROM conversation c
  102. INNER JOIN operator o ON o.operator_id = c.operator_id
  103. LEFT JOIN conversation_analysis ca
  104. ON ca.conversation_id = c.conversation_id
  105. AND ca.conversation_analysis_deleted_at = 'infinity'
  106. WHERE c.company_id = :company_id
  107. AND c.conversation_deleted_at = 'infinity'
  108. AND c.conversation_started_at >= :start_datetime
  109. AND c.conversation_started_at < :end_exclusive_datetime
  110. AND o.operator_deleted_at = 'infinity'";
  111. $conversationParams = [
  112. 'company_id' => $companyId,
  113. 'start_datetime' => $range['start_datetime'],
  114. 'end_exclusive_datetime' => $range['end_exclusive_datetime'],
  115. ];
  116. if ($filters['area'] !== 'all') {
  117. $conversationSql .= " AND lower(o.operator_department) = :area";
  118. $conversationParams['area'] = $filters['area'];
  119. }
  120. if ($filters['sentiment'] !== 'all') {
  121. $conversationSql .= ' AND ' . $this->getSentimentWhereClause('ca', $filters['sentiment']);
  122. }
  123. $conversationStmt = $this->pdo->prepare($conversationSql);
  124. $conversationStmt->execute($conversationParams);
  125. $conversationMetrics = $conversationStmt->fetch(\PDO::FETCH_ASSOC) ?: [];
  126. return [
  127. 'registeredUsers' => (int) $registeredStmt->fetchColumn(),
  128. 'activeAgents' => (int) $activeOperatorsStmt->fetchColumn(),
  129. 'totalConversations' => (int) ($conversationMetrics['total_conversations'] ?? 0),
  130. 'generalSentimentScore' => round((float) ($conversationMetrics['general_sentiment_score'] ?? 0), 2),
  131. 'unregisteredUsers' => (int) $unregisteredStmt->fetchColumn(),
  132. ];
  133. }
  134. private function getPriorityQueue(int $companyId, array $filters, array $range): array
  135. {
  136. $sql = "SELECT
  137. c.conversation_id AS id,
  138. cl.client_name AS customer_name,
  139. cl.client_segment AS segment,
  140. COALESCE(NULLIF(a.alert_title, ''), ca.conversation_analysis_sentiment, c.conversation_status) AS status_label,
  141. c.conversation_sla_deadline,
  142. c.conversation_last_message_at,
  143. o.operator_name AS seller_name,
  144. c.conversation_last_message_preview AS last_message,
  145. COALESCE(NULLIF(a.alert_description, ''), CONCAT(ca.conversation_analysis_aspect, ' — ', ca.conversation_analysis_sub_aspect), c.conversation_last_message_preview) AS motive,
  146. c.conversation_impact_value,
  147. c.conversation_ticket_value,
  148. c.conversation_conversion_chance,
  149. c.conversation_optimum_window,
  150. c.client_id,
  151. c.operator_id
  152. FROM conversation c
  153. INNER JOIN client cl ON cl.client_id = c.client_id AND cl.client_deleted_at = 'infinity'
  154. INNER JOIN operator o ON o.operator_id = c.operator_id AND o.operator_deleted_at = 'infinity'
  155. LEFT JOIN conversation_analysis ca
  156. ON ca.conversation_id = c.conversation_id
  157. AND ca.conversation_analysis_deleted_at = 'infinity'
  158. LEFT JOIN LATERAL (
  159. SELECT alert_title, alert_description
  160. FROM alert
  161. WHERE company_id = c.company_id
  162. AND client_id = c.client_id
  163. AND alert_deleted_at = 'infinity'
  164. AND alert_is_resolved = FALSE
  165. ORDER BY alert_created_at DESC
  166. LIMIT 1
  167. ) a ON TRUE
  168. WHERE c.company_id = :company_id
  169. AND c.conversation_deleted_at = 'infinity'
  170. AND c.conversation_started_at >= :start_datetime
  171. AND c.conversation_started_at < :end_exclusive_datetime";
  172. $params = [
  173. 'company_id' => $companyId,
  174. 'start_datetime' => $range['start_datetime'],
  175. 'end_exclusive_datetime' => $range['end_exclusive_datetime'],
  176. ];
  177. if ($filters['area'] !== 'all') {
  178. $sql .= " AND lower(o.operator_department) = :area";
  179. $params['area'] = $filters['area'];
  180. }
  181. if ($filters['sentiment'] !== 'all') {
  182. $sql .= ' AND ' . $this->getSentimentWhereClause('ca', $filters['sentiment']);
  183. }
  184. $sql .= " ORDER BY (c.conversation_sla_deadline < NOW()) DESC,
  185. ROUND(c.conversation_impact_value * (c.conversation_conversion_chance / 100.0)) DESC,
  186. c.conversation_last_message_at ASC
  187. LIMIT 10";
  188. $stmt = $this->pdo->prepare($sql);
  189. $stmt->execute($params);
  190. $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
  191. return array_map(function (array $row): array {
  192. $impact = (float) ($row['conversation_impact_value'] ?? 0);
  193. $chance = (int) ($row['conversation_conversion_chance'] ?? 0);
  194. return [
  195. 'id' => (int) $row['id'],
  196. 'customerName' => $row['customer_name'] ?? '',
  197. 'segment' => $row['segment'] ?? '',
  198. 'status' => $this->normalizeStatusLabel((string) ($row['status_label'] ?? 'open')),
  199. 'slaStatus' => $this->formatSlaStatus($row['conversation_sla_deadline'] ?? null),
  200. 'timeAgo' => $this->formatRelativeTime($row['conversation_last_message_at'] ?? null),
  201. 'sellerName' => $row['seller_name'] ?? '',
  202. 'lastMessage' => $row['last_message'] ?? '',
  203. 'motive' => $row['motive'] ?? '',
  204. 'impact' => (int) round($impact),
  205. 'ticket' => (int) round((float) ($row['conversation_ticket_value'] ?? 0)),
  206. 'chance' => $chance,
  207. 'optimumWindow' => $row['conversation_optimum_window'] ?? '',
  208. 'score' => (int) round($impact * ($chance / 100)),
  209. 'conversationId' => (int) $row['id'],
  210. 'clientId' => (int) ($row['client_id'] ?? 0),
  211. 'operatorId' => (int) ($row['operator_id'] ?? 0),
  212. ];
  213. }, $rows);
  214. }
  215. private function getRadarData(int $companyId, array $range): array
  216. {
  217. $stmt = $this->pdo->prepare(
  218. "SELECT
  219. emotion_confidence,
  220. emotion_happiness,
  221. emotion_anticipation,
  222. emotion_fear,
  223. emotion_sadness,
  224. emotion_anger
  225. FROM emotion_snapshot
  226. WHERE company_id = :company_id
  227. AND emotion_snapshot_date BETWEEN :start_date AND :end_date
  228. ORDER BY emotion_snapshot_date DESC
  229. LIMIT 1"
  230. );
  231. $stmt->execute([
  232. 'company_id' => $companyId,
  233. 'start_date' => $range['start_date'],
  234. 'end_date' => $range['end_date'],
  235. ]);
  236. $row = $stmt->fetch(\PDO::FETCH_ASSOC) ?: [];
  237. return [
  238. ['name' => 'Confiança', 'value' => (float) ($row['emotion_confidence'] ?? 0)],
  239. ['name' => 'Alegria', 'value' => (float) ($row['emotion_happiness'] ?? 0)],
  240. ['name' => 'Antecipação', 'value' => (float) ($row['emotion_anticipation'] ?? 0)],
  241. ['name' => 'Medo', 'value' => (float) ($row['emotion_fear'] ?? 0)],
  242. ['name' => 'Tristeza', 'value' => (float) ($row['emotion_sadness'] ?? 0)],
  243. ['name' => 'Raiva', 'value' => (float) ($row['emotion_anger'] ?? 0)],
  244. ];
  245. }
  246. private function getVolumeData(int $companyId, array $range): array
  247. {
  248. $stmt = $this->pdo->prepare(
  249. "SELECT
  250. volume_snapshot_date,
  251. lower(volume_channel) AS volume_channel,
  252. SUM(volume_message_count) AS message_count
  253. FROM volume_snapshot
  254. WHERE company_id = :company_id
  255. AND volume_snapshot_date BETWEEN :start_date AND :end_date
  256. GROUP BY volume_snapshot_date, lower(volume_channel)
  257. ORDER BY volume_snapshot_date ASC, lower(volume_channel) ASC"
  258. );
  259. $stmt->execute([
  260. 'company_id' => $companyId,
  261. 'start_date' => $range['start_date'],
  262. 'end_date' => $range['end_date'],
  263. ]);
  264. $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
  265. $grouped = [];
  266. foreach ($rows as $row) {
  267. $date = $this->formatSnapshotDate((string) $row['volume_snapshot_date']);
  268. if (!isset($grouped[$date])) {
  269. $grouped[$date] = ['date' => $date];
  270. }
  271. $grouped[$date][$row['volume_channel']] = (int) ($row['message_count'] ?? 0);
  272. }
  273. return array_values($grouped);
  274. }
  275. private function getAspectsData(int $companyId, array $filters, array $range): array
  276. {
  277. $sql = "SELECT
  278. aspect_feedback_aspect AS aspect,
  279. SUM(CASE WHEN " . $this->getAspectSentimentCase('positive') . " THEN 1 ELSE 0 END) AS positive_count,
  280. SUM(CASE WHEN " . $this->getAspectSentimentCase('neutral') . " THEN 1 ELSE 0 END) AS neutral_count,
  281. SUM(CASE WHEN " . $this->getAspectSentimentCase('negative') . " THEN 1 ELSE 0 END) AS negative_count
  282. FROM aspect_feedback
  283. WHERE company_id = :company_id
  284. AND aspect_feedback_deleted_at = 'infinity'
  285. AND aspect_feedback_created_at >= :start_datetime
  286. AND aspect_feedback_created_at < :end_exclusive_datetime";
  287. $params = [
  288. 'company_id' => $companyId,
  289. 'start_datetime' => $range['start_datetime'],
  290. 'end_exclusive_datetime' => $range['end_exclusive_datetime'],
  291. ];
  292. if ($filters['sentiment'] !== 'all') {
  293. $sql .= ' AND ' . $this->getAspectFilterClause($filters['sentiment']);
  294. }
  295. $sql .= ' GROUP BY aspect_feedback_aspect ORDER BY aspect_feedback_aspect ASC';
  296. $stmt = $this->pdo->prepare($sql);
  297. $stmt->execute($params);
  298. $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
  299. return array_map(static function (array $row): array {
  300. return [
  301. 'aspect' => $row['aspect'],
  302. 'positive' => (int) ($row['positive_count'] ?? 0),
  303. 'neutral' => (int) ($row['neutral_count'] ?? 0),
  304. 'negative' => (int) ($row['negative_count'] ?? 0),
  305. ];
  306. }, $rows);
  307. }
  308. private function getAspectsDrilldown(int $companyId, array $filters, array $range): array
  309. {
  310. $sql = "SELECT
  311. aspect_feedback_aspect AS aspect,
  312. aspect_feedback_sentiment AS sentiment,
  313. aspect_feedback_text AS label,
  314. COUNT(*) AS total
  315. FROM aspect_feedback
  316. WHERE company_id = :company_id
  317. AND aspect_feedback_deleted_at = 'infinity'
  318. AND aspect_feedback_created_at >= :start_datetime
  319. AND aspect_feedback_created_at < :end_exclusive_datetime";
  320. $params = [
  321. 'company_id' => $companyId,
  322. 'start_datetime' => $range['start_datetime'],
  323. 'end_exclusive_datetime' => $range['end_exclusive_datetime'],
  324. ];
  325. if ($filters['sentiment'] !== 'all') {
  326. $sql .= ' AND ' . $this->getAspectFilterClause($filters['sentiment']);
  327. }
  328. $sql .= ' GROUP BY aspect_feedback_aspect, aspect_feedback_sentiment, aspect_feedback_text
  329. ORDER BY aspect_feedback_aspect ASC, COUNT(*) DESC, aspect_feedback_text ASC';
  330. $stmt = $this->pdo->prepare($sql);
  331. $stmt->execute($params);
  332. $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
  333. $drilldown = [];
  334. foreach ($rows as $row) {
  335. $aspect = $row['aspect'];
  336. $sentiment = $this->normalizeAspectSentiment((string) ($row['sentiment'] ?? 'neutral'));
  337. if (!isset($drilldown[$aspect])) {
  338. $drilldown[$aspect] = [
  339. 'positive' => [],
  340. 'neutral' => [],
  341. 'negative' => [],
  342. ];
  343. }
  344. if (count($drilldown[$aspect][$sentiment]) >= 5) {
  345. continue;
  346. }
  347. $drilldown[$aspect][$sentiment][] = [
  348. 'label' => $row['label'],
  349. 'value' => (int) ($row['total'] ?? 0),
  350. ];
  351. }
  352. return $drilldown;
  353. }
  354. private function getSentimentWhereClause(string $analysisAlias, string $sentiment): string
  355. {
  356. if ($sentiment === 'positive') {
  357. return "(
  358. lower(COALESCE({$analysisAlias}.conversation_analysis_sentiment, '')) IN ('positive', 'positivo')
  359. OR {$analysisAlias}.conversation_analysis_sentiment_score >= 0.15
  360. )";
  361. }
  362. if ($sentiment === 'negative') {
  363. return "(
  364. lower(COALESCE({$analysisAlias}.conversation_analysis_sentiment, '')) IN ('negative', 'negativo')
  365. OR {$analysisAlias}.conversation_analysis_sentiment_score <= -0.15
  366. )";
  367. }
  368. return "(
  369. lower(COALESCE({$analysisAlias}.conversation_analysis_sentiment, '')) NOT IN ('positive', 'positivo', 'negative', 'negativo')
  370. AND {$analysisAlias}.conversation_analysis_sentiment_score > -0.15
  371. AND {$analysisAlias}.conversation_analysis_sentiment_score < 0.15
  372. )";
  373. }
  374. private function getAspectSentimentCase(string $sentiment): string
  375. {
  376. if ($sentiment === 'positive') {
  377. return "lower(aspect_feedback_sentiment) IN ('positive', 'positivo')";
  378. }
  379. if ($sentiment === 'negative') {
  380. return "lower(aspect_feedback_sentiment) IN ('negative', 'negativo')";
  381. }
  382. return "lower(aspect_feedback_sentiment) NOT IN ('positive', 'positivo', 'negative', 'negativo')";
  383. }
  384. private function getAspectFilterClause(string $sentiment): string
  385. {
  386. return $this->getAspectSentimentCase($sentiment);
  387. }
  388. private function normalizeAspectSentiment(string $sentiment): string
  389. {
  390. $normalized = strtolower(trim($sentiment));
  391. if (in_array($normalized, ['positive', 'positivo'], true)) {
  392. return 'positive';
  393. }
  394. if (in_array($normalized, ['negative', 'negativo'], true)) {
  395. return 'negative';
  396. }
  397. return 'neutral';
  398. }
  399. private function normalizeStatusLabel(string $status): string
  400. {
  401. $normalized = trim($status);
  402. if ($normalized === '') {
  403. return 'SEM STATUS';
  404. }
  405. return mb_strtoupper(str_replace('_', ' ', $normalized));
  406. }
  407. private function formatSlaStatus(?string $deadline): string
  408. {
  409. if (!$deadline) {
  410. return 'Sem SLA';
  411. }
  412. $deadlineTime = strtotime($deadline);
  413. if ($deadlineTime === false) {
  414. return 'Sem SLA';
  415. }
  416. $delta = $deadlineTime - time();
  417. if ($delta >= 0) {
  418. return 'Dentro do SLA';
  419. }
  420. $overdue = abs($delta);
  421. if ($overdue >= 86400) {
  422. return 'SLA ' . floor($overdue / 86400) . 'd estourado';
  423. }
  424. if ($overdue >= 3600) {
  425. return 'SLA ' . floor($overdue / 3600) . 'h estourado';
  426. }
  427. return 'SLA ' . max(1, floor($overdue / 60)) . 'm estourado';
  428. }
  429. private function formatRelativeTime(?string $dateTime): string
  430. {
  431. if (!$dateTime) {
  432. return 'agora';
  433. }
  434. $timestamp = strtotime($dateTime);
  435. if ($timestamp === false) {
  436. return 'agora';
  437. }
  438. $delta = max(0, time() - $timestamp);
  439. if ($delta >= 86400) {
  440. return 'há ' . floor($delta / 86400) . 'd';
  441. }
  442. if ($delta >= 3600) {
  443. return 'há ' . floor($delta / 3600) . 'h';
  444. }
  445. if ($delta >= 60) {
  446. return 'há ' . floor($delta / 60) . 'm';
  447. }
  448. return 'agora';
  449. }
  450. private function formatSnapshotDate(string $date): string
  451. {
  452. return $date . 'T00:00:00Z';
  453. }
  454. }