AgentsModel.php 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579
  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. FROM operator o
  180. LEFT JOIN operator_daily_stats curr
  181. ON curr.operator_id = o.operator_id
  182. AND curr.operator_stat_date = :anchor_date
  183. LEFT JOIN operator_daily_stats prev
  184. ON prev.operator_id = o.operator_id
  185. AND prev.operator_stat_date = :previous_date
  186. WHERE o.company_id = :company_id
  187. AND o.operator_deleted_at = 'infinity'";
  188. $params = [
  189. 'company_id' => $companyId,
  190. 'anchor_date' => $anchorDate->format('Y-m-d'),
  191. 'previous_date' => $anchorDate->modify('-1 day')->format('Y-m-d'),
  192. ];
  193. if ($filters['search'] !== '') {
  194. $sql .= ' AND (o.operator_name ILIKE :search OR o.operator_email ILIKE :search)';
  195. $params['search'] = '%' . $filters['search'] . '%';
  196. }
  197. if ($filters['department'] !== '') {
  198. $sql .= ' AND o.operator_department = :department';
  199. $params['department'] = $filters['department'];
  200. }
  201. if ($filters['status'] !== '') {
  202. if ($filters['status'] === 'Disponível para Escalonamento') {
  203. $sql .= ' AND o.operator_available_for_escalation = TRUE';
  204. } else {
  205. $sql .= ' AND o.operator_status = :status';
  206. $params['status'] = $filters['status'];
  207. }
  208. }
  209. if ($filters['channel'] !== '') {
  210. $sql .= "
  211. AND EXISTS (
  212. SELECT 1
  213. FROM operator_channel oc_filter
  214. WHERE oc_filter.operator_id = o.operator_id
  215. AND oc_filter.operator_channel_deleted_at = 'infinity'
  216. AND lower(oc_filter.operator_channel) = :channel
  217. )";
  218. $params['channel'] = $filters['channel'];
  219. }
  220. $sql .= ' ORDER BY o.operator_name ASC, o.operator_id ASC';
  221. $stmt = $this->pdo->prepare($sql);
  222. $stmt->execute($params);
  223. $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
  224. $channelsByOperator = $this->getChannelsByOperator($companyId);
  225. $items = [];
  226. foreach ($rows as $row) {
  227. $operatorId = (int) ($row['operator_id'] ?? 0);
  228. $items[] = $this->formatAgentItem($row, $channelsByOperator[$operatorId] ?? []);
  229. }
  230. return $items;
  231. }
  232. private function getStats(int $companyId): array
  233. {
  234. $stmt = $this->pdo->prepare(
  235. "SELECT
  236. COUNT(*) AS total,
  237. COUNT(*) FILTER (
  238. WHERE operator_status IN ('Ativo', 'Em Atendimento', 'Disponível')
  239. ) AS active,
  240. COUNT(*) FILTER (
  241. WHERE operator_status = 'Em Atendimento'
  242. ) AS in_attendance,
  243. COUNT(*) FILTER (
  244. WHERE operator_available_for_escalation = TRUE
  245. ) AS available_for_escalation
  246. FROM operator
  247. WHERE company_id = :company_id
  248. AND operator_deleted_at = 'infinity'"
  249. );
  250. $stmt->execute(['company_id' => $companyId]);
  251. $row = $stmt->fetch(\PDO::FETCH_ASSOC) ?: [];
  252. return [
  253. 'total' => (int) ($row['total'] ?? 0),
  254. 'active' => (int) ($row['active'] ?? 0),
  255. 'inAttendance' => (int) ($row['in_attendance'] ?? 0),
  256. 'availableForEscalation' => (int) ($row['available_for_escalation'] ?? 0),
  257. ];
  258. }
  259. private function getChannelsByOperator(int $companyId): array
  260. {
  261. $stmt = $this->pdo->prepare(
  262. "SELECT
  263. o.operator_id,
  264. oc.operator_channel
  265. FROM operator o
  266. LEFT JOIN operator_channel oc
  267. ON oc.operator_id = o.operator_id
  268. AND oc.operator_channel_deleted_at = 'infinity'
  269. WHERE o.company_id = :company_id
  270. AND o.operator_deleted_at = 'infinity'
  271. ORDER BY o.operator_id ASC, oc.operator_channel ASC"
  272. );
  273. $stmt->execute(['company_id' => $companyId]);
  274. $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
  275. $grouped = [];
  276. foreach ($rows as $row) {
  277. $operatorId = (int) ($row['operator_id'] ?? 0);
  278. $channel = trim((string) ($row['operator_channel'] ?? ''));
  279. if ($operatorId <= 0 || $channel === '') {
  280. continue;
  281. }
  282. if (!isset($grouped[$operatorId])) {
  283. $grouped[$operatorId] = [];
  284. }
  285. $grouped[$operatorId][] = mb_strtolower($channel);
  286. }
  287. return $grouped;
  288. }
  289. private function formatAgentItem(array $row, array $channels): array
  290. {
  291. $currentResponseSeconds = isset($row['avg_response_seconds']) ? (int) $row['avg_response_seconds'] : null;
  292. $previousResponseSeconds = isset($row['prev_avg_response_seconds']) ? (int) $row['prev_avg_response_seconds'] : null;
  293. return [
  294. 'id' => (int) ($row['operator_id'] ?? 0),
  295. 'name' => $row['operator_name'] ?? '',
  296. 'email' => $row['operator_email'] ?? '',
  297. 'initials' => $row['operator_initials'] ?? '',
  298. 'department' => $row['operator_department'] ?? '',
  299. 'channels' => array_values(array_unique($channels)),
  300. 'status' => $this->normalizeStatus((string) ($row['operator_status'] ?? 'Inativo')),
  301. 'availableForEscalation' => (bool) ($row['operator_available_for_escalation'] ?? false),
  302. 'todayAttendances' => (int) ($row['today_attendances'] ?? 0),
  303. 'avgResponseTime' => $this->formatDuration($currentResponseSeconds),
  304. 'responseTimeTrend' => $this->resolveResponseTimeTrend($currentResponseSeconds, $previousResponseSeconds),
  305. 'slaPct' => (int) round((float) ($row['sla_pct'] ?? 0)),
  306. ];
  307. }
  308. private function normalizePayload(array $payload): ?array
  309. {
  310. $id = isset($payload['id']) ? (int) $payload['id'] : null;
  311. if ($id !== null && $id <= 0) {
  312. $id = null;
  313. }
  314. $name = trim((string) ($payload['name'] ?? ''));
  315. $email = mb_strtolower(trim((string) ($payload['email'] ?? '')));
  316. $department = trim((string) ($payload['department'] ?? ''));
  317. $status = $this->normalizeStatus((string) ($payload['status'] ?? 'Ativo'));
  318. $availableForEscalation = (bool) ($payload['availableForEscalation'] ?? false);
  319. $channels = array_values(array_filter(array_map(static function ($channel): string {
  320. return mb_strtolower(trim((string) $channel));
  321. }, is_array($payload['channels'] ?? null) ? $payload['channels'] : []), static function (string $channel): bool {
  322. return in_array($channel, ['whatsapp', 'instagram'], true);
  323. }));
  324. if ($name === '' || $email === '' || $department === '') {
  325. return null;
  326. }
  327. return [
  328. 'id' => $id,
  329. 'name' => $name,
  330. 'email' => $email,
  331. 'department' => $department,
  332. 'status' => $status,
  333. 'available_for_escalation' => $availableForEscalation,
  334. 'channels' => array_values(array_unique($channels)),
  335. 'initials' => $this->buildInitials($name),
  336. 'phone' => trim((string) ($payload['phone'] ?? '')),
  337. ];
  338. }
  339. private function emailExists(int $companyId, string $email, ?int $ignoreId): bool
  340. {
  341. $sql = "SELECT 1
  342. FROM operator
  343. WHERE company_id = :company_id
  344. AND operator_deleted_at = 'infinity'
  345. AND lower(operator_email) = :email";
  346. $params = [
  347. 'company_id' => $companyId,
  348. 'email' => $email,
  349. ];
  350. if ($ignoreId !== null) {
  351. $sql .= ' AND operator_id <> :ignore_id';
  352. $params['ignore_id'] = $ignoreId;
  353. }
  354. $sql .= ' LIMIT 1';
  355. $stmt = $this->pdo->prepare($sql);
  356. $stmt->execute($params);
  357. return (bool) $stmt->fetchColumn();
  358. }
  359. private function agentExists(int $companyId, int $agentId): bool
  360. {
  361. $stmt = $this->pdo->prepare(
  362. "SELECT 1
  363. FROM operator
  364. WHERE company_id = :company_id
  365. AND operator_id = :operator_id
  366. AND operator_deleted_at = 'infinity'
  367. LIMIT 1"
  368. );
  369. $stmt->execute([
  370. 'company_id' => $companyId,
  371. 'operator_id' => $agentId,
  372. ]);
  373. return (bool) $stmt->fetchColumn();
  374. }
  375. private function replaceChannels(int $operatorId, array $channels): void
  376. {
  377. $stmt = $this->pdo->prepare(
  378. "UPDATE operator_channel
  379. SET operator_channel_deleted_at = NOW()
  380. WHERE operator_id = :operator_id
  381. AND operator_channel_deleted_at = 'infinity'"
  382. );
  383. $stmt->execute(['operator_id' => $operatorId]);
  384. if ($channels === []) {
  385. return;
  386. }
  387. $insert = $this->pdo->prepare(
  388. "INSERT INTO operator_channel (operator_id, operator_channel)
  389. VALUES (:operator_id, :channel)"
  390. );
  391. foreach ($channels as $channel) {
  392. $insert->execute([
  393. 'operator_id' => $operatorId,
  394. 'channel' => $channel,
  395. ]);
  396. }
  397. }
  398. private function getOperatorRow(int $companyId, int $agentId): ?array
  399. {
  400. $stmt = $this->pdo->prepare(
  401. "SELECT *
  402. FROM operator
  403. WHERE company_id = :company_id
  404. AND operator_id = :operator_id
  405. AND operator_deleted_at = 'infinity'
  406. LIMIT 1"
  407. );
  408. $stmt->execute([
  409. 'company_id' => $companyId,
  410. 'operator_id' => $agentId,
  411. ]);
  412. $row = $stmt->fetch(\PDO::FETCH_ASSOC);
  413. return $row === false ? null : $row;
  414. }
  415. private function getAgentItemById(int $companyId, int $agentId, \DateTimeImmutable $anchorDate): ?array
  416. {
  417. $sql = "SELECT
  418. o.operator_id,
  419. o.operator_name,
  420. o.operator_initials,
  421. o.operator_email,
  422. o.operator_department,
  423. o.operator_status,
  424. o.operator_available_for_escalation,
  425. COALESCE(curr.operator_attendances_count, 0) AS today_attendances,
  426. curr.operator_avg_response_seconds AS avg_response_seconds,
  427. prev.operator_avg_response_seconds AS prev_avg_response_seconds,
  428. COALESCE(curr.operator_sla_compliance_pct, 0) AS sla_pct
  429. FROM operator o
  430. LEFT JOIN operator_daily_stats curr
  431. ON curr.operator_id = o.operator_id
  432. AND curr.operator_stat_date = :anchor_date
  433. LEFT JOIN operator_daily_stats prev
  434. ON prev.operator_id = o.operator_id
  435. AND prev.operator_stat_date = :previous_date
  436. WHERE o.company_id = :company_id
  437. AND o.operator_id = :operator_id
  438. AND o.operator_deleted_at = 'infinity'
  439. LIMIT 1";
  440. $stmt = $this->pdo->prepare($sql);
  441. $stmt->execute([
  442. 'company_id' => $companyId,
  443. 'operator_id' => $agentId,
  444. 'anchor_date' => $anchorDate->format('Y-m-d'),
  445. 'previous_date' => $anchorDate->modify('-1 day')->format('Y-m-d'),
  446. ]);
  447. $row = $stmt->fetch(\PDO::FETCH_ASSOC);
  448. if ($row === false) {
  449. return null;
  450. }
  451. $channelsByOperator = $this->getChannelsByOperator($companyId);
  452. return $this->formatAgentItem($row, $channelsByOperator[$agentId] ?? []);
  453. }
  454. private function buildInitials(string $name): string
  455. {
  456. $parts = preg_split('/\s+/', trim($name)) ?: [];
  457. $letters = array_slice(array_filter($parts), 0, 2);
  458. $initials = '';
  459. foreach ($letters as $part) {
  460. $initials .= mb_strtoupper(mb_substr($part, 0, 1));
  461. }
  462. return $initials !== '' ? $initials : 'AG';
  463. }
  464. private function normalizeStatus(string $status): string
  465. {
  466. $normalized = mb_strtolower(trim($status));
  467. if (in_array($normalized, ['ativo', 'active'], true)) {
  468. return 'Ativo';
  469. }
  470. if (in_array($normalized, ['em atendimento', 'attending'], true)) {
  471. return 'Em Atendimento';
  472. }
  473. if (in_array($normalized, ['disponível', 'disponivel', 'available'], true)) {
  474. return 'Disponível';
  475. }
  476. return 'Inativo';
  477. }
  478. private function formatDuration(?int $seconds): string
  479. {
  480. if ($seconds === null || $seconds <= 0) {
  481. return '—';
  482. }
  483. $minutes = intdiv($seconds, 60);
  484. $remainingSeconds = $seconds % 60;
  485. return sprintf('%dm %02ds', $minutes, $remainingSeconds);
  486. }
  487. private function resolveResponseTimeTrend(?int $currentSeconds, ?int $previousSeconds): string
  488. {
  489. if ($currentSeconds === null || $currentSeconds <= 0 || $previousSeconds === null || $previousSeconds <= 0) {
  490. return 'stable';
  491. }
  492. if ($currentSeconds < $previousSeconds) {
  493. return 'down';
  494. }
  495. if ($currentSeconds > $previousSeconds) {
  496. return 'up';
  497. }
  498. return 'stable';
  499. }
  500. }