PersonasModel.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. <?php
  2. namespace Models;
  3. use Libs\Database;
  4. class PersonasModel
  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. $personaRows = $this->getPersonaRows($companyId, $filters, $range);
  16. $stats = $this->getStats($companyId, $filters, $range);
  17. return [
  18. 'kpis' => $this->buildKpis($personaRows, $stats),
  19. 'stats' => $stats,
  20. 'personas' => array_map(fn (array $row): array => $this->formatPersonaRow($row), $personaRows),
  21. ];
  22. }
  23. private function normalizeFilters(array $queryParams): array
  24. {
  25. $period = strtolower(trim((string) ($queryParams['period'] ?? 'week')));
  26. if (!in_array($period, ['week', 'month', 'quarter'], true)) {
  27. $period = 'week';
  28. }
  29. $unit = strtolower(trim((string) ($queryParams['unit'] ?? 'all')));
  30. if ($unit === '') {
  31. $unit = 'all';
  32. }
  33. $area = trim((string) ($queryParams['area'] ?? 'any'));
  34. if ($area === '') {
  35. $area = 'any';
  36. }
  37. $area = mb_strtolower($area);
  38. if ($area === 'all') {
  39. $area = 'any';
  40. }
  41. $sentiment = strtolower(trim((string) ($queryParams['sentiment'] ?? 'all')));
  42. if (!in_array($sentiment, ['all', 'positive', 'neutral', 'negative'], true)) {
  43. $sentiment = 'all';
  44. }
  45. return [
  46. 'period' => $period,
  47. 'unit' => $unit,
  48. 'area' => $area,
  49. 'area_token' => $this->normalizeToken($area),
  50. 'sentiment' => $sentiment,
  51. ];
  52. }
  53. private function resolveDateRange(string $period): array
  54. {
  55. $today = new \DateTimeImmutable('today');
  56. if ($period === 'quarter') {
  57. $start = $today->modify('-89 days');
  58. } elseif ($period === 'month') {
  59. $start = $today->modify('-29 days');
  60. } else {
  61. $start = $today->modify('-6 days');
  62. }
  63. return [
  64. 'start_datetime' => $start->format('Y-m-d 00:00:00'),
  65. 'end_exclusive_datetime' => $today->modify('+1 day')->format('Y-m-d 00:00:00'),
  66. ];
  67. }
  68. private function getPersonaRows(int $companyId, array $filters, array $range): array
  69. {
  70. $sql = "SELECT
  71. p.persona_id,
  72. p.persona_name,
  73. p.persona_type,
  74. p.persona_description,
  75. p.persona_details,
  76. p.persona_risk_level,
  77. p.persona_churn_risk_pct,
  78. p.persona_expansion_potential,
  79. COALESCE((
  80. SELECT ba.best_action_idea
  81. FROM best_action ba
  82. WHERE ba.persona_id = p.persona_id
  83. AND ba.best_action_deleted_at = 'infinity'
  84. AND lower(ba.best_action_type) IN ('expansao', 'expansão', 'expansion', 'upsell', 'cross_sell')
  85. ORDER BY ba.best_action_created_at DESC
  86. LIMIT 1
  87. ), p.persona_expansion_strategy) AS expansao,
  88. COALESCE((
  89. SELECT ba.best_action_idea
  90. FROM best_action ba
  91. WHERE ba.persona_id = p.persona_id
  92. AND ba.best_action_deleted_at = 'infinity'
  93. AND lower(ba.best_action_type) IN ('engajamento', 'engagement', 'retencao', 'retenção', 'churn', 'save_churn')
  94. ORDER BY ba.best_action_created_at DESC
  95. LIMIT 1
  96. ), p.persona_engagement_strategy) AS engajamento,
  97. COUNT(DISTINCT CASE WHEN c.conversation_id IS NOT NULL THEN cp.client_id END) AS matched_clients
  98. FROM persona p
  99. LEFT JOIN client_persona cp
  100. ON cp.persona_id = p.persona_id
  101. AND cp.client_persona_deleted_at = 'infinity'
  102. LEFT JOIN conversation c
  103. ON c.client_id = cp.client_id
  104. AND c.company_id = p.company_id
  105. AND c.conversation_deleted_at = 'infinity'
  106. AND c.conversation_last_message_at >= :start_datetime
  107. AND c.conversation_last_message_at < :end_exclusive_datetime
  108. LEFT JOIN conversation_analysis ca
  109. ON ca.conversation_id = c.conversation_id
  110. AND ca.conversation_analysis_deleted_at = 'infinity'
  111. WHERE p.company_id = :company_id
  112. AND p.persona_deleted_at = 'infinity'";
  113. $params = [
  114. 'company_id' => $companyId,
  115. 'start_datetime' => $range['start_datetime'],
  116. 'end_exclusive_datetime' => $range['end_exclusive_datetime'],
  117. ];
  118. $this->appendPersonaActivityFilters($sql, $params, 'ca', $filters);
  119. $sql .= "
  120. GROUP BY
  121. p.persona_id,
  122. p.persona_name,
  123. p.persona_type,
  124. p.persona_description,
  125. p.persona_details,
  126. p.persona_risk_level,
  127. p.persona_churn_risk_pct,
  128. p.persona_expansion_potential,
  129. p.persona_expansion_strategy,
  130. p.persona_engagement_strategy,
  131. p.persona_created_at
  132. ORDER BY matched_clients DESC, p.persona_created_at DESC, p.persona_id ASC";
  133. $stmt = $this->pdo->prepare($sql);
  134. $stmt->execute($params);
  135. return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
  136. }
  137. private function getStats(int $companyId, array $filters, array $range): array
  138. {
  139. $sql = "SELECT
  140. COUNT(DISTINCT CASE WHEN c.conversation_id IS NOT NULL THEN cp.client_id END) AS identified,
  141. COUNT(DISTINCT m.message_id) AS messages,
  142. COUNT(DISTINCT NULLIF(ca.conversation_analysis_aspect, '')) AS aspects,
  143. COUNT(DISTINCT NULLIF(ca.conversation_analysis_sub_aspect, '')) AS subaspects
  144. FROM persona p
  145. LEFT JOIN client_persona cp
  146. ON cp.persona_id = p.persona_id
  147. AND cp.client_persona_deleted_at = 'infinity'
  148. LEFT JOIN conversation c
  149. ON c.client_id = cp.client_id
  150. AND c.company_id = p.company_id
  151. AND c.conversation_deleted_at = 'infinity'
  152. AND c.conversation_last_message_at >= :start_datetime
  153. AND c.conversation_last_message_at < :end_exclusive_datetime
  154. LEFT JOIN conversation_analysis ca
  155. ON ca.conversation_id = c.conversation_id
  156. AND ca.conversation_analysis_deleted_at = 'infinity'
  157. LEFT JOIN message m
  158. ON m.conversation_id = c.conversation_id
  159. AND m.message_deleted_at = 'infinity'
  160. AND m.message_sent_at >= :start_datetime
  161. AND m.message_sent_at < :end_exclusive_datetime
  162. WHERE p.company_id = :company_id
  163. AND p.persona_deleted_at = 'infinity'";
  164. $params = [
  165. 'company_id' => $companyId,
  166. 'start_datetime' => $range['start_datetime'],
  167. 'end_exclusive_datetime' => $range['end_exclusive_datetime'],
  168. ];
  169. $this->appendPersonaActivityFilters($sql, $params, 'ca', $filters);
  170. $stmt = $this->pdo->prepare($sql);
  171. $stmt->execute($params);
  172. $row = $stmt->fetch(\PDO::FETCH_ASSOC) ?: [];
  173. return [
  174. 'identified' => (int) ($row['identified'] ?? 0),
  175. 'messages' => (int) ($row['messages'] ?? 0),
  176. 'aspects' => (int) ($row['aspects'] ?? 0),
  177. 'subaspects' => (int) ($row['subaspects'] ?? 0),
  178. ];
  179. }
  180. private function buildKpis(array $personaRows, array $stats): array
  181. {
  182. $activeRows = array_values(array_filter($personaRows, static function (array $row): bool {
  183. return (int) ($row['matched_clients'] ?? 0) > 0;
  184. }));
  185. $sourceRows = $activeRows !== [] ? $activeRows : $personaRows;
  186. if ($sourceRows === []) {
  187. return [
  188. 'active' => 0,
  189. 'churn' => 0.0,
  190. 'loss' => 0.0,
  191. 'potentialLabel' => 'Neutro',
  192. ];
  193. }
  194. $avgChurn = array_sum(array_map(static function (array $row): float {
  195. return (float) ($row['persona_churn_risk_pct'] ?? 0);
  196. }, $sourceRows)) / count($sourceRows);
  197. $avgPotentialScore = array_sum(array_map(function (array $row): float {
  198. return $this->mapPotentialLabelToScore((string) ($row['persona_expansion_potential'] ?? ''));
  199. }, $sourceRows)) / count($sourceRows);
  200. $estimatedLoss = round($stats['identified'] * ($avgChurn / 100) * 450, 2);
  201. return [
  202. 'active' => count($activeRows),
  203. 'churn' => round($avgChurn, 1),
  204. 'loss' => $estimatedLoss,
  205. 'potentialLabel' => $this->mapScoreToPotentialLabel($avgPotentialScore),
  206. ];
  207. }
  208. private function formatPersonaRow(array $row): array
  209. {
  210. return [
  211. 'id' => (string) ($row['persona_id'] ?? ''),
  212. 'nome' => $row['persona_name'] ?? '',
  213. 'tipo' => $row['persona_type'] ?? 'O PERFIL',
  214. 'descricao' => $row['persona_description'] ?? '',
  215. 'detalhes' => $row['persona_details'] ?? '',
  216. 'expansao' => $row['expansao'] ?? '',
  217. 'engajamento' => $row['engajamento'] ?? '',
  218. 'risco' => $row['persona_risk_level'] ?? 'Médio',
  219. ];
  220. }
  221. private function appendPersonaActivityFilters(string &$sql, array &$params, string $analysisAlias, array $filters): void
  222. {
  223. if ($filters['area'] !== 'any' && $filters['area_token'] !== '') {
  224. $sql .= ' AND ('
  225. . $this->getNormalizedTextSql("{$analysisAlias}.conversation_analysis_aspect") . ' = :area_token'
  226. . ' OR '
  227. . $this->getNormalizedTextSql("{$analysisAlias}.conversation_analysis_sub_aspect") . ' = :area_token'
  228. . ')';
  229. $params['area_token'] = $filters['area_token'];
  230. }
  231. if ($filters['sentiment'] !== 'all') {
  232. $sql .= ' AND ' . $this->getSentimentWhereClause($analysisAlias, $filters['sentiment']);
  233. }
  234. }
  235. private function getSentimentWhereClause(string $analysisAlias, string $sentiment): string
  236. {
  237. if ($sentiment === 'positive') {
  238. return "(
  239. lower(COALESCE({$analysisAlias}.conversation_analysis_sentiment, '')) IN ('positive', 'positivo')
  240. OR {$analysisAlias}.conversation_analysis_sentiment_score >= 0.15
  241. )";
  242. }
  243. if ($sentiment === 'negative') {
  244. return "(
  245. lower(COALESCE({$analysisAlias}.conversation_analysis_sentiment, '')) IN ('negative', 'negativo')
  246. OR {$analysisAlias}.conversation_analysis_sentiment_score <= -0.15
  247. )";
  248. }
  249. return "(
  250. {$analysisAlias}.conversation_id IS NOT NULL
  251. AND lower(COALESCE({$analysisAlias}.conversation_analysis_sentiment, '')) NOT IN ('positive', 'positivo', 'negative', 'negativo')
  252. AND {$analysisAlias}.conversation_analysis_sentiment_score > -0.15
  253. AND {$analysisAlias}.conversation_analysis_sentiment_score < 0.15
  254. )";
  255. }
  256. private function getNormalizedTextSql(string $column): string
  257. {
  258. return "regexp_replace(translate(lower(COALESCE({$column}, '')), 'áàâãäéèêëíìîïóòôõöúùûüçñ', 'aaaaaeeeeiiiiooooouuuucn'), '[^a-z0-9]+', '', 'g')";
  259. }
  260. private function normalizeToken(string $value): string
  261. {
  262. $normalized = trim($value);
  263. if ($normalized === '' || $normalized === 'any') {
  264. return '';
  265. }
  266. if (function_exists('iconv')) {
  267. $converted = iconv('UTF-8', 'ASCII//TRANSLIT', $normalized);
  268. if (is_string($converted) && $converted !== '') {
  269. $normalized = $converted;
  270. }
  271. }
  272. $normalized = mb_strtolower($normalized);
  273. $normalized = preg_replace('/[^a-z0-9]+/', '', $normalized) ?? '';
  274. return $normalized;
  275. }
  276. private function mapPotentialLabelToScore(string $label): float
  277. {
  278. $normalized = $this->normalizeToken($label);
  279. if ($normalized === 'muitoalto') {
  280. return 1.5;
  281. }
  282. if ($normalized === 'alto') {
  283. return 1.0;
  284. }
  285. if ($normalized === 'medio') {
  286. return 0.35;
  287. }
  288. if ($normalized === 'baixo') {
  289. return -1.0;
  290. }
  291. return 0.0;
  292. }
  293. private function mapScoreToPotentialLabel(float $score): string
  294. {
  295. if ($score >= 1.2) {
  296. return 'Muito alto';
  297. }
  298. if ($score >= 0.5) {
  299. return 'Alto';
  300. }
  301. if ($score <= -0.35) {
  302. return 'Baixo';
  303. }
  304. return 'Neutro';
  305. }
  306. }