ExecutiveDashboardModel.php 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487
  1. <?php
  2. namespace Models;
  3. use Libs\Database;
  4. class ExecutiveDashboardModel
  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. $anchorDate = $this->getAnchorDate($companyId);
  14. $latestKpi = $this->getLatestKpiSnapshot($companyId, $anchorDate);
  15. $previousKpi = $this->getPreviousKpiSnapshot($companyId, $anchorDate);
  16. $latestEmotion = $this->getLatestEmotionSnapshot($companyId, $anchorDate);
  17. $previousEmotion = $this->getPreviousEmotionSnapshot($companyId, $anchorDate);
  18. return [
  19. 'topKpis' => $this->buildTopKpis($latestKpi, $previousKpi),
  20. 'churnDistribution' => $this->buildChurnDistribution($latestKpi),
  21. 'ltvRisk' => $this->buildLtvRisk($latestKpi, $previousKpi),
  22. 'sla' => $this->buildSla($companyId, $latestKpi, $anchorDate),
  23. 'emotions' => $this->buildEmotions($latestEmotion),
  24. 'quickAccess' => $this->buildQuickAccess($companyId, $latestKpi, $latestEmotion, $previousEmotion, $anchorDate),
  25. ];
  26. }
  27. private function getAnchorDate(int $companyId): \DateTimeImmutable
  28. {
  29. $stmt = $this->pdo->prepare(
  30. "SELECT MAX(snapshot_date)
  31. FROM (
  32. SELECT MAX(kpi_snapshot_date)::date AS snapshot_date
  33. FROM kpi_snapshot
  34. WHERE company_id = :company_id
  35. UNION ALL
  36. SELECT MAX(emotion_snapshot_date)::date AS snapshot_date
  37. FROM emotion_snapshot
  38. WHERE company_id = :company_id
  39. UNION ALL
  40. SELECT MAX(operator_stat_date)::date AS snapshot_date
  41. FROM operator_daily_stats
  42. WHERE company_id = :company_id
  43. ) snapshots"
  44. );
  45. $stmt->execute(['company_id' => $companyId]);
  46. $anchorDate = $stmt->fetchColumn();
  47. if (!is_string($anchorDate) || $anchorDate === '') {
  48. return new \DateTimeImmutable('today');
  49. }
  50. return new \DateTimeImmutable($anchorDate);
  51. }
  52. private function getLatestKpiSnapshot(int $companyId, \DateTimeImmutable $anchorDate): array
  53. {
  54. $stmt = $this->pdo->prepare(
  55. "SELECT *
  56. FROM kpi_snapshot
  57. WHERE company_id = :company_id
  58. AND kpi_snapshot_date <= :anchor_date
  59. ORDER BY kpi_snapshot_date DESC
  60. LIMIT 1"
  61. );
  62. $stmt->execute([
  63. 'company_id' => $companyId,
  64. 'anchor_date' => $anchorDate->format('Y-m-d'),
  65. ]);
  66. return $stmt->fetch(\PDO::FETCH_ASSOC) ?: [];
  67. }
  68. private function getPreviousKpiSnapshot(int $companyId, \DateTimeImmutable $anchorDate): array
  69. {
  70. $stmt = $this->pdo->prepare(
  71. "SELECT *
  72. FROM kpi_snapshot
  73. WHERE company_id = :company_id
  74. AND kpi_snapshot_date < :anchor_date
  75. ORDER BY kpi_snapshot_date DESC
  76. LIMIT 1"
  77. );
  78. $stmt->execute([
  79. 'company_id' => $companyId,
  80. 'anchor_date' => $anchorDate->format('Y-m-d'),
  81. ]);
  82. return $stmt->fetch(\PDO::FETCH_ASSOC) ?: [];
  83. }
  84. private function getLatestEmotionSnapshot(int $companyId, \DateTimeImmutable $anchorDate): array
  85. {
  86. $stmt = $this->pdo->prepare(
  87. "SELECT *
  88. FROM emotion_snapshot
  89. WHERE company_id = :company_id
  90. AND emotion_snapshot_date <= :anchor_date
  91. ORDER BY emotion_snapshot_date DESC
  92. LIMIT 1"
  93. );
  94. $stmt->execute([
  95. 'company_id' => $companyId,
  96. 'anchor_date' => $anchorDate->format('Y-m-d'),
  97. ]);
  98. return $stmt->fetch(\PDO::FETCH_ASSOC) ?: [];
  99. }
  100. private function getPreviousEmotionSnapshot(int $companyId, \DateTimeImmutable $anchorDate): array
  101. {
  102. $stmt = $this->pdo->prepare(
  103. "SELECT *
  104. FROM emotion_snapshot
  105. WHERE company_id = :company_id
  106. AND emotion_snapshot_date < :anchor_date
  107. ORDER BY emotion_snapshot_date DESC
  108. LIMIT 1"
  109. );
  110. $stmt->execute([
  111. 'company_id' => $companyId,
  112. 'anchor_date' => $anchorDate->format('Y-m-d'),
  113. ]);
  114. return $stmt->fetch(\PDO::FETCH_ASSOC) ?: [];
  115. }
  116. private function buildTopKpis(array $latestKpi, array $previousKpi): array
  117. {
  118. $currentSales = (float) ($latestKpi['kpi_current_sales'] ?? 0);
  119. $previousSales = (float) ($previousKpi['kpi_current_sales'] ?? $currentSales);
  120. $avgTicket = (float) ($latestKpi['kpi_avg_ticket'] ?? 0);
  121. $previousAvgTicket = (float) ($previousKpi['kpi_avg_ticket'] ?? $avgTicket);
  122. $ltvAtRisk = (float) ($latestKpi['kpi_ltv_at_risk'] ?? 0);
  123. $previousLtvAtRisk = (float) ($previousKpi['kpi_ltv_at_risk'] ?? $ltvAtRisk);
  124. $criticalClients = (int) ($latestKpi['kpi_critical_risk_clients'] ?? 0);
  125. $previousCriticalClients = (int) ($previousKpi['kpi_critical_risk_clients'] ?? $criticalClients);
  126. return [
  127. [
  128. 'title' => 'Venda Atual',
  129. 'value' => $this->formatCurrency($currentSales),
  130. 'trendLabel' => $this->formatTrendLabel($currentSales, $previousSales, 'vs ontem', false),
  131. 'danger' => false,
  132. ],
  133. [
  134. 'title' => 'Ticket Médio',
  135. 'value' => $this->formatCurrency($avgTicket),
  136. 'trendLabel' => $this->formatTrendLabel($avgTicket, $previousAvgTicket, 'vs ontem', false),
  137. 'danger' => false,
  138. ],
  139. [
  140. 'title' => 'Lifetime em Risco',
  141. 'value' => $this->formatCurrency($ltvAtRisk),
  142. 'trendLabel' => $this->formatTrendLabel($ltvAtRisk, $previousLtvAtRisk, 'vs ontem', true),
  143. 'danger' => $ltvAtRisk > 0,
  144. ],
  145. [
  146. 'title' => 'Clientes em Risco Crítico',
  147. 'value' => (string) $criticalClients,
  148. 'trendLabel' => $this->formatAbsoluteTrendLabel($criticalClients - $previousCriticalClients, 'desde ontem', true),
  149. 'danger' => $criticalClients > 0,
  150. ],
  151. ];
  152. }
  153. private function buildChurnDistribution(array $latestKpi): array
  154. {
  155. return [
  156. [
  157. 'label' => 'Baixo',
  158. 'value' => (int) round((float) ($latestKpi['kpi_churn_low_pct'] ?? 0)),
  159. 'color' => '#10b981',
  160. ],
  161. [
  162. 'label' => 'Moderado',
  163. 'value' => (int) round((float) ($latestKpi['kpi_churn_moderate_pct'] ?? 0)),
  164. 'color' => '#f59e0b',
  165. ],
  166. [
  167. 'label' => 'Alto',
  168. 'value' => (int) round((float) ($latestKpi['kpi_churn_high_pct'] ?? 0)),
  169. 'color' => '#f97316',
  170. ],
  171. [
  172. 'label' => 'Crítico',
  173. 'value' => (int) round((float) ($latestKpi['kpi_churn_critical_pct'] ?? 0)),
  174. 'color' => '#ef4444',
  175. ],
  176. ];
  177. }
  178. private function buildLtvRisk(array $latestKpi, array $previousKpi): array
  179. {
  180. $ltvTotal = (float) ($latestKpi['kpi_ltv_total'] ?? 0);
  181. $ltvAtRisk = (float) ($latestKpi['kpi_ltv_at_risk'] ?? 0);
  182. $criticalClients = (int) ($latestKpi['kpi_critical_risk_clients'] ?? 0);
  183. $previousCriticalClients = (int) ($previousKpi['kpi_critical_risk_clients'] ?? $criticalClients);
  184. $delta = $criticalClients - $previousCriticalClients;
  185. return [
  186. 'ltvTotal' => round($ltvTotal, 2),
  187. 'ltvAtRisk' => round($ltvAtRisk, 2),
  188. 'ltvRiskPct' => $ltvTotal > 0 ? (int) round(($ltvAtRisk / $ltvTotal) * 100) : 0,
  189. 'criticalClients' => $criticalClients,
  190. 'avgTicket' => (int) round((float) ($latestKpi['kpi_avg_ticket'] ?? 0)),
  191. 'trendText' => $this->formatCriticalTrendText($delta),
  192. ];
  193. }
  194. private function buildSla(int $companyId, array $latestKpi, \DateTimeImmutable $anchorDate): array
  195. {
  196. return [
  197. 'withinPct' => (int) round((float) ($latestKpi['kpi_sla_compliance_pct'] ?? 0)),
  198. 'breachPct' => max(0, 100 - (int) round((float) ($latestKpi['kpi_sla_compliance_pct'] ?? 0))),
  199. 'byDepartment' => $this->getSlaByDepartment($companyId, $anchorDate),
  200. ];
  201. }
  202. private function getSlaByDepartment(int $companyId, \DateTimeImmutable $anchorDate): array
  203. {
  204. $stmt = $this->pdo->prepare(
  205. "SELECT
  206. o.operator_department AS department,
  207. ROUND(AVG(ods.operator_sla_compliance_pct))::int AS value
  208. FROM operator_daily_stats ods
  209. INNER JOIN operator o
  210. ON o.operator_id = ods.operator_id
  211. AND o.operator_deleted_at = 'infinity'
  212. WHERE ods.company_id = :company_id
  213. AND ods.operator_stat_date = :anchor_date
  214. GROUP BY o.operator_department
  215. ORDER BY o.operator_department ASC"
  216. );
  217. $stmt->execute([
  218. 'company_id' => $companyId,
  219. 'anchor_date' => $anchorDate->format('Y-m-d'),
  220. ]);
  221. $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
  222. return array_map(static function (array $row): array {
  223. return [
  224. 'department' => $row['department'] ?? '',
  225. 'value' => (int) ($row['value'] ?? 0),
  226. ];
  227. }, $rows);
  228. }
  229. private function buildEmotions(array $latestEmotion): array
  230. {
  231. $totalAnalyzed = (int) ($latestEmotion['emotion_total_analyzed'] ?? 0);
  232. $emotionMap = [
  233. ['label' => 'Alegria', 'column' => 'emotion_happiness', 'color' => '#10b981'],
  234. ['label' => 'Confiança', 'column' => 'emotion_confidence', 'color' => '#6366f1'],
  235. ['label' => 'Antecipação', 'column' => 'emotion_anticipation', 'color' => '#8b5cf6'],
  236. ['label' => 'Surpresa', 'column' => 'emotion_surprise', 'color' => '#f59e0b'],
  237. ['label' => 'Medo', 'column' => 'emotion_fear', 'color' => '#f97316'],
  238. ['label' => 'Raiva', 'column' => 'emotion_anger', 'color' => '#ef4444'],
  239. ['label' => 'Tristeza', 'column' => 'emotion_sadness', 'color' => '#64748b'],
  240. ];
  241. $items = [];
  242. foreach ($emotionMap as $emotion) {
  243. $value = (float) ($latestEmotion[$emotion['column']] ?? 0);
  244. $items[] = [
  245. 'label' => $emotion['label'],
  246. 'value' => (int) round($value),
  247. 'count' => (int) round(($totalAnalyzed * $value) / 100),
  248. 'color' => $emotion['color'],
  249. ];
  250. }
  251. usort($items, static function (array $left, array $right): int {
  252. return $right['value'] <=> $left['value'];
  253. });
  254. return [
  255. 'items' => $items,
  256. 'avgSentimentScore' => round($this->calculateEmotionSentimentScore($latestEmotion), 2),
  257. ];
  258. }
  259. private function buildQuickAccess(int $companyId, array $latestKpi, array $latestEmotion, array $previousEmotion, \DateTimeImmutable $anchorDate): array
  260. {
  261. return [
  262. 'conversationsToday' => $this->getConversationsToday($companyId, $anchorDate),
  263. 'activePersonas' => $this->getActivePersonas($companyId),
  264. 'activeAgents' => $this->getActiveAgents($companyId, $latestKpi),
  265. 'activePlaybooks' => $this->getActivePlaybooks($companyId),
  266. 'pendingSettings' => $this->getPendingSettings($companyId),
  267. 'evolutionDelta' => $this->formatSignedPercent($this->calculateEmotionDeltaPercent($latestEmotion, $previousEmotion)),
  268. ];
  269. }
  270. private function getConversationsToday(int $companyId, \DateTimeImmutable $anchorDate): int
  271. {
  272. $stmt = $this->pdo->prepare(
  273. "SELECT COALESCE(SUM(operator_attendances_count), 0)
  274. FROM operator_daily_stats
  275. WHERE company_id = :company_id
  276. AND operator_stat_date = :anchor_date"
  277. );
  278. $stmt->execute([
  279. 'company_id' => $companyId,
  280. 'anchor_date' => $anchorDate->format('Y-m-d'),
  281. ]);
  282. return (int) $stmt->fetchColumn();
  283. }
  284. private function getActivePersonas(int $companyId): int
  285. {
  286. $stmt = $this->pdo->prepare(
  287. "SELECT COUNT(*)
  288. FROM persona
  289. WHERE company_id = :company_id
  290. AND persona_deleted_at = 'infinity'"
  291. );
  292. $stmt->execute(['company_id' => $companyId]);
  293. return (int) $stmt->fetchColumn();
  294. }
  295. private function getActiveAgents(int $companyId, array $latestKpi): int
  296. {
  297. $count = (int) ($latestKpi['kpi_total_active_operators'] ?? 0);
  298. if ($count > 0) {
  299. return $count;
  300. }
  301. $stmt = $this->pdo->prepare(
  302. "SELECT COUNT(*)
  303. FROM operator
  304. WHERE company_id = :company_id
  305. AND operator_deleted_at = 'infinity'
  306. AND lower(operator_status) <> 'inativo'"
  307. );
  308. $stmt->execute(['company_id' => $companyId]);
  309. return (int) $stmt->fetchColumn();
  310. }
  311. private function getActivePlaybooks(int $companyId): int
  312. {
  313. $stmt = $this->pdo->prepare(
  314. "SELECT COUNT(*)
  315. FROM ai_action
  316. WHERE company_id = :company_id
  317. AND ai_action_deleted_at = 'infinity'
  318. AND ai_action_created_at >= NOW() - INTERVAL '30 days'"
  319. );
  320. $stmt->execute(['company_id' => $companyId]);
  321. return (int) $stmt->fetchColumn();
  322. }
  323. private function getPendingSettings(int $companyId): int
  324. {
  325. $departmentsStmt = $this->pdo->prepare(
  326. "SELECT COUNT(DISTINCT lower(operator_department))
  327. FROM operator
  328. WHERE company_id = :company_id
  329. AND operator_deleted_at = 'infinity'"
  330. );
  331. $departmentsStmt->execute(['company_id' => $companyId]);
  332. $departmentCount = (int) $departmentsStmt->fetchColumn();
  333. $configsStmt = $this->pdo->prepare(
  334. "SELECT COUNT(DISTINCT lower(sla_config_department))
  335. FROM sla_config
  336. WHERE company_id = :company_id
  337. AND sla_config_deleted_at = 'infinity'"
  338. );
  339. $configsStmt->execute(['company_id' => $companyId]);
  340. $configCount = (int) $configsStmt->fetchColumn();
  341. return max(0, $departmentCount - $configCount);
  342. }
  343. private function calculateEmotionSentimentScore(array $emotionSnapshot): float
  344. {
  345. $weights = [
  346. 'emotion_happiness' => 0.9,
  347. 'emotion_confidence' => 0.6,
  348. 'emotion_anticipation' => 0.3,
  349. 'emotion_surprise' => 0.1,
  350. 'emotion_fear' => -0.6,
  351. 'emotion_anger' => -1.0,
  352. 'emotion_sadness' => -0.7,
  353. ];
  354. $score = 0.0;
  355. foreach ($weights as $column => $weight) {
  356. $score += ((float) ($emotionSnapshot[$column] ?? 0) / 100) * $weight;
  357. }
  358. return max(-1, min(1, $score));
  359. }
  360. private function calculateEmotionDeltaPercent(array $latestEmotion, array $previousEmotion): float
  361. {
  362. $latest = $this->calculateEmotionSentimentScore($latestEmotion);
  363. $previous = $this->calculateEmotionSentimentScore($previousEmotion);
  364. if (abs($previous) < 0.00001) {
  365. return $latest > 0 ? 100.0 : 0.0;
  366. }
  367. return (($latest - $previous) / abs($previous)) * 100;
  368. }
  369. private function formatCurrency(float $value): string
  370. {
  371. return 'R$ ' . number_format($value, 2, ',', '.');
  372. }
  373. private function formatTrendLabel(float $current, float $previous, string $suffix, bool $dangerOnIncrease): string
  374. {
  375. if (abs($current - $previous) < 0.00001) {
  376. return '→ estável';
  377. }
  378. $deltaPct = $this->calculateDeltaPercent($current, $previous);
  379. $arrow = $deltaPct >= 0 ? '↑' : '↓';
  380. $prefix = $deltaPct >= 0 ? '+' : '';
  381. return sprintf('%s %s%s%% %s', $arrow, $prefix, round($deltaPct), $suffix);
  382. }
  383. private function formatAbsoluteTrendLabel(int $delta, string $suffix, bool $dangerOnIncrease): string
  384. {
  385. if ($delta === 0) {
  386. return '→ estável';
  387. }
  388. $arrow = $delta > 0 ? '↑' : '↓';
  389. $prefix = $delta > 0 ? '+' : '';
  390. return sprintf('%s %s%d %s', $arrow, $prefix, $delta, $suffix);
  391. }
  392. private function formatCriticalTrendText(int $delta): string
  393. {
  394. if ($delta > 0) {
  395. return sprintf('+%d clientes entraram em risco crítico desde ontem', $delta);
  396. }
  397. if ($delta < 0) {
  398. return sprintf('%d clientes saíram do risco crítico desde ontem', abs($delta));
  399. }
  400. return 'Nenhuma mudança no risco crítico desde ontem';
  401. }
  402. private function formatSignedPercent(float $value): string
  403. {
  404. $rounded = round($value);
  405. if ($rounded > 0) {
  406. return '+' . $rounded . '%';
  407. }
  408. if ($rounded < 0) {
  409. return (string) $rounded . '%';
  410. }
  411. return '0%';
  412. }
  413. private function calculateDeltaPercent(float $current, float $previous): float
  414. {
  415. if (abs($previous) < 0.00001) {
  416. return $current > 0 ? 100.0 : 0.0;
  417. }
  418. return (($current - $previous) / abs($previous)) * 100;
  419. }
  420. }