AgentsModel.php 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578
  1. <?php
  2. namespace Models;
  3. use Libs\Database;
  4. class AgentsModel
  5. {
  6. private \PDO $pdo;
  7. public function __construct()
  8. {
  9. $this->pdo = Database::pdo();
  10. }
  11. public function getAgentsData(int $companyId, array $queryParams = []): array
  12. {
  13. $anchorDate = $this->getAnchorDate($companyId);
  14. $filters = $this->normalizeFilters($queryParams);
  15. $items = $this->getItems($companyId, $anchorDate, $filters);
  16. return [
  17. 'items' => $items,
  18. 'stats' => $this->getStats($companyId),
  19. ];
  20. }
  21. public function saveAgent(int $companyId, array $payload): ?array
  22. {
  23. $data = $this->normalizePayload($payload);
  24. if ($data === null) {
  25. return null;
  26. }
  27. if ($this->emailExists($companyId, $data['email'], $data['id'])) {
  28. return null;
  29. }
  30. if ($data['id'] !== null) {
  31. if (!$this->agentExists($companyId, $data['id'])) {
  32. return null;
  33. }
  34. $stmt = $this->pdo->prepare(
  35. "UPDATE operator
  36. SET operator_name = :name,
  37. operator_initials = :initials,
  38. operator_email = :email,
  39. operator_phone = :phone,
  40. operator_department = :department,
  41. operator_status = :status,
  42. operator_available_for_escalation = :available_for_escalation
  43. WHERE company_id = :company_id
  44. AND operator_id = :operator_id
  45. AND operator_deleted_at = 'infinity'"
  46. );
  47. $stmt->execute([
  48. 'name' => $data['name'],
  49. 'initials' => $data['initials'],
  50. 'email' => $data['email'],
  51. 'phone' => $data['phone'],
  52. 'department' => $data['department'],
  53. 'status' => $data['status'],
  54. 'available_for_escalation' => $data['available_for_escalation'],
  55. 'company_id' => $companyId,
  56. 'operator_id' => $data['id'],
  57. ]);
  58. $operatorId = $data['id'];
  59. } else {
  60. $stmt = $this->pdo->prepare(
  61. "INSERT INTO operator (
  62. company_id,
  63. operator_name,
  64. operator_initials,
  65. operator_email,
  66. operator_phone,
  67. operator_department,
  68. operator_status,
  69. operator_available_for_escalation
  70. ) VALUES (
  71. :company_id,
  72. :name,
  73. :initials,
  74. :email,
  75. :phone,
  76. :department,
  77. :status,
  78. :available_for_escalation
  79. ) RETURNING operator_id"
  80. );
  81. $stmt->execute([
  82. 'company_id' => $companyId,
  83. 'name' => $data['name'],
  84. 'initials' => $data['initials'],
  85. 'email' => $data['email'],
  86. 'phone' => $data['phone'],
  87. 'department' => $data['department'],
  88. 'status' => $data['status'],
  89. 'available_for_escalation' => $data['available_for_escalation'],
  90. ]);
  91. $operatorId = (int) $stmt->fetchColumn();
  92. }
  93. $this->replaceChannels($operatorId, $data['channels']);
  94. return $this->getAgentItemById($companyId, $operatorId, $this->getAnchorDate($companyId));
  95. }
  96. public function toggleAgentStatus(int $companyId, int $agentId): ?array
  97. {
  98. $agent = $this->getOperatorRow($companyId, $agentId);
  99. if ($agent === null) {
  100. return null;
  101. }
  102. $currentStatus = (string) ($agent['operator_status'] ?? 'Inativo');
  103. $nextStatus = $this->normalizeStatus($currentStatus) === 'Inativo' ? 'Ativo' : 'Inativo';
  104. $stmt = $this->pdo->prepare(
  105. "UPDATE operator
  106. SET operator_status = :status
  107. WHERE company_id = :company_id
  108. AND operator_id = :operator_id
  109. AND operator_deleted_at = 'infinity'"
  110. );
  111. $stmt->execute([
  112. 'status' => $nextStatus,
  113. 'company_id' => $companyId,
  114. 'operator_id' => $agentId,
  115. ]);
  116. return $this->getAgentItemById($companyId, $agentId, $this->getAnchorDate($companyId));
  117. }
  118. public function toggleAgentEscalation(int $companyId, int $agentId): ?array
  119. {
  120. $agent = $this->getOperatorRow($companyId, $agentId);
  121. if ($agent === null) {
  122. return null;
  123. }
  124. $stmt = $this->pdo->prepare(
  125. "UPDATE operator
  126. SET operator_available_for_escalation = :available_for_escalation
  127. WHERE company_id = :company_id
  128. AND operator_id = :operator_id
  129. AND operator_deleted_at = 'infinity'"
  130. );
  131. $stmt->execute([
  132. 'available_for_escalation' => !((bool) ($agent['operator_available_for_escalation'] ?? false)),
  133. 'company_id' => $companyId,
  134. 'operator_id' => $agentId,
  135. ]);
  136. return $this->getAgentItemById($companyId, $agentId, $this->getAnchorDate($companyId));
  137. }
  138. private function getAnchorDate(int $companyId): \DateTimeImmutable
  139. {
  140. $stmt = $this->pdo->prepare(
  141. "SELECT MAX(operator_stat_date)
  142. FROM operator_daily_stats
  143. WHERE company_id = :company_id"
  144. );
  145. $stmt->execute(['company_id' => $companyId]);
  146. $date = $stmt->fetchColumn();
  147. if (!is_string($date) || $date === '') {
  148. return new \DateTimeImmutable('today');
  149. }
  150. return new \DateTimeImmutable($date);
  151. }
  152. private function normalizeFilters(array $queryParams): array
  153. {
  154. $search = trim((string) ($queryParams['search'] ?? ''));
  155. $department = trim((string) ($queryParams['department'] ?? ''));
  156. $channel = mb_strtolower(trim((string) ($queryParams['channel'] ?? '')));
  157. $status = trim((string) ($queryParams['status'] ?? ''));
  158. return [
  159. 'search' => $search,
  160. 'department' => $department,
  161. 'channel' => in_array($channel, ['whatsapp', 'instagram'], true) ? $channel : '',
  162. 'status' => $status,
  163. ];
  164. }
  165. private function getItems(int $companyId, \DateTimeImmutable $anchorDate, array $filters): array
  166. {
  167. $sql = "SELECT
  168. o.operator_id,
  169. o.operator_name,
  170. o.operator_initials,
  171. o.operator_email,
  172. o.operator_department,
  173. o.operator_status,
  174. o.operator_available_for_escalation,
  175. COALESCE(curr.operator_attendances_count, 0) AS today_attendances,
  176. curr.operator_avg_response_seconds AS avg_response_seconds,
  177. prev.operator_avg_response_seconds AS prev_avg_response_seconds,
  178. COALESCE(curr.operator_sla_compliance_pct, 0) AS sla_pct,
  179. COALESCE(string_agg(DISTINCT lower(oc.operator_channel), ',' ORDER BY lower(oc.operator_channel)), '') AS channels
  180. FROM operator o
  181. LEFT JOIN operator_daily_stats curr
  182. ON curr.operator_id = o.operator_id
  183. AND curr.operator_stat_date = :anchor_date
  184. LEFT JOIN operator_daily_stats prev
  185. ON prev.operator_id = o.operator_id
  186. AND prev.operator_stat_date = :previous_date
  187. LEFT JOIN operator_channel oc
  188. ON oc.operator_id = o.operator_id
  189. AND oc.operator_channel_deleted_at = 'infinity'
  190. WHERE o.company_id = :company_id
  191. AND o.operator_deleted_at = 'infinity'";
  192. $params = [
  193. 'company_id' => $companyId,
  194. 'anchor_date' => $anchorDate->format('Y-m-d'),
  195. 'previous_date' => $anchorDate->modify('-1 day')->format('Y-m-d'),
  196. ];
  197. if ($filters['search'] !== '') {
  198. $sql .= ' AND (o.operator_name ILIKE :search OR o.operator_email ILIKE :search)';
  199. $params['search'] = '%' . $filters['search'] . '%';
  200. }
  201. if ($filters['department'] !== '') {
  202. $sql .= ' AND o.operator_department = :department';
  203. $params['department'] = $filters['department'];
  204. }
  205. if ($filters['status'] !== '') {
  206. if ($filters['status'] === 'Disponível para Escalonamento') {
  207. $sql .= ' AND o.operator_available_for_escalation = TRUE';
  208. } else {
  209. $sql .= ' AND o.operator_status = :status';
  210. $params['status'] = $filters['status'];
  211. }
  212. }
  213. if ($filters['channel'] !== '') {
  214. $sql .= "
  215. AND EXISTS (
  216. SELECT 1
  217. FROM operator_channel oc_filter
  218. WHERE oc_filter.operator_id = o.operator_id
  219. AND oc_filter.operator_channel_deleted_at = 'infinity'
  220. AND lower(oc_filter.operator_channel) = :channel
  221. )";
  222. $params['channel'] = $filters['channel'];
  223. }
  224. $sql .= " GROUP BY
  225. o.operator_id,
  226. curr.operator_attendances_count,
  227. curr.operator_avg_response_seconds,
  228. prev.operator_avg_response_seconds,
  229. curr.operator_sla_compliance_pct";
  230. $sql .= ' ORDER BY o.operator_name ASC, o.operator_id ASC';
  231. $stmt = $this->pdo->prepare($sql);
  232. $stmt->execute($params);
  233. $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
  234. $items = [];
  235. foreach ($rows as $row) {
  236. $items[] = $this->formatAgentItem($row, $this->splitChannels((string) ($row['channels'] ?? '')));
  237. }
  238. return $items;
  239. }
  240. private function getStats(int $companyId): array
  241. {
  242. $stmt = $this->pdo->prepare(
  243. "SELECT
  244. COUNT(*) AS total,
  245. COUNT(*) FILTER (
  246. WHERE operator_status IN ('Ativo', 'Em Atendimento', 'Disponível')
  247. ) AS active,
  248. COUNT(*) FILTER (
  249. WHERE operator_status = 'Em Atendimento'
  250. ) AS in_attendance,
  251. COUNT(*) FILTER (
  252. WHERE operator_available_for_escalation = TRUE
  253. ) AS available_for_escalation
  254. FROM operator
  255. WHERE company_id = :company_id
  256. AND operator_deleted_at = 'infinity'"
  257. );
  258. $stmt->execute(['company_id' => $companyId]);
  259. $row = $stmt->fetch(\PDO::FETCH_ASSOC) ?: [];
  260. return [
  261. 'total' => (int) ($row['total'] ?? 0),
  262. 'active' => (int) ($row['active'] ?? 0),
  263. 'inAttendance' => (int) ($row['in_attendance'] ?? 0),
  264. 'availableForEscalation' => (int) ($row['available_for_escalation'] ?? 0),
  265. ];
  266. }
  267. /**
  268. * Converte a lista de canais agregada pelo banco (string separada por vírgula)
  269. * em array de canais únicos. Os canais já vêm em minúsculas do string_agg.
  270. */
  271. private function splitChannels(string $raw): array
  272. {
  273. if ($raw === '') {
  274. return [];
  275. }
  276. $channels = array_filter(
  277. array_map('trim', explode(',', $raw)),
  278. static fn(string $channel): bool => $channel !== ''
  279. );
  280. return array_values(array_unique($channels));
  281. }
  282. private function formatAgentItem(array $row, array $channels): array
  283. {
  284. $currentResponseSeconds = isset($row['avg_response_seconds']) ? (int) $row['avg_response_seconds'] : null;
  285. $previousResponseSeconds = isset($row['prev_avg_response_seconds']) ? (int) $row['prev_avg_response_seconds'] : null;
  286. return [
  287. 'id' => (int) ($row['operator_id'] ?? 0),
  288. 'name' => $row['operator_name'] ?? '',
  289. 'email' => $row['operator_email'] ?? '',
  290. 'initials' => $row['operator_initials'] ?? '',
  291. 'department' => $row['operator_department'] ?? '',
  292. 'channels' => array_values(array_unique($channels)),
  293. 'status' => $this->normalizeStatus((string) ($row['operator_status'] ?? 'Inativo')),
  294. 'availableForEscalation' => (bool) ($row['operator_available_for_escalation'] ?? false),
  295. 'todayAttendances' => (int) ($row['today_attendances'] ?? 0),
  296. 'avgResponseTime' => $this->formatDuration($currentResponseSeconds),
  297. 'responseTimeTrend' => $this->resolveResponseTimeTrend($currentResponseSeconds, $previousResponseSeconds),
  298. 'slaPct' => (int) round((float) ($row['sla_pct'] ?? 0)),
  299. ];
  300. }
  301. private function normalizePayload(array $payload): ?array
  302. {
  303. $id = isset($payload['id']) ? (int) $payload['id'] : null;
  304. if ($id !== null && $id <= 0) {
  305. $id = null;
  306. }
  307. $name = trim((string) ($payload['name'] ?? ''));
  308. $email = mb_strtolower(trim((string) ($payload['email'] ?? '')));
  309. $department = trim((string) ($payload['department'] ?? ''));
  310. $status = $this->normalizeStatus((string) ($payload['status'] ?? 'Ativo'));
  311. $availableForEscalation = (bool) ($payload['availableForEscalation'] ?? false);
  312. $channels = array_values(array_filter(array_map(static function ($channel): string {
  313. return mb_strtolower(trim((string) $channel));
  314. }, is_array($payload['channels'] ?? null) ? $payload['channels'] : []), static function (string $channel): bool {
  315. return in_array($channel, ['whatsapp', 'instagram'], true);
  316. }));
  317. if ($name === '' || $email === '' || $department === '') {
  318. return null;
  319. }
  320. return [
  321. 'id' => $id,
  322. 'name' => $name,
  323. 'email' => $email,
  324. 'department' => $department,
  325. 'status' => $status,
  326. 'available_for_escalation' => $availableForEscalation,
  327. 'channels' => array_values(array_unique($channels)),
  328. 'initials' => $this->buildInitials($name),
  329. 'phone' => trim((string) ($payload['phone'] ?? '')),
  330. ];
  331. }
  332. private function emailExists(int $companyId, string $email, ?int $ignoreId): bool
  333. {
  334. $sql = "SELECT 1
  335. FROM operator
  336. WHERE company_id = :company_id
  337. AND operator_deleted_at = 'infinity'
  338. AND lower(operator_email) = :email";
  339. $params = [
  340. 'company_id' => $companyId,
  341. 'email' => $email,
  342. ];
  343. if ($ignoreId !== null) {
  344. $sql .= ' AND operator_id <> :ignore_id';
  345. $params['ignore_id'] = $ignoreId;
  346. }
  347. $sql .= ' LIMIT 1';
  348. $stmt = $this->pdo->prepare($sql);
  349. $stmt->execute($params);
  350. return (bool) $stmt->fetchColumn();
  351. }
  352. private function agentExists(int $companyId, int $agentId): bool
  353. {
  354. $stmt = $this->pdo->prepare(
  355. "SELECT 1
  356. FROM operator
  357. WHERE company_id = :company_id
  358. AND operator_id = :operator_id
  359. AND operator_deleted_at = 'infinity'
  360. LIMIT 1"
  361. );
  362. $stmt->execute([
  363. 'company_id' => $companyId,
  364. 'operator_id' => $agentId,
  365. ]);
  366. return (bool) $stmt->fetchColumn();
  367. }
  368. private function replaceChannels(int $operatorId, array $channels): void
  369. {
  370. $stmt = $this->pdo->prepare(
  371. "UPDATE operator_channel
  372. SET operator_channel_deleted_at = NOW()
  373. WHERE operator_id = :operator_id
  374. AND operator_channel_deleted_at = 'infinity'"
  375. );
  376. $stmt->execute(['operator_id' => $operatorId]);
  377. if ($channels === []) {
  378. return;
  379. }
  380. $insert = $this->pdo->prepare(
  381. "INSERT INTO operator_channel (operator_id, operator_channel)
  382. VALUES (:operator_id, :channel)"
  383. );
  384. foreach ($channels as $channel) {
  385. $insert->execute([
  386. 'operator_id' => $operatorId,
  387. 'channel' => $channel,
  388. ]);
  389. }
  390. }
  391. private function getOperatorRow(int $companyId, int $agentId): ?array
  392. {
  393. $stmt = $this->pdo->prepare(
  394. "SELECT *
  395. FROM operator
  396. WHERE company_id = :company_id
  397. AND operator_id = :operator_id
  398. AND operator_deleted_at = 'infinity'
  399. LIMIT 1"
  400. );
  401. $stmt->execute([
  402. 'company_id' => $companyId,
  403. 'operator_id' => $agentId,
  404. ]);
  405. $row = $stmt->fetch(\PDO::FETCH_ASSOC);
  406. return $row === false ? null : $row;
  407. }
  408. private function getAgentItemById(int $companyId, int $agentId, \DateTimeImmutable $anchorDate): ?array
  409. {
  410. $sql = "SELECT
  411. o.operator_id,
  412. o.operator_name,
  413. o.operator_initials,
  414. o.operator_email,
  415. o.operator_department,
  416. o.operator_status,
  417. o.operator_available_for_escalation,
  418. COALESCE(curr.operator_attendances_count, 0) AS today_attendances,
  419. curr.operator_avg_response_seconds AS avg_response_seconds,
  420. prev.operator_avg_response_seconds AS prev_avg_response_seconds,
  421. COALESCE(curr.operator_sla_compliance_pct, 0) AS sla_pct,
  422. COALESCE(string_agg(DISTINCT lower(oc.operator_channel), ',' ORDER BY lower(oc.operator_channel)), '') AS channels
  423. FROM operator o
  424. LEFT JOIN operator_daily_stats curr
  425. ON curr.operator_id = o.operator_id
  426. AND curr.operator_stat_date = :anchor_date
  427. LEFT JOIN operator_daily_stats prev
  428. ON prev.operator_id = o.operator_id
  429. AND prev.operator_stat_date = :previous_date
  430. LEFT JOIN operator_channel oc
  431. ON oc.operator_id = o.operator_id
  432. AND oc.operator_channel_deleted_at = 'infinity'
  433. WHERE o.company_id = :company_id
  434. AND o.operator_id = :operator_id
  435. AND o.operator_deleted_at = 'infinity'
  436. GROUP BY
  437. o.operator_id,
  438. curr.operator_attendances_count,
  439. curr.operator_avg_response_seconds,
  440. prev.operator_avg_response_seconds,
  441. curr.operator_sla_compliance_pct
  442. LIMIT 1";
  443. $stmt = $this->pdo->prepare($sql);
  444. $stmt->execute([
  445. 'company_id' => $companyId,
  446. 'operator_id' => $agentId,
  447. 'anchor_date' => $anchorDate->format('Y-m-d'),
  448. 'previous_date' => $anchorDate->modify('-1 day')->format('Y-m-d'),
  449. ]);
  450. $row = $stmt->fetch(\PDO::FETCH_ASSOC);
  451. if ($row === false) {
  452. return null;
  453. }
  454. return $this->formatAgentItem($row, $this->splitChannels((string) ($row['channels'] ?? '')));
  455. }
  456. private function buildInitials(string $name): string
  457. {
  458. $parts = preg_split('/\s+/', trim($name)) ?: [];
  459. $letters = array_slice(array_filter($parts), 0, 2);
  460. $initials = '';
  461. foreach ($letters as $part) {
  462. $initials .= mb_strtoupper(mb_substr($part, 0, 1));
  463. }
  464. return $initials !== '' ? $initials : 'AG';
  465. }
  466. private function normalizeStatus(string $status): string
  467. {
  468. $normalized = mb_strtolower(trim($status));
  469. if (in_array($normalized, ['ativo', 'active'], true)) {
  470. return 'Ativo';
  471. }
  472. if (in_array($normalized, ['em atendimento', 'attending'], true)) {
  473. return 'Em Atendimento';
  474. }
  475. if (in_array($normalized, ['disponível', 'disponivel', 'available'], true)) {
  476. return 'Disponível';
  477. }
  478. return 'Inativo';
  479. }
  480. private function formatDuration(?int $seconds): string
  481. {
  482. if ($seconds === null || $seconds <= 0) {
  483. return '—';
  484. }
  485. $minutes = intdiv($seconds, 60);
  486. $remainingSeconds = $seconds % 60;
  487. return sprintf('%dm %02ds', $minutes, $remainingSeconds);
  488. }
  489. private function resolveResponseTimeTrend(?int $currentSeconds, ?int $previousSeconds): string
  490. {
  491. if ($currentSeconds === null || $currentSeconds <= 0 || $previousSeconds === null || $previousSeconds <= 0) {
  492. return 'stable';
  493. }
  494. if ($currentSeconds < $previousSeconds) {
  495. return 'down';
  496. }
  497. if ($currentSeconds > $previousSeconds) {
  498. return 'up';
  499. }
  500. return 'stable';
  501. }
  502. }