| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487 |
- <?php
- namespace Models;
- use Libs\Database;
- class ExecutiveDashboardModel
- {
- private \PDO $pdo;
- public function __construct()
- {
- $this->pdo = Database::pdo();
- }
- public function getDashboardData(int $companyId, array $queryParams = []): array
- {
- $anchorDate = $this->getAnchorDate($companyId);
- $latestKpi = $this->getLatestKpiSnapshot($companyId, $anchorDate);
- $previousKpi = $this->getPreviousKpiSnapshot($companyId, $anchorDate);
- $latestEmotion = $this->getLatestEmotionSnapshot($companyId, $anchorDate);
- $previousEmotion = $this->getPreviousEmotionSnapshot($companyId, $anchorDate);
- return [
- 'topKpis' => $this->buildTopKpis($latestKpi, $previousKpi),
- 'churnDistribution' => $this->buildChurnDistribution($latestKpi),
- 'ltvRisk' => $this->buildLtvRisk($latestKpi, $previousKpi),
- 'sla' => $this->buildSla($companyId, $latestKpi, $anchorDate),
- 'emotions' => $this->buildEmotions($latestEmotion),
- 'quickAccess' => $this->buildQuickAccess($companyId, $latestKpi, $latestEmotion, $previousEmotion, $anchorDate),
- ];
- }
- private function getAnchorDate(int $companyId): \DateTimeImmutable
- {
- $stmt = $this->pdo->prepare(
- "SELECT MAX(snapshot_date)
- FROM (
- SELECT MAX(kpi_snapshot_date)::date AS snapshot_date
- FROM kpi_snapshot
- WHERE company_id = :company_id
- UNION ALL
- SELECT MAX(emotion_snapshot_date)::date AS snapshot_date
- FROM emotion_snapshot
- WHERE company_id = :company_id
- UNION ALL
- SELECT MAX(operator_stat_date)::date AS snapshot_date
- FROM operator_daily_stats
- WHERE company_id = :company_id
- ) snapshots"
- );
- $stmt->execute(['company_id' => $companyId]);
- $anchorDate = $stmt->fetchColumn();
- if (!is_string($anchorDate) || $anchorDate === '') {
- return new \DateTimeImmutable('today');
- }
- return new \DateTimeImmutable($anchorDate);
- }
- private function getLatestKpiSnapshot(int $companyId, \DateTimeImmutable $anchorDate): array
- {
- $stmt = $this->pdo->prepare(
- "SELECT *
- FROM kpi_snapshot
- WHERE company_id = :company_id
- AND kpi_snapshot_date <= :anchor_date
- ORDER BY kpi_snapshot_date DESC
- LIMIT 1"
- );
- $stmt->execute([
- 'company_id' => $companyId,
- 'anchor_date' => $anchorDate->format('Y-m-d'),
- ]);
- return $stmt->fetch(\PDO::FETCH_ASSOC) ?: [];
- }
- private function getPreviousKpiSnapshot(int $companyId, \DateTimeImmutable $anchorDate): array
- {
- $stmt = $this->pdo->prepare(
- "SELECT *
- FROM kpi_snapshot
- WHERE company_id = :company_id
- AND kpi_snapshot_date < :anchor_date
- ORDER BY kpi_snapshot_date DESC
- LIMIT 1"
- );
- $stmt->execute([
- 'company_id' => $companyId,
- 'anchor_date' => $anchorDate->format('Y-m-d'),
- ]);
- return $stmt->fetch(\PDO::FETCH_ASSOC) ?: [];
- }
- private function getLatestEmotionSnapshot(int $companyId, \DateTimeImmutable $anchorDate): array
- {
- $stmt = $this->pdo->prepare(
- "SELECT *
- FROM emotion_snapshot
- WHERE company_id = :company_id
- AND emotion_snapshot_date <= :anchor_date
- ORDER BY emotion_snapshot_date DESC
- LIMIT 1"
- );
- $stmt->execute([
- 'company_id' => $companyId,
- 'anchor_date' => $anchorDate->format('Y-m-d'),
- ]);
- return $stmt->fetch(\PDO::FETCH_ASSOC) ?: [];
- }
- private function getPreviousEmotionSnapshot(int $companyId, \DateTimeImmutable $anchorDate): array
- {
- $stmt = $this->pdo->prepare(
- "SELECT *
- FROM emotion_snapshot
- WHERE company_id = :company_id
- AND emotion_snapshot_date < :anchor_date
- ORDER BY emotion_snapshot_date DESC
- LIMIT 1"
- );
- $stmt->execute([
- 'company_id' => $companyId,
- 'anchor_date' => $anchorDate->format('Y-m-d'),
- ]);
- return $stmt->fetch(\PDO::FETCH_ASSOC) ?: [];
- }
- private function buildTopKpis(array $latestKpi, array $previousKpi): array
- {
- $currentSales = (float) ($latestKpi['kpi_current_sales'] ?? 0);
- $previousSales = (float) ($previousKpi['kpi_current_sales'] ?? $currentSales);
- $avgTicket = (float) ($latestKpi['kpi_avg_ticket'] ?? 0);
- $previousAvgTicket = (float) ($previousKpi['kpi_avg_ticket'] ?? $avgTicket);
- $ltvAtRisk = (float) ($latestKpi['kpi_ltv_at_risk'] ?? 0);
- $previousLtvAtRisk = (float) ($previousKpi['kpi_ltv_at_risk'] ?? $ltvAtRisk);
- $criticalClients = (int) ($latestKpi['kpi_critical_risk_clients'] ?? 0);
- $previousCriticalClients = (int) ($previousKpi['kpi_critical_risk_clients'] ?? $criticalClients);
- return [
- [
- 'title' => 'Venda Atual',
- 'value' => $this->formatCurrency($currentSales),
- 'trendLabel' => $this->formatTrendLabel($currentSales, $previousSales, 'vs ontem', false),
- 'danger' => false,
- ],
- [
- 'title' => 'Ticket Médio',
- 'value' => $this->formatCurrency($avgTicket),
- 'trendLabel' => $this->formatTrendLabel($avgTicket, $previousAvgTicket, 'vs ontem', false),
- 'danger' => false,
- ],
- [
- 'title' => 'Lifetime em Risco',
- 'value' => $this->formatCurrency($ltvAtRisk),
- 'trendLabel' => $this->formatTrendLabel($ltvAtRisk, $previousLtvAtRisk, 'vs ontem', true),
- 'danger' => $ltvAtRisk > 0,
- ],
- [
- 'title' => 'Clientes em Risco Crítico',
- 'value' => (string) $criticalClients,
- 'trendLabel' => $this->formatAbsoluteTrendLabel($criticalClients - $previousCriticalClients, 'desde ontem', true),
- 'danger' => $criticalClients > 0,
- ],
- ];
- }
- private function buildChurnDistribution(array $latestKpi): array
- {
- return [
- [
- 'label' => 'Baixo',
- 'value' => (int) round((float) ($latestKpi['kpi_churn_low_pct'] ?? 0)),
- 'color' => '#10b981',
- ],
- [
- 'label' => 'Moderado',
- 'value' => (int) round((float) ($latestKpi['kpi_churn_moderate_pct'] ?? 0)),
- 'color' => '#f59e0b',
- ],
- [
- 'label' => 'Alto',
- 'value' => (int) round((float) ($latestKpi['kpi_churn_high_pct'] ?? 0)),
- 'color' => '#f97316',
- ],
- [
- 'label' => 'Crítico',
- 'value' => (int) round((float) ($latestKpi['kpi_churn_critical_pct'] ?? 0)),
- 'color' => '#ef4444',
- ],
- ];
- }
- private function buildLtvRisk(array $latestKpi, array $previousKpi): array
- {
- $ltvTotal = (float) ($latestKpi['kpi_ltv_total'] ?? 0);
- $ltvAtRisk = (float) ($latestKpi['kpi_ltv_at_risk'] ?? 0);
- $criticalClients = (int) ($latestKpi['kpi_critical_risk_clients'] ?? 0);
- $previousCriticalClients = (int) ($previousKpi['kpi_critical_risk_clients'] ?? $criticalClients);
- $delta = $criticalClients - $previousCriticalClients;
- return [
- 'ltvTotal' => round($ltvTotal, 2),
- 'ltvAtRisk' => round($ltvAtRisk, 2),
- 'ltvRiskPct' => $ltvTotal > 0 ? (int) round(($ltvAtRisk / $ltvTotal) * 100) : 0,
- 'criticalClients' => $criticalClients,
- 'avgTicket' => (int) round((float) ($latestKpi['kpi_avg_ticket'] ?? 0)),
- 'trendText' => $this->formatCriticalTrendText($delta),
- ];
- }
- private function buildSla(int $companyId, array $latestKpi, \DateTimeImmutable $anchorDate): array
- {
- return [
- 'withinPct' => (int) round((float) ($latestKpi['kpi_sla_compliance_pct'] ?? 0)),
- 'breachPct' => max(0, 100 - (int) round((float) ($latestKpi['kpi_sla_compliance_pct'] ?? 0))),
- 'byDepartment' => $this->getSlaByDepartment($companyId, $anchorDate),
- ];
- }
- private function getSlaByDepartment(int $companyId, \DateTimeImmutable $anchorDate): array
- {
- $stmt = $this->pdo->prepare(
- "SELECT
- o.operator_department AS department,
- ROUND(AVG(ods.operator_sla_compliance_pct))::int AS value
- FROM operator_daily_stats ods
- INNER JOIN operator o
- ON o.operator_id = ods.operator_id
- AND o.operator_deleted_at = 'infinity'
- WHERE ods.company_id = :company_id
- AND ods.operator_stat_date = :anchor_date
- GROUP BY o.operator_department
- ORDER BY o.operator_department ASC"
- );
- $stmt->execute([
- 'company_id' => $companyId,
- 'anchor_date' => $anchorDate->format('Y-m-d'),
- ]);
- $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
- return array_map(static function (array $row): array {
- return [
- 'department' => $row['department'] ?? '',
- 'value' => (int) ($row['value'] ?? 0),
- ];
- }, $rows);
- }
- private function buildEmotions(array $latestEmotion): array
- {
- $totalAnalyzed = (int) ($latestEmotion['emotion_total_analyzed'] ?? 0);
- $emotionMap = [
- ['label' => 'Alegria', 'column' => 'emotion_happiness', 'color' => '#10b981'],
- ['label' => 'Confiança', 'column' => 'emotion_confidence', 'color' => '#6366f1'],
- ['label' => 'Antecipação', 'column' => 'emotion_anticipation', 'color' => '#8b5cf6'],
- ['label' => 'Surpresa', 'column' => 'emotion_surprise', 'color' => '#f59e0b'],
- ['label' => 'Medo', 'column' => 'emotion_fear', 'color' => '#f97316'],
- ['label' => 'Raiva', 'column' => 'emotion_anger', 'color' => '#ef4444'],
- ['label' => 'Tristeza', 'column' => 'emotion_sadness', 'color' => '#64748b'],
- ];
- $items = [];
- foreach ($emotionMap as $emotion) {
- $value = (float) ($latestEmotion[$emotion['column']] ?? 0);
- $items[] = [
- 'label' => $emotion['label'],
- 'value' => (int) round($value),
- 'count' => (int) round(($totalAnalyzed * $value) / 100),
- 'color' => $emotion['color'],
- ];
- }
- usort($items, static function (array $left, array $right): int {
- return $right['value'] <=> $left['value'];
- });
- return [
- 'items' => $items,
- 'avgSentimentScore' => round($this->calculateEmotionSentimentScore($latestEmotion), 2),
- ];
- }
- private function buildQuickAccess(int $companyId, array $latestKpi, array $latestEmotion, array $previousEmotion, \DateTimeImmutable $anchorDate): array
- {
- return [
- 'conversationsToday' => $this->getConversationsToday($companyId, $anchorDate),
- 'activePersonas' => $this->getActivePersonas($companyId),
- 'activeAgents' => $this->getActiveAgents($companyId, $latestKpi),
- 'activePlaybooks' => $this->getActivePlaybooks($companyId),
- 'pendingSettings' => $this->getPendingSettings($companyId),
- 'evolutionDelta' => $this->formatSignedPercent($this->calculateEmotionDeltaPercent($latestEmotion, $previousEmotion)),
- ];
- }
- private function getConversationsToday(int $companyId, \DateTimeImmutable $anchorDate): int
- {
- $stmt = $this->pdo->prepare(
- "SELECT COALESCE(SUM(operator_attendances_count), 0)
- FROM operator_daily_stats
- WHERE company_id = :company_id
- AND operator_stat_date = :anchor_date"
- );
- $stmt->execute([
- 'company_id' => $companyId,
- 'anchor_date' => $anchorDate->format('Y-m-d'),
- ]);
- return (int) $stmt->fetchColumn();
- }
- private function getActivePersonas(int $companyId): int
- {
- $stmt = $this->pdo->prepare(
- "SELECT COUNT(*)
- FROM persona
- WHERE company_id = :company_id
- AND persona_deleted_at = 'infinity'"
- );
- $stmt->execute(['company_id' => $companyId]);
- return (int) $stmt->fetchColumn();
- }
- private function getActiveAgents(int $companyId, array $latestKpi): int
- {
- $count = (int) ($latestKpi['kpi_total_active_operators'] ?? 0);
- if ($count > 0) {
- return $count;
- }
- $stmt = $this->pdo->prepare(
- "SELECT COUNT(*)
- FROM operator
- WHERE company_id = :company_id
- AND operator_deleted_at = 'infinity'
- AND lower(operator_status) <> 'inativo'"
- );
- $stmt->execute(['company_id' => $companyId]);
- return (int) $stmt->fetchColumn();
- }
- private function getActivePlaybooks(int $companyId): int
- {
- $stmt = $this->pdo->prepare(
- "SELECT COUNT(*)
- FROM ai_action
- WHERE company_id = :company_id
- AND ai_action_deleted_at = 'infinity'
- AND ai_action_created_at >= NOW() - INTERVAL '30 days'"
- );
- $stmt->execute(['company_id' => $companyId]);
- return (int) $stmt->fetchColumn();
- }
- private function getPendingSettings(int $companyId): int
- {
- $departmentsStmt = $this->pdo->prepare(
- "SELECT COUNT(DISTINCT lower(operator_department))
- FROM operator
- WHERE company_id = :company_id
- AND operator_deleted_at = 'infinity'"
- );
- $departmentsStmt->execute(['company_id' => $companyId]);
- $departmentCount = (int) $departmentsStmt->fetchColumn();
- $configsStmt = $this->pdo->prepare(
- "SELECT COUNT(DISTINCT lower(sla_config_department))
- FROM sla_config
- WHERE company_id = :company_id
- AND sla_config_deleted_at = 'infinity'"
- );
- $configsStmt->execute(['company_id' => $companyId]);
- $configCount = (int) $configsStmt->fetchColumn();
- return max(0, $departmentCount - $configCount);
- }
- private function calculateEmotionSentimentScore(array $emotionSnapshot): float
- {
- $weights = [
- 'emotion_happiness' => 0.9,
- 'emotion_confidence' => 0.6,
- 'emotion_anticipation' => 0.3,
- 'emotion_surprise' => 0.1,
- 'emotion_fear' => -0.6,
- 'emotion_anger' => -1.0,
- 'emotion_sadness' => -0.7,
- ];
- $score = 0.0;
- foreach ($weights as $column => $weight) {
- $score += ((float) ($emotionSnapshot[$column] ?? 0) / 100) * $weight;
- }
- return max(-1, min(1, $score));
- }
- private function calculateEmotionDeltaPercent(array $latestEmotion, array $previousEmotion): float
- {
- $latest = $this->calculateEmotionSentimentScore($latestEmotion);
- $previous = $this->calculateEmotionSentimentScore($previousEmotion);
- if (abs($previous) < 0.00001) {
- return $latest > 0 ? 100.0 : 0.0;
- }
- return (($latest - $previous) / abs($previous)) * 100;
- }
- private function formatCurrency(float $value): string
- {
- return 'R$ ' . number_format($value, 2, ',', '.');
- }
- private function formatTrendLabel(float $current, float $previous, string $suffix, bool $dangerOnIncrease): string
- {
- if (abs($current - $previous) < 0.00001) {
- return '→ estável';
- }
- $deltaPct = $this->calculateDeltaPercent($current, $previous);
- $arrow = $deltaPct >= 0 ? '↑' : '↓';
- $prefix = $deltaPct >= 0 ? '+' : '';
- return sprintf('%s %s%s%% %s', $arrow, $prefix, round($deltaPct), $suffix);
- }
- private function formatAbsoluteTrendLabel(int $delta, string $suffix, bool $dangerOnIncrease): string
- {
- if ($delta === 0) {
- return '→ estável';
- }
- $arrow = $delta > 0 ? '↑' : '↓';
- $prefix = $delta > 0 ? '+' : '';
- return sprintf('%s %s%d %s', $arrow, $prefix, $delta, $suffix);
- }
- private function formatCriticalTrendText(int $delta): string
- {
- if ($delta > 0) {
- return sprintf('+%d clientes entraram em risco crítico desde ontem', $delta);
- }
- if ($delta < 0) {
- return sprintf('%d clientes saíram do risco crítico desde ontem', abs($delta));
- }
- return 'Nenhuma mudança no risco crítico desde ontem';
- }
- private function formatSignedPercent(float $value): string
- {
- $rounded = round($value);
- if ($rounded > 0) {
- return '+' . $rounded . '%';
- }
- if ($rounded < 0) {
- return (string) $rounded . '%';
- }
- return '0%';
- }
- private function calculateDeltaPercent(float $current, float $previous): float
- {
- if (abs($previous) < 0.00001) {
- return $current > 0 ? 100.0 : 0.0;
- }
- return (($current - $previous) / abs($previous)) * 100;
- }
- }
|