AnalyticsSentimentDashboardModel.php 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676
  1. <?php
  2. namespace Models;
  3. use Libs\Database;
  4. class AnalyticsSentimentDashboardModel
  5. {
  6. private \PDO $pdo;
  7. public function __construct()
  8. {
  9. $this->pdo = Database::pdo();
  10. }
  11. public function getDashboardData(int $companyId, array $queryParams): array
  12. {
  13. $filters = $this->normalizeFilters($queryParams);
  14. $summaryRange = $this->resolveRange($filters);
  15. return [
  16. 'summaryCards' => $this->getSummaryCards($companyId, $filters, $summaryRange),
  17. 'alerts' => $this->getAlerts($companyId, $filters, $summaryRange),
  18. 'timelineViews' => [
  19. 'day' => $this->getDayTimeline($companyId, $filters),
  20. 'week' => $this->getWeekTimeline($companyId, $filters),
  21. 'month' => $this->getMonthTimeline($companyId, $filters),
  22. ],
  23. 'aspects' => $this->getAspects($companyId, $filters, $summaryRange),
  24. ];
  25. }
  26. private function normalizeFilters(array $queryParams): array
  27. {
  28. $timeframe = strtolower(trim((string) ($queryParams['timeframe'] ?? 'week')));
  29. if (!in_array($timeframe, ['day', 'week', 'month', 'custom'], true)) {
  30. $timeframe = 'week';
  31. }
  32. // Intervalo personalizado: aceita start_date/end_date no formato YYYY-MM-DD.
  33. // Se as datas forem válidas, o timeframe é tratado como "custom" mesmo que
  34. // não tenha sido enviado explicitamente.
  35. $customStart = $this->parseDate((string) ($queryParams['start_date'] ?? ''));
  36. $customEnd = $this->parseDate((string) ($queryParams['end_date'] ?? ''));
  37. if ($customStart !== null && $customEnd !== null) {
  38. $timeframe = 'custom';
  39. if ($customEnd < $customStart) {
  40. [$customStart, $customEnd] = [$customEnd, $customStart];
  41. }
  42. } elseif ($timeframe === 'custom') {
  43. // "custom" sem datas válidas cai para o padrão semanal.
  44. $timeframe = 'week';
  45. }
  46. $sentiment = strtolower(trim((string) ($queryParams['sentiment'] ?? 'all')));
  47. if (!in_array($sentiment, ['all', 'positive', 'neutral', 'negative'], true)) {
  48. $sentiment = 'all';
  49. }
  50. $aspect = trim((string) ($queryParams['aspect'] ?? ''));
  51. if ($aspect === '' || strtolower($aspect) === 'all') {
  52. $aspect = null;
  53. }
  54. return [
  55. 'timeframe' => $timeframe,
  56. 'sentiment' => $sentiment,
  57. 'aspect' => $aspect !== null ? mb_strtolower($aspect) : null,
  58. 'custom_start' => $customStart,
  59. 'custom_end' => $customEnd,
  60. ];
  61. }
  62. private function parseDate(string $value): ?\DateTimeImmutable
  63. {
  64. $value = trim($value);
  65. if ($value === '') {
  66. return null;
  67. }
  68. $date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value);
  69. $errors = \DateTimeImmutable::getLastErrors();
  70. if ($date === false || ($errors !== false && ($errors['warning_count'] > 0 || $errors['error_count'] > 0))) {
  71. return null;
  72. }
  73. return $date;
  74. }
  75. private function resolveRange(array $filters): array
  76. {
  77. $today = new \DateTimeImmutable('today');
  78. $timeframe = $filters['timeframe'];
  79. if ($timeframe === 'custom' && $filters['custom_start'] !== null && $filters['custom_end'] !== null) {
  80. $start = $filters['custom_start'];
  81. $end = $filters['custom_end'];
  82. } else {
  83. if ($timeframe === 'day') {
  84. $start = $today;
  85. } elseif ($timeframe === 'month') {
  86. $start = $today->modify('-29 days');
  87. } else {
  88. $start = $today->modify('-6 days');
  89. }
  90. $end = $today;
  91. }
  92. return [
  93. 'start_datetime' => $start->format('Y-m-d 00:00:00'),
  94. 'end_exclusive_datetime' => $end->modify('+1 day')->format('Y-m-d 00:00:00'),
  95. ];
  96. }
  97. private function getSummaryCards(int $companyId, array $filters, array $range): array
  98. {
  99. $atRiskClients = $this->getAlertClientCountByTypes(
  100. $companyId,
  101. ['churn_risk', 'frustration', 'risk', 'at_risk'],
  102. $range
  103. );
  104. $opportunities = $this->getAlertClientCountByTypes(
  105. $companyId,
  106. ['buying_intent', 'opportunity', 'upsell', 'cross_sell'],
  107. $range
  108. );
  109. $recentInteractions = $this->getRecentInteractionsCount($companyId, $filters, $range);
  110. $netTrend = $this->getNetTrend($companyId, $filters, $range);
  111. return [
  112. [
  113. 'id' => 'atRiskClients',
  114. 'label' => 'Clientes em risco',
  115. 'value' => $atRiskClients,
  116. 'image' => '/images/sentiment/risk.svg',
  117. ],
  118. [
  119. 'id' => 'opportunities',
  120. 'label' => 'Oportunidades',
  121. 'value' => $opportunities,
  122. 'image' => '/images/sentiment/opportunity.svg',
  123. ],
  124. [
  125. 'id' => 'recentInteractions',
  126. 'label' => 'Interacoes recentes',
  127. 'value' => $recentInteractions,
  128. 'image' => '/images/sentiment/interactions.svg',
  129. ],
  130. [
  131. 'id' => 'netTrend',
  132. 'label' => 'Tendencia liquida',
  133. 'value' => $netTrend,
  134. 'image' => '/images/sentiment/trend.svg',
  135. ],
  136. ];
  137. }
  138. private function getAlertClientCountByTypes(int $companyId, array $types, array $range): int
  139. {
  140. $placeholders = [];
  141. $params = [
  142. 'company_id' => $companyId,
  143. 'start_datetime' => $range['start_datetime'],
  144. 'end_exclusive_datetime' => $range['end_exclusive_datetime'],
  145. ];
  146. foreach ($types as $index => $type) {
  147. $key = 'type_' . $index;
  148. $placeholders[] = ':' . $key;
  149. $params[$key] = $type;
  150. }
  151. $stmt = $this->pdo->prepare(
  152. "SELECT COUNT(DISTINCT client_id)
  153. FROM alert
  154. WHERE company_id = :company_id
  155. AND alert_deleted_at = 'infinity'
  156. AND alert_is_resolved = FALSE
  157. AND alert_created_at >= :start_datetime
  158. AND alert_created_at < :end_exclusive_datetime
  159. AND lower(alert_type) IN (" . implode(', ', $placeholders) . ')'
  160. );
  161. $stmt->execute($params);
  162. return (int) $stmt->fetchColumn();
  163. }
  164. private function getRecentInteractionsCount(int $companyId, array $filters, array $range): int
  165. {
  166. $sql = "SELECT COUNT(DISTINCT c.conversation_id)
  167. FROM conversation c
  168. LEFT JOIN conversation_analysis ca
  169. ON ca.conversation_id = c.conversation_id
  170. AND ca.conversation_analysis_deleted_at = 'infinity'
  171. WHERE c.company_id = :company_id
  172. AND c.conversation_deleted_at = 'infinity'
  173. AND c.conversation_last_message_at >= :start_datetime
  174. AND c.conversation_last_message_at < :end_exclusive_datetime";
  175. $params = [
  176. 'company_id' => $companyId,
  177. 'start_datetime' => $range['start_datetime'],
  178. 'end_exclusive_datetime' => $range['end_exclusive_datetime'],
  179. ];
  180. $this->appendConversationAnalysisFilters($sql, $params, 'ca', $filters);
  181. $stmt = $this->pdo->prepare($sql);
  182. $stmt->execute($params);
  183. return (int) $stmt->fetchColumn();
  184. }
  185. private function getNetTrend(int $companyId, array $filters, array $range): string
  186. {
  187. $sql = "SELECT
  188. COALESCE(SUM(CASE WHEN po.opinion_is_positive THEN 1 ELSE 0 END), 0) AS gains,
  189. COALESCE(SUM(CASE WHEN po.opinion_is_positive THEN 0 ELSE 1 END), 0) AS losses
  190. FROM public_opinion po
  191. INNER JOIN conversation c
  192. ON c.conversation_id = po.conversation_id
  193. AND c.conversation_deleted_at = 'infinity'
  194. LEFT JOIN conversation_analysis ca
  195. ON ca.conversation_id = po.conversation_id
  196. AND ca.conversation_analysis_deleted_at = 'infinity'
  197. WHERE po.company_id = :company_id
  198. AND po.opinion_deleted_at = 'infinity'
  199. AND po.opinion_classified_at >= :start_datetime
  200. AND po.opinion_classified_at < :end_exclusive_datetime";
  201. $params = [
  202. 'company_id' => $companyId,
  203. 'start_datetime' => $range['start_datetime'],
  204. 'end_exclusive_datetime' => $range['end_exclusive_datetime'],
  205. ];
  206. $this->appendConversationAnalysisFilters($sql, $params, 'ca', $filters);
  207. $stmt = $this->pdo->prepare($sql);
  208. $stmt->execute($params);
  209. $row = $stmt->fetch(\PDO::FETCH_ASSOC) ?: [];
  210. $gains = (int) ($row['gains'] ?? 0);
  211. $losses = (int) ($row['losses'] ?? 0);
  212. $total = $gains + $losses;
  213. if ($total === 0) {
  214. return '0%';
  215. }
  216. $percentage = (int) round((($gains - $losses) / $total) * 100);
  217. if ($percentage > 0) {
  218. return '+' . $percentage . '%';
  219. }
  220. return $percentage . '%';
  221. }
  222. private function getAlerts(int $companyId, array $filters, array $range): array
  223. {
  224. $sql = "SELECT
  225. a.alert_id,
  226. a.client_id,
  227. cl.client_name,
  228. a.alert_title,
  229. a.alert_description,
  230. a.alert_priority,
  231. a.alert_type,
  232. a.alert_created_at
  233. FROM alert a
  234. INNER JOIN client cl
  235. ON cl.client_id = a.client_id
  236. AND cl.client_deleted_at = 'infinity'
  237. WHERE a.company_id = :company_id
  238. AND a.alert_deleted_at = 'infinity'
  239. AND a.alert_is_resolved = FALSE
  240. AND a.alert_created_at >= :start_datetime
  241. AND a.alert_created_at < :end_exclusive_datetime";
  242. $params = [
  243. 'company_id' => $companyId,
  244. 'start_datetime' => $range['start_datetime'],
  245. 'end_exclusive_datetime' => $range['end_exclusive_datetime'],
  246. ];
  247. if ($filters['aspect'] !== null || $filters['sentiment'] !== 'all') {
  248. $sql .= "
  249. AND EXISTS (
  250. SELECT 1
  251. FROM conversation c
  252. INNER JOIN conversation_analysis ca
  253. ON ca.conversation_id = c.conversation_id
  254. AND ca.conversation_analysis_deleted_at = 'infinity'
  255. WHERE c.company_id = a.company_id
  256. AND c.client_id = a.client_id
  257. AND c.conversation_deleted_at = 'infinity'
  258. AND c.conversation_last_message_at >= :filter_start_datetime
  259. AND c.conversation_last_message_at < :filter_end_exclusive_datetime";
  260. $params['filter_start_datetime'] = $range['start_datetime'];
  261. $params['filter_end_exclusive_datetime'] = $range['end_exclusive_datetime'];
  262. $this->appendConversationAnalysisFilters($sql, $params, 'ca', $filters);
  263. $sql .= "
  264. )";
  265. }
  266. $sql .= "
  267. ORDER BY
  268. CASE lower(a.alert_priority)
  269. WHEN 'high' THEN 1
  270. WHEN 'medium' THEN 2
  271. ELSE 3
  272. END,
  273. a.alert_created_at DESC
  274. LIMIT 10";
  275. $stmt = $this->pdo->prepare($sql);
  276. $stmt->execute($params);
  277. $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
  278. return array_map(function (array $row): array {
  279. $priority = $this->normalizeAlertPriority((string) ($row['alert_priority'] ?? 'low'));
  280. return [
  281. 'id' => 'alert-' . (int) $row['alert_id'],
  282. 'clientId' => (int) ($row['client_id'] ?? 0),
  283. 'clientName' => $row['client_name'] ?? '',
  284. 'title' => $row['alert_title'] ?? '',
  285. 'description' => $row['alert_description'] ?? '',
  286. 'priority' => $priority,
  287. 'priorityLabel' => $this->getPriorityLabel($priority),
  288. 'category' => $this->normalizeAlertCategory((string) ($row['alert_type'] ?? '')),
  289. ];
  290. }, $rows);
  291. }
  292. private function getDayTimeline(int $companyId, array $filters): array
  293. {
  294. $today = new \DateTimeImmutable('today');
  295. $start = $today->modify('-6 days');
  296. $range = [
  297. 'start_datetime' => $start->format('Y-m-d 00:00:00'),
  298. 'end_exclusive_datetime' => $today->modify('+1 day')->format('Y-m-d 00:00:00'),
  299. ];
  300. $stats = $this->getOpinionBuckets(
  301. $companyId,
  302. $filters,
  303. $range,
  304. "TO_CHAR(DATE(po.opinion_classified_at), 'YYYY-MM-DD')"
  305. );
  306. $items = [];
  307. for ($i = 0; $i < 7; $i++) {
  308. $bucketDate = $start->modify('+' . $i . ' days')->format('Y-m-d');
  309. $items[] = [
  310. 'period' => 'Dia ' . ($i + 1),
  311. 'gains' => (int) ($stats[$bucketDate]['gains'] ?? 0),
  312. 'losses' => (int) ($stats[$bucketDate]['losses'] ?? 0),
  313. ];
  314. }
  315. return $items;
  316. }
  317. private function getWeekTimeline(int $companyId, array $filters): array
  318. {
  319. $currentWeek = new \DateTimeImmutable('monday this week');
  320. $start = $currentWeek->modify('-5 weeks');
  321. $range = [
  322. 'start_datetime' => $start->format('Y-m-d 00:00:00'),
  323. 'end_exclusive_datetime' => $currentWeek->modify('+1 week')->format('Y-m-d 00:00:00'),
  324. ];
  325. $stats = $this->getOpinionBuckets(
  326. $companyId,
  327. $filters,
  328. $range,
  329. "TO_CHAR(DATE_TRUNC('week', po.opinion_classified_at), 'YYYY-MM-DD')"
  330. );
  331. $items = [];
  332. for ($i = 0; $i < 6; $i++) {
  333. $bucketDate = $start->modify('+' . $i . ' weeks')->format('Y-m-d');
  334. $items[] = [
  335. 'period' => 'Sem ' . ($i + 1),
  336. 'gains' => (int) ($stats[$bucketDate]['gains'] ?? 0),
  337. 'losses' => (int) ($stats[$bucketDate]['losses'] ?? 0),
  338. ];
  339. }
  340. return $items;
  341. }
  342. private function getMonthTimeline(int $companyId, array $filters): array
  343. {
  344. $currentMonth = new \DateTimeImmutable('first day of this month');
  345. $start = $currentMonth->modify('-5 months');
  346. $range = [
  347. 'start_datetime' => $start->format('Y-m-d 00:00:00'),
  348. 'end_exclusive_datetime' => $currentMonth->modify('+1 month')->format('Y-m-d 00:00:00'),
  349. ];
  350. $stats = $this->getOpinionBuckets(
  351. $companyId,
  352. $filters,
  353. $range,
  354. "TO_CHAR(DATE_TRUNC('month', po.opinion_classified_at), 'YYYY-MM-DD')"
  355. );
  356. $items = [];
  357. for ($i = 0; $i < 6; $i++) {
  358. $bucketDate = $start->modify('+' . $i . ' months')->format('Y-m-d');
  359. $items[] = [
  360. 'period' => $this->formatMonthLabel($start->modify('+' . $i . ' months')),
  361. 'gains' => (int) ($stats[$bucketDate]['gains'] ?? 0),
  362. 'losses' => (int) ($stats[$bucketDate]['losses'] ?? 0),
  363. ];
  364. }
  365. return $items;
  366. }
  367. private function getOpinionBuckets(int $companyId, array $filters, array $range, string $bucketSql): array
  368. {
  369. $sql = "SELECT
  370. {$bucketSql} AS bucket_key,
  371. COALESCE(SUM(CASE WHEN po.opinion_is_positive THEN 1 ELSE 0 END), 0) AS gains,
  372. COALESCE(SUM(CASE WHEN po.opinion_is_positive THEN 0 ELSE 1 END), 0) AS losses
  373. FROM public_opinion po
  374. INNER JOIN conversation c
  375. ON c.conversation_id = po.conversation_id
  376. AND c.conversation_deleted_at = 'infinity'
  377. LEFT JOIN conversation_analysis ca
  378. ON ca.conversation_id = po.conversation_id
  379. AND ca.conversation_analysis_deleted_at = 'infinity'
  380. WHERE po.company_id = :company_id
  381. AND po.opinion_deleted_at = 'infinity'
  382. AND po.opinion_classified_at >= :start_datetime
  383. AND po.opinion_classified_at < :end_exclusive_datetime";
  384. $params = [
  385. 'company_id' => $companyId,
  386. 'start_datetime' => $range['start_datetime'],
  387. 'end_exclusive_datetime' => $range['end_exclusive_datetime'],
  388. ];
  389. $this->appendConversationAnalysisFilters($sql, $params, 'ca', $filters);
  390. $sql .= "
  391. GROUP BY bucket_key
  392. ORDER BY bucket_key ASC";
  393. $stmt = $this->pdo->prepare($sql);
  394. $stmt->execute($params);
  395. $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
  396. $mapped = [];
  397. foreach ($rows as $row) {
  398. $mapped[$row['bucket_key']] = [
  399. 'gains' => (int) ($row['gains'] ?? 0),
  400. 'losses' => (int) ($row['losses'] ?? 0),
  401. ];
  402. }
  403. return $mapped;
  404. }
  405. private function getAspects(int $companyId, array $filters, array $range): array
  406. {
  407. $sql = "SELECT
  408. af.aspect_feedback_aspect,
  409. af.aspect_feedback_sentiment,
  410. af.aspect_feedback_text,
  411. cl.client_name,
  412. cl.client_phone,
  413. af.aspect_feedback_created_at
  414. FROM aspect_feedback af
  415. INNER JOIN conversation c
  416. ON c.conversation_id = af.conversation_id
  417. AND c.conversation_deleted_at = 'infinity'
  418. INNER JOIN client cl
  419. ON cl.client_id = c.client_id
  420. AND cl.client_deleted_at = 'infinity'
  421. WHERE af.company_id = :company_id
  422. AND af.aspect_feedback_deleted_at = 'infinity'
  423. AND af.aspect_feedback_created_at >= :start_datetime
  424. AND af.aspect_feedback_created_at < :end_exclusive_datetime";
  425. $params = [
  426. 'company_id' => $companyId,
  427. 'start_datetime' => $range['start_datetime'],
  428. 'end_exclusive_datetime' => $range['end_exclusive_datetime'],
  429. ];
  430. if ($filters['aspect'] !== null) {
  431. $sql .= " AND lower(af.aspect_feedback_aspect) = :aspect";
  432. $params['aspect'] = $filters['aspect'];
  433. }
  434. if ($filters['sentiment'] !== 'all') {
  435. $sql .= ' AND ' . $this->getAspectSentimentWhereClause('af', $filters['sentiment']);
  436. }
  437. $sql .= "
  438. ORDER BY af.aspect_feedback_aspect ASC, af.aspect_feedback_created_at DESC";
  439. $stmt = $this->pdo->prepare($sql);
  440. $stmt->execute($params);
  441. $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
  442. $grouped = [];
  443. foreach ($rows as $row) {
  444. $name = (string) ($row['aspect_feedback_aspect'] ?? '');
  445. $id = $this->slugify($name);
  446. if (!isset($grouped[$id])) {
  447. $grouped[$id] = [
  448. 'id' => $id,
  449. 'name' => $name,
  450. 'volume' => 0,
  451. 'positive' => [],
  452. 'neutral' => [],
  453. 'negative' => [],
  454. ];
  455. }
  456. $sentiment = $this->normalizeAspectSentiment((string) ($row['aspect_feedback_sentiment'] ?? 'neutral'));
  457. $grouped[$id]['volume']++;
  458. $grouped[$id][$sentiment][] = [
  459. 'text' => $row['aspect_feedback_text'] ?? '',
  460. 'client' => $row['client_name'] !== '' ? $row['client_name'] : ($row['client_phone'] ?? ''),
  461. ];
  462. }
  463. usort($grouped, static function (array $left, array $right): int {
  464. return $right['volume'] <=> $left['volume'];
  465. });
  466. return array_values($grouped);
  467. }
  468. private function appendConversationAnalysisFilters(string &$sql, array &$params, string $alias, array $filters): void
  469. {
  470. if ($filters['aspect'] !== null) {
  471. $sql .= " AND lower(COALESCE({$alias}.conversation_analysis_aspect, '')) = :aspect";
  472. $params['aspect'] = $filters['aspect'];
  473. }
  474. if ($filters['sentiment'] !== 'all') {
  475. $sql .= ' AND ' . $this->getConversationSentimentWhereClause($alias, $filters['sentiment']);
  476. }
  477. }
  478. private function getConversationSentimentWhereClause(string $alias, string $sentiment): string
  479. {
  480. if ($sentiment === 'positive') {
  481. return "(
  482. lower(COALESCE({$alias}.conversation_analysis_sentiment, '')) IN ('positive', 'positivo')
  483. OR {$alias}.conversation_analysis_sentiment_score >= 0.15
  484. )";
  485. }
  486. if ($sentiment === 'negative') {
  487. return "(
  488. lower(COALESCE({$alias}.conversation_analysis_sentiment, '')) IN ('negative', 'negativo')
  489. OR {$alias}.conversation_analysis_sentiment_score <= -0.15
  490. )";
  491. }
  492. return "(
  493. {$alias}.conversation_id IS NOT NULL
  494. AND lower(COALESCE({$alias}.conversation_analysis_sentiment, '')) NOT IN ('positive', 'positivo', 'negative', 'negativo')
  495. AND {$alias}.conversation_analysis_sentiment_score > -0.15
  496. AND {$alias}.conversation_analysis_sentiment_score < 0.15
  497. )";
  498. }
  499. private function getAspectSentimentWhereClause(string $alias, string $sentiment): string
  500. {
  501. if ($sentiment === 'positive') {
  502. return "lower({$alias}.aspect_feedback_sentiment) IN ('positive', 'positivo')";
  503. }
  504. if ($sentiment === 'negative') {
  505. return "lower({$alias}.aspect_feedback_sentiment) IN ('negative', 'negativo')";
  506. }
  507. return "lower({$alias}.aspect_feedback_sentiment) NOT IN ('positive', 'positivo', 'negative', 'negativo')";
  508. }
  509. private function normalizeAspectSentiment(string $sentiment): string
  510. {
  511. $normalized = mb_strtolower(trim($sentiment));
  512. if (in_array($normalized, ['positive', 'positivo'], true)) {
  513. return 'positive';
  514. }
  515. if (in_array($normalized, ['negative', 'negativo'], true)) {
  516. return 'negative';
  517. }
  518. return 'neutral';
  519. }
  520. private function normalizeAlertPriority(string $priority): string
  521. {
  522. $normalized = mb_strtolower(trim($priority));
  523. if (in_array($normalized, ['high', 'alta'], true)) {
  524. return 'high';
  525. }
  526. if (in_array($normalized, ['medium', 'media', 'média'], true)) {
  527. return 'medium';
  528. }
  529. return 'low';
  530. }
  531. private function getPriorityLabel(string $priority): string
  532. {
  533. if ($priority === 'high') {
  534. return 'Alta prioridade';
  535. }
  536. if ($priority === 'medium') {
  537. return 'Media prioridade';
  538. }
  539. return 'Baixa prioridade';
  540. }
  541. private function normalizeAlertCategory(string $type): string
  542. {
  543. $normalized = mb_strtolower(trim($type));
  544. if (in_array($normalized, ['buying_intent', 'opportunity', 'upsell', 'cross_sell'], true)) {
  545. return 'buying_intent';
  546. }
  547. if (in_array($normalized, ['frustration', 'frustracao', 'frustração'], true)) {
  548. return 'frustration';
  549. }
  550. return 'churn_risk';
  551. }
  552. private function formatMonthLabel(\DateTimeImmutable $date): string
  553. {
  554. $labels = [
  555. '01' => 'Jan',
  556. '02' => 'Fev',
  557. '03' => 'Mar',
  558. '04' => 'Abr',
  559. '05' => 'Mai',
  560. '06' => 'Jun',
  561. '07' => 'Jul',
  562. '08' => 'Ago',
  563. '09' => 'Set',
  564. '10' => 'Out',
  565. '11' => 'Nov',
  566. '12' => 'Dez',
  567. ];
  568. return $labels[$date->format('m')] ?? $date->format('m');
  569. }
  570. private function slugify(string $value): string
  571. {
  572. $normalized = trim($value);
  573. if ($normalized === '') {
  574. return 'aspect';
  575. }
  576. if (function_exists('iconv')) {
  577. $converted = iconv('UTF-8', 'ASCII//TRANSLIT', $normalized);
  578. if (is_string($converted) && $converted !== '') {
  579. $normalized = $converted;
  580. }
  581. }
  582. $normalized = mb_strtolower($normalized);
  583. $normalized = preg_replace('/[^a-z0-9]+/', '-', $normalized) ?? 'aspect';
  584. $normalized = trim($normalized, '-');
  585. return $normalized !== '' ? $normalized : 'aspect';
  586. }
  587. }