SlaConfigsModel.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  1. <?php
  2. namespace Models;
  3. use Libs\Database;
  4. class SlaConfigsModel
  5. {
  6. private const DEFAULT_ALERT_PERCENT = 80;
  7. private \PDO $pdo;
  8. public function __construct()
  9. {
  10. $this->pdo = Database::pdo();
  11. }
  12. public function getConfigs(int $companyId): array
  13. {
  14. $configs = $this->fetchConfigs($companyId);
  15. if ($configs === []) {
  16. return ['items' => []];
  17. }
  18. $liveDataByDepartment = $this->getLiveDataByDepartment($companyId);
  19. $items = [];
  20. foreach ($configs as $config) {
  21. $departmentName = trim((string) ($config['sla_config_department'] ?? ''));
  22. $departmentKey = mb_strtolower($departmentName);
  23. $liveData = $liveDataByDepartment[$departmentKey] ?? null;
  24. $items[] = [
  25. 'id' => $this->slugify($departmentName),
  26. 'name' => $departmentName,
  27. 'firstResponseH' => (int) ($config['sla_config_response_hours'] ?? 0),
  28. 'firstResponseM' => 0,
  29. 'resolutionH' => (int) ($config['sla_config_resolution_hours'] ?? 0),
  30. 'alertPct' => self::DEFAULT_ALERT_PERCENT,
  31. 'liveStatus' => $this->resolveLiveStatus($liveData),
  32. 'liveDetail' => $this->resolveLiveDetail($liveData),
  33. 'lastUpdated' => $this->formatRelativeTime($config['sla_config_updated_at'] ?? null),
  34. ];
  35. }
  36. return ['items' => $items];
  37. }
  38. public function saveConfig(int $companyId, array $payload): array
  39. {
  40. $id = trim((string) ($payload['id'] ?? ''));
  41. $name = trim((string) ($payload['name'] ?? ''));
  42. $firstResponseHours = (int) ($payload['firstResponseH'] ?? 0);
  43. $firstResponseMinutes = (int) ($payload['firstResponseM'] ?? 0);
  44. $resolutionHours = (int) ($payload['resolutionH'] ?? 0);
  45. $existingConfig = $id !== '' ? $this->findConfigBySlugId($companyId, $id) : null;
  46. if ($id !== '' && $existingConfig === null) {
  47. return ['status' => 'not_found'];
  48. }
  49. $departmentName = $name !== ''
  50. ? $name
  51. : (string) ($existingConfig['sla_config_department'] ?? '');
  52. if ($departmentName === '') {
  53. return ['status' => 'invalid'];
  54. }
  55. $responseHoursToPersist = $this->normalizeResponseHoursToPersist($firstResponseHours, $firstResponseMinutes);
  56. try {
  57. if ($existingConfig !== null) {
  58. $stmt = $this->pdo->prepare(
  59. "UPDATE sla_config
  60. SET sla_config_department = :department,
  61. sla_config_response_hours = :response_hours,
  62. sla_config_resolution_hours = :resolution_hours,
  63. sla_config_updated_at = NOW()
  64. WHERE company_id = :company_id
  65. AND sla_config_department = :current_department
  66. AND sla_config_deleted_at = 'infinity'"
  67. );
  68. $stmt->execute([
  69. 'department' => $departmentName,
  70. 'response_hours' => $responseHoursToPersist,
  71. 'resolution_hours' => $resolutionHours,
  72. 'company_id' => $companyId,
  73. 'current_department' => $existingConfig['sla_config_department'],
  74. ]);
  75. $status = 'updated';
  76. } else {
  77. $stmt = $this->pdo->prepare(
  78. "INSERT INTO sla_config (
  79. company_id,
  80. sla_config_department,
  81. sla_config_response_hours,
  82. sla_config_resolution_hours
  83. ) VALUES (
  84. :company_id,
  85. :department,
  86. :response_hours,
  87. :resolution_hours
  88. )"
  89. );
  90. $stmt->execute([
  91. 'company_id' => $companyId,
  92. 'department' => $departmentName,
  93. 'response_hours' => $responseHoursToPersist,
  94. 'resolution_hours' => $resolutionHours,
  95. ]);
  96. $status = 'created';
  97. }
  98. } catch (\PDOException $e) {
  99. if ($e->getCode() === '23505') {
  100. return ['status' => 'duplicate'];
  101. }
  102. return ['status' => 'error'];
  103. }
  104. $savedItem = $this->getConfigItemByDepartmentName($companyId, $departmentName);
  105. if ($savedItem === null) {
  106. return ['status' => 'error'];
  107. }
  108. return [
  109. 'status' => $status,
  110. 'item' => $savedItem,
  111. ];
  112. }
  113. public function getLiveStatus(int $companyId): array
  114. {
  115. $configItems = $this->getConfigs($companyId)['items'] ?? [];
  116. $items = [];
  117. foreach ($configItems as $item) {
  118. $items[] = [
  119. 'id' => $item['id'] ?? '',
  120. 'name' => $item['name'] ?? '',
  121. 'liveStatus' => $item['liveStatus'] ?? 'ok',
  122. 'liveDetail' => $item['liveDetail'] ?? 'dentro do SLA',
  123. 'lastUpdated' => $item['lastUpdated'] ?? 'agora',
  124. ];
  125. }
  126. return ['items' => $items];
  127. }
  128. private function fetchConfigs(int $companyId): array
  129. {
  130. $stmt = $this->pdo->prepare(
  131. "SELECT
  132. sla_config_department,
  133. sla_config_response_hours,
  134. sla_config_resolution_hours,
  135. sla_config_updated_at
  136. FROM sla_config
  137. WHERE company_id = :company_id
  138. AND sla_config_deleted_at = 'infinity'
  139. ORDER BY sla_config_department ASC"
  140. );
  141. $stmt->execute(['company_id' => $companyId]);
  142. return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
  143. }
  144. private function findConfigBySlugId(int $companyId, string $id): ?array
  145. {
  146. $configs = $this->fetchConfigs($companyId);
  147. foreach ($configs as $config) {
  148. $departmentName = trim((string) ($config['sla_config_department'] ?? ''));
  149. if ($departmentName === '') {
  150. continue;
  151. }
  152. if ($this->slugify($departmentName) === $id) {
  153. return $config;
  154. }
  155. }
  156. return null;
  157. }
  158. private function getConfigItemByDepartmentName(int $companyId, string $departmentName): ?array
  159. {
  160. $items = $this->getConfigs($companyId)['items'] ?? [];
  161. foreach ($items as $item) {
  162. if (($item['name'] ?? '') === $departmentName) {
  163. return $item;
  164. }
  165. }
  166. return null;
  167. }
  168. private function normalizeResponseHoursToPersist(int $hours, int $minutes): int
  169. {
  170. $totalMinutes = max(0, ($hours * 60) + $minutes);
  171. if ($totalMinutes === 0) {
  172. return 0;
  173. }
  174. return (int) ceil($totalMinutes / 60);
  175. }
  176. private function getLiveDataByDepartment(int $companyId): array
  177. {
  178. $stmt = $this->pdo->prepare(
  179. "SELECT
  180. lower(trim(o.operator_department)) AS department_key,
  181. MAX(
  182. CASE
  183. WHEN c.conversation_id IS NOT NULL
  184. AND c.conversation_sla_deadline < NOW()
  185. THEN EXTRACT(EPOCH FROM (NOW() - c.conversation_sla_deadline))
  186. ELSE NULL
  187. END
  188. ) AS max_overdue_seconds,
  189. MAX(
  190. CASE
  191. WHEN c.conversation_id IS NOT NULL
  192. AND c.conversation_sla_deadline >= NOW()
  193. AND EXTRACT(EPOCH FROM (c.conversation_sla_deadline - c.conversation_started_at)) > 0
  194. THEN (
  195. EXTRACT(EPOCH FROM (NOW() - c.conversation_started_at))
  196. / EXTRACT(EPOCH FROM (c.conversation_sla_deadline - c.conversation_started_at))
  197. ) * 100
  198. ELSE NULL
  199. END
  200. ) AS max_progress_pct
  201. FROM operator o
  202. LEFT JOIN conversation c
  203. ON c.operator_id = o.operator_id
  204. AND c.company_id = :company_id
  205. AND c.conversation_deleted_at = 'infinity'
  206. AND c.conversation_status = 'open'
  207. WHERE o.company_id = :company_id
  208. AND o.operator_deleted_at = 'infinity'
  209. GROUP BY lower(trim(o.operator_department))"
  210. );
  211. $stmt->execute(['company_id' => $companyId]);
  212. $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
  213. $grouped = [];
  214. foreach ($rows as $row) {
  215. $key = (string) ($row['department_key'] ?? '');
  216. if ($key === '') {
  217. continue;
  218. }
  219. $grouped[$key] = [
  220. 'max_overdue_seconds' => $row['max_overdue_seconds'] !== null ? (float) $row['max_overdue_seconds'] : null,
  221. 'max_progress_pct' => $row['max_progress_pct'] !== null ? (float) $row['max_progress_pct'] : null,
  222. ];
  223. }
  224. return $grouped;
  225. }
  226. private function resolveLiveStatus(?array $liveData): string
  227. {
  228. $maxOverdueSeconds = $liveData['max_overdue_seconds'] ?? null;
  229. if ($maxOverdueSeconds !== null && $maxOverdueSeconds > 0) {
  230. return 'breach';
  231. }
  232. $maxProgressPct = $liveData['max_progress_pct'] ?? null;
  233. if ($maxProgressPct !== null && $maxProgressPct >= self::DEFAULT_ALERT_PERCENT) {
  234. return 'warning';
  235. }
  236. return 'ok';
  237. }
  238. private function resolveLiveDetail(?array $liveData): string
  239. {
  240. $maxOverdueSeconds = $liveData['max_overdue_seconds'] ?? null;
  241. if ($maxOverdueSeconds !== null && $maxOverdueSeconds > 0) {
  242. return $this->formatOverdueTime((int) round($maxOverdueSeconds)) . ' estourado';
  243. }
  244. $maxProgressPct = $liveData['max_progress_pct'] ?? null;
  245. if ($maxProgressPct !== null && $maxProgressPct >= self::DEFAULT_ALERT_PERCENT) {
  246. $remainingPct = max(0, (int) round(100 - $maxProgressPct));
  247. return sprintf('próximo de estourar (%d%% restante)', $remainingPct);
  248. }
  249. return 'dentro do SLA';
  250. }
  251. private function formatOverdueTime(int $seconds): string
  252. {
  253. if ($seconds < 60) {
  254. return $seconds . 's';
  255. }
  256. $minutes = intdiv($seconds, 60);
  257. if ($minutes < 60) {
  258. return $minutes . 'min';
  259. }
  260. $hours = intdiv($minutes, 60);
  261. $remainingMinutes = $minutes % 60;
  262. if ($remainingMinutes === 0) {
  263. return $hours . 'h';
  264. }
  265. return sprintf('%dh %dmin', $hours, $remainingMinutes);
  266. }
  267. private function formatRelativeTime(?string $timestamp): string
  268. {
  269. if ($timestamp === null || trim($timestamp) === '') {
  270. return 'agora';
  271. }
  272. try {
  273. $dateTime = new \DateTimeImmutable($timestamp);
  274. } catch (\Throwable $e) {
  275. return 'agora';
  276. }
  277. $diffSeconds = time() - $dateTime->getTimestamp();
  278. if ($diffSeconds <= 30) {
  279. return 'agora';
  280. }
  281. if ($diffSeconds < 3600) {
  282. $minutes = max(1, (int) floor($diffSeconds / 60));
  283. return $minutes === 1 ? 'há 1 minuto' : sprintf('há %d minutos', $minutes);
  284. }
  285. if ($diffSeconds < 86400) {
  286. $hours = max(1, (int) floor($diffSeconds / 3600));
  287. return $hours === 1 ? 'há 1 hora' : sprintf('há %d horas', $hours);
  288. }
  289. $days = max(1, (int) floor($diffSeconds / 86400));
  290. return $days === 1 ? 'há 1 dia' : sprintf('há %d dias', $days);
  291. }
  292. private function slugify(string $value): string
  293. {
  294. $normalized = trim($value);
  295. if ($normalized === '') {
  296. return '';
  297. }
  298. $transliterated = function_exists('iconv') ? iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $normalized) : $normalized;
  299. $transliterated = is_string($transliterated) ? $transliterated : $normalized;
  300. $slug = preg_replace('/[^a-zA-Z0-9]+/', '-', $transliterated) ?? '';
  301. $slug = trim($slug, '-');
  302. return mb_strtolower($slug);
  303. }
  304. }