PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.4.5
Fluent Support – Helpdesk & Customer Support Ticket System v2.4.5
2.4.5 2.4.0 2.3.2 2.3.1 2.3.0 2.2.1 2.2.0 trunk 1.10.0 1.10.1 1.10.2 1.10.3 1.10.4 1.10.5 1.4.0 1.4.1 1.4.2 1.4.5 1.4.6 1.4.7 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 All 69 releases
fluent-support / app / Modules / Reporting / Reporting.php

Reporting.php in Fluent Support – Helpdesk & Customer Support Ticket System 2.4.5, at app/Modules/Reporting/Reporting.php

977 lines 34.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentSupport\App\Modules\Reporting;
4
5 use FluentSupport\App\Models\Agent;
6 use FluentSupport\App\Models\AgentGroup;
7 use FluentSupport\App\Models\Conversation;
8 use FluentSupport\App\Models\MailBox;
9 use FluentSupport\App\Models\Person;
10 use FluentSupport\App\Models\Product;
11 use FluentSupport\App\Models\TagPivot;
12 use FluentSupport\App\Models\Ticket;
13 use FluentSupport\App\Modules\PermissionManager;
14 use FluentSupport\App\Services\Helper;
15 use FluentSupport\App\Services\Tickets\AgentTicketAccess;
16 use FluentSupport\Framework\Database\Orm\Builder;
17 use FluentSupport\Framework\Support\Arr;
18 use FluentSupport\Framework\Support\DateTime;
19
20 /**
21 * Ticket, response and agent reports, scoped to the current agent's mailboxes.
22 */
23 class Reporting
24 {
25 use ReportingHelperTrait;
26
27 /**
28 * New tickets per period.
29 *
30 * @param string|false $from
31 * @param string|false $to
32 * @param array $filters
33 * @return array
34 */
35 public function getTicketsGrowth($from = false, $to = false, $filters = [])
36 {
37 $period = $this->makeDatePeriod(
38 $from = $this->makeFromDate($from),
39 $to = $this->makeToDate($to),
40 $frequency = $this->getFrequency($from, $to)
41 );
42
43 list($groupBy, $orderBy) = $this->getGroupAndOrder($frequency);
44
45 $query = $this->db()->table('fs_tickets')
46 ->select($this->prepareSelect($frequency))
47 ->whereBetween('created_at', $this->prepareBetween($frequency, $from, $to))
48 ->groupBy($groupBy)
49 ->oldest($orderBy);
50
51 // Scope first. A mailbox_id filter below is not proof of access.
52 (new AgentTicketAccess())->applyMailboxRestrictionScope($query);
53
54 if ($filters) {
55 if (!empty($filters['statuses'])) {
56 $query->whereIn('status', $filters['statuses']);
57 }
58
59 if (!empty($filters['product_id'])) {
60 $query->where('product_id', $filters['product_id']);
61 }
62
63 if (!empty($filters['agent_id'])) {
64 $query->where('agent_id', $filters['agent_id']);
65 }
66
67 if (!empty($filters['mailbox_id'])) {
68 $query->where('mailbox_id', $filters['mailbox_id']);
69 }
70
71 if (!empty($filters['agent_ids'])) {
72 $query->whereIn('agent_id', $filters['agent_ids']);
73 }
74 }
75
76 $items = $query->get();
77
78 return $this->getResult($period, $items);
79 }
80
81 /**
82 * Closed tickets per period, by resolved_at.
83 *
84 * @param string|false $from
85 * @param string|false $to
86 * @param array $filters
87 * @return array
88 */
89 public function getTicketResolveGrowth($from = false, $to = false, $filters = [], $type = '')
90 {
91 $period = $this->makeDatePeriod(
92 $from = $this->makeFromDate($from),
93 $to = $this->makeToDate($to),
94 $frequency = $this->getFrequency($from, $to)
95 );
96
97 list($groupBy, $orderBy) = $this->getGroupAndOrder($frequency);
98
99 $filterColumn = (!empty($type)) ? $type.'_id' : 'id';
100
101 $query = $this->db()->table('fs_tickets')
102 ->select($this->prepareSelect($frequency, 'resolved_at'))
103 ->whereBetween('resolved_at', $this->prepareBetween($frequency, $from, $to))
104 ->where('status', 'closed')
105 ->where($filterColumn, '>', 0)
106 ->groupBy($groupBy)
107 ->oldest($orderBy);
108
109 (new AgentTicketAccess())->applyMailboxRestrictionScope($query);
110
111 if ($filters) {
112 if (!empty($filters['product_id'])) {
113 $query->where('product_id', $filters['product_id']);
114 }
115
116 if (!empty($filters['agent_id'])) {
117 $query->where('agent_id', $filters['agent_id']);
118 }
119
120 if (!empty($filters['mailbox_id'])) {
121 $query->where('mailbox_id', $filters['mailbox_id']);
122 }
123
124 if (!empty($filters['agent_ids'])) {
125 $query->whereIn('agent_id', $filters['agent_ids']);
126 }
127 }
128
129 $items = $query->get();
130
131 return $this->getResult($period, $items);
132 }
133
134 /**
135 * Responses per period.
136 *
137 * @param string|false $from
138 * @param string|false $to
139 * @param array $filters
140 * @return array
141 */
142 public function getResponseGrowth($from = false, $to = false, $filters = [])
143 {
144 $period = $this->makeDatePeriod(
145 $from = $this->makeFromDate($from),
146 $to = $this->makeToDate($to),
147 $frequency = $this->getFrequency($from, $to)
148 );
149
150 list($groupBy, $orderBy) = $this->getGroupAndOrder($frequency);
151
152 $query = (new AgentTicketAccess())->applyMailboxRestrictionScopeViaTicket(
153 Conversation::query()
154 ->select($this->prepareSelect($frequency))
155 ->whereBetween('created_at', $this->prepareBetween($frequency, $from, $to))
156 ->where('conversation_type', 'response')
157 ->whereIn('person_id', Agent::getAgentIds())
158 ->groupBy($groupBy)
159 ->oldest($orderBy)
160 );
161
162 if ($filters) {
163 if (!empty($filters['person_id'])) {
164 $query->where('person_id', $filters['person_id']);
165 }
166
167 if (!empty($filters['person_ids'])) {
168 $query->whereIn('person_id', $filters['person_ids']);
169 }
170
171 if (!empty($filters['product_id'])) {
172 $query->where('product_id', $filters['product_id']);
173 }
174 }
175
176 $items = $query->get();
177
178 return $this->getResult($period, $items);
179 }
180
181 public function getResponseGrowthChart($from = false, $to = false, $filters = [], $type = ''): array
182 {
183 $period = $this->makeDatePeriod(
184 $from = $this->makeFromDate($from),
185 $to = $this->makeToDate($to),
186 $frequency = $this->getFrequency($from, $to)
187 );
188
189 list($groupBy, $orderBy) = $this->getGroupAndOrder($frequency);
190
191 $filterColumn = (!empty($type)) ? $type.'_id' : 'id';
192
193 $query = $this->db()->table('fs_tickets')
194 ->select($this->prepareSelect($frequency,'created_at','response_count'))
195 ->whereBetween('created_at', $this->prepareBetween($frequency, $from, $to))
196 ->havingRaw('COUNT(response_count)> 0')
197 ->where($filterColumn, '>', 0)
198 ->groupBy($groupBy)
199 ->oldest($orderBy);
200
201 (new AgentTicketAccess())->applyMailboxRestrictionScope($query);
202
203 if ($filters) {
204 if (!empty($filters['product_id'])) {
205 $query->where('product_id', $filters['product_id']);
206 }
207
208 if (!empty($filters['mailbox_id'])) {
209 $query->where('mailbox_id', $filters['mailbox_id']);
210 }
211 }
212
213 $items = $query->get();
214
215 return $this->getResult($period, $items);
216 }
217
218 // One grouped query instead of one per agent; shared by the two summaries.
219 private function interactionCountsByAgent($from, $to, $access)
220 {
221 return $access->applyMailboxRestrictionScopeViaTicket(
222 Conversation::select([
223 $this->db()->raw('person_id as agent_id'),
224 $this->db()->raw('COUNT(DISTINCT ticket_id) as count')
225 ])
226 ->whereIn('person_id', Agent::getAgentIds())
227 ->whereBetween('created_at', [$from, $to])
228 ->where('conversation_type', 'response')
229 ->groupBy('person_id')
230 )->get();
231 }
232
233 /**
234 * Per-agent ticket and response counts for a date range (default today).
235 *
236 * @param string|false $from
237 * @param string|false $to
238 * @param string|false $agent Comma-separated agent ids.
239 * @return mixed
240 */
241 public function agentSummary($from = false, $to = false, $agent = false)
242 {
243 if(!$from) {
244 $from = current_time('Y-m-d');
245 }
246
247 if(!$to) {
248 $to = current_time('Y-m-d');
249 }
250
251 $from .= ' 00:00:00';
252 $to .= ' 23:59:59';
253 $reports = [];
254
255 $access = new AgentTicketAccess();
256
257 $resolves = $this->db()->table('fs_tickets')
258 ->select([
259 $this->db()->raw('COUNT(id) AS count'),
260 'agent_id',
261 ])
262 ->groupBy('agent_id')
263 ->where('status', 'closed')
264 ->whereBetween('resolved_at', [$from, $to]);
265
266 $resolves = $access->applyMailboxRestrictionScope($resolves)->get();
267
268 $reports = $this->pushReportData('closed', $resolves, $reports, 'agent_id');
269
270 // Open tickets aren't limited to the date range
271 $openTickets = $this->db()->table('fs_tickets')
272 ->select([
273 $this->db()->raw('COUNT(id) AS count'),
274 'agent_id'
275 ])
276 ->groupBy('agent_id')
277 ->where('status', '!=', 'closed');
278
279 $openTickets = $access->applyMailboxRestrictionScope($openTickets)->get();
280
281 $reports = $this->pushReportData('opens', $openTickets, $reports, 'agent_id');
282 $responses = $access->applyMailboxRestrictionScopeViaTicket(
283 Conversation::select([
284 $this->db()->raw('COUNT(id) AS count'),
285 $this->db()->raw('person_id as agent_id'),
286 $this->db()->raw('created_at')
287 ])
288 ->whereIn('person_id', Agent::getAgentIds())
289 ->whereBetween('created_at', [$from, $to])
290 ->where('conversation_type', 'response')
291 ->groupBy('agent_id')
292 )->get();
293
294 $reports = $this->pushReportData('responses', $responses, $reports, 'agent_id');
295 foreach ($this->interactionCountsByAgent($from, $to, $access) as $row) {
296 if (isset($reports[$row->agent_id])) {
297 $reports[$row->agent_id]['interactions'] = (int) $row->count;
298 }
299 }
300
301 $agentIds = array_keys($reports);
302
303 if ($agent) {
304 $agentIds = array_map('intval', explode(',', $agent));
305 }
306
307 $agentFeedbackRatingEnabled = Helper::getBusinessSettings('agent_feedback_rating') === 'yes';
308 if (defined('FLUENTSUPPORTPRO_PLUGIN_VERSION') && $agentFeedbackRatingEnabled) {
309 global $wpdb;
310 $prefix = $wpdb->prefix;
311
312 // One grouped join, not GROUP_CONCAT of response ids, which
313 // truncates at group_concat_max_len (1024 bytes) for busy agents.
314 $feedbackRows = $this->db()->table('fs_conversations')
315 ->join('fs_meta', 'fs_meta.object_id', '=', 'fs_conversations.id')
316 ->join('fs_tickets', 'fs_tickets.id', '=', 'fs_conversations.ticket_id')
317 ->select([
318 $this->db()->raw($prefix . 'fs_conversations.person_id AS agent_id'),
319 $this->db()->raw("SUM(CASE WHEN {$prefix}fs_meta.value = 'like' THEN 1 ELSE 0 END) AS likes"),
320 $this->db()->raw("SUM(CASE WHEN {$prefix}fs_meta.value = 'dislike' THEN 1 ELSE 0 END) AS dislikes"),
321 ])
322 ->where('fs_meta.object_type', 'conversation_meta')
323 ->where('fs_meta.key', 'agent_feedback_ratings')
324 ->whereBetween('fs_meta.created_at', [$from, $to])
325 ->where('fs_conversations.conversation_type', 'response')
326 ->whereIn('fs_conversations.person_id', $agentIds)
327 ->whereIn('fs_conversations.person_id', Agent::getAgentIds())
328 ->groupBy('fs_conversations.person_id');
329
330 foreach ($access->applyMailboxRestrictionScope($feedbackRows)->get() as $row) {
331 $reports[$row->agent_id]['likes'] = (int) $row->likes;
332 $reports[$row->agent_id]['dislikes'] = (int) $row->dislikes;
333 }
334 }
335
336 // Without an agent filter, list every agent, including those with no
337 // activity in the range.
338 $agentsQuery = Agent::select(['id', 'first_name', 'last_name', 'email']);
339
340 if ($agent) {
341 $agentsQuery->whereIn('id', $agentIds);
342 }
343
344 $agents = $agentsQuery->get();
345
346 $reportFields = [
347 'interactions' => 0,
348 'responses' => 0,
349 'opens' => 0,
350 'closed' => 0,
351 'waiting_tickets' => 0,
352 ];
353
354 $additionalFields = defined('FLUENTSUPPORTPRO_PLUGIN_VERSION') && $agentFeedbackRatingEnabled ? ['likes' => 0, 'dislikes' => 0] : [];
355 $reportFields = $reportFields + $additionalFields;
356
357 // One grouped query for all agents instead of one per agent.
358 $waitStats = $this->getWaitStatsByAgents($agents->pluck('id')->toArray());
359 $emptyActiveStat = ['average_waiting' => 0, 'max_waiting' => 0, 'waiting_tickets' => 0];
360
361 foreach ($agents as $agent) {
362 $agent->stats = wp_parse_args(isset($reports[$agent->id]) ? $reports[$agent->id] : [], $reportFields);
363 $agent->active_stat = isset($waitStats[$agent->id])
364 ? $this->formatActiveStat($waitStats[$agent->id])
365 : $emptyActiveStat;
366 }
367 return $agents;
368 }
369
370 public function getSummary($type, $from = null, $to = null)
371 {
372 global $wpdb;
373 $tablePrefix = $wpdb->prefix;
374
375 if (!$from) {
376 $from = current_time('Y-m-d');
377 }
378
379 if (!$to) {
380 $to = current_time('Y-m-d');
381 }
382
383 $from .= ' 00:00:00';
384 $to .= ' 23:59:59';
385 $reports = [];
386
387 $groupByField = $type == 'product' ? 'product_id' : 'mailbox_id';
388
389 $access = new AgentTicketAccess();
390
391 $resolves = $this->db()->table('fs_tickets')
392 ->select([
393 $this->db()->raw('COUNT(id) AS count'),
394 $groupByField,
395 ])
396 ->groupBy($groupByField)
397 ->where('status', 'closed')
398 ->whereBetween('resolved_at', [$from, $to]);
399
400 $resolves = $access->applyMailboxRestrictionScope($resolves)->get();
401
402 $reports = $this->pushReportData('closed', $resolves, $reports, $groupByField);
403
404 $openTickets = $this->db()->table('fs_tickets')
405 ->select([
406 $this->db()->raw('COUNT(id) AS count'),
407 $groupByField
408 ])
409 ->groupBy($groupByField)
410 ->where('status', '!=', 'closed')
411 ->whereBetween('created_at', [$from, $to]);
412
413 $openTickets = $access->applyMailboxRestrictionScope($openTickets)->get();
414
415 $reports = $this->pushReportData('opens', $openTickets, $reports, $groupByField);
416
417 $responses = $this->db()->table('fs_conversations')
418 ->join('fs_tickets', 'fs_tickets.id', '=', 'fs_conversations.ticket_id')
419 ->select([
420 $this->db()->raw('COUNT(' . $tablePrefix . 'fs_conversations.id) AS count'),
421 'fs_tickets.' . $groupByField,
422 ])
423 ->groupBy('fs_tickets.' . $groupByField)
424 ->whereBetween('fs_conversations.created_at', [$from, $to]);
425
426 $responses = $access->applyMailboxRestrictionScope($responses)->get();
427
428 $reports = $this->pushReportData('responses', $responses, $reports, $groupByField);
429
430 // Interactions = distinct tickets replied to in the range. Use COUNT(DISTINCT),
431 // not GROUP_CONCAT, which truncates at group_concat_max_len (1024 bytes).
432 $interactions = $this->db()->table('fs_conversations')
433 ->join('fs_tickets', 'fs_tickets.id', '=', 'fs_conversations.ticket_id')
434 ->select([
435 'fs_tickets.' . $groupByField,
436 $this->db()->raw('COUNT(DISTINCT ' . $tablePrefix . 'fs_conversations.ticket_id) AS count'),
437 ])
438 ->where('fs_conversations.conversation_type', 'response')
439 ->whereBetween('fs_conversations.created_at', [$from, $to])
440 ->groupBy('fs_tickets.' . $groupByField);
441
442 $interactions = $access->applyMailboxRestrictionScope($interactions)->get();
443
444 $reports = $this->pushReportData('interactions', $interactions, $reports, $groupByField);
445
446 $ids = array_keys($reports);
447
448 $types = [
449 'product' => [
450 'model' => Product::class,
451 'fields' => ['id', 'title'],
452 ],
453 'mailbox' => [
454 'model' => MailBox::class,
455 'fields' => ['id', 'name'],
456 ],
457 ];
458
459 $model = $types[$type]['model'];
460 $fields = $types[$type]['fields'];
461
462 $items = $model::select($fields)
463 ->whereIn('id', $ids)
464 ->get();
465
466 foreach ($items as $item) {
467 $report = isset($reports[$item->id]) ? $reports[$item->id] : [];
468
469 $report = wp_parse_args($report, [
470 'responses' => 0,
471 'opens' => 0,
472 'closed' => 0,
473 'interactions' => 0
474 ]);
475 $item->stats = $report;
476 $item->active_stat = '';
477 }
478
479 return $items;
480 }
481
482 /**
483 * @param string|false $from
484 * @param string|false $to
485 * @return mixed
486 */
487 public function agentGroupSummary($from = false, $to = false)
488 {
489 if (!$from) {
490 $from = current_time('Y-m-d');
491 }
492
493 if (!$to) {
494 $to = current_time('Y-m-d');
495 }
496
497 $from .= ' 00:00:00';
498 $to .= ' 23:59:59';
499
500 // Same per-agent queries as agentSummary()
501 $reports = [];
502
503 $access = new AgentTicketAccess();
504
505 $resolves = $this->db()->table('fs_tickets')
506 ->select([
507 $this->db()->raw('COUNT(id) AS count'),
508 'agent_id',
509 ])
510 ->groupBy('agent_id')
511 ->where('status', 'closed')
512 ->whereBetween('resolved_at', [$from, $to]);
513
514 $resolves = $access->applyMailboxRestrictionScope($resolves)->get();
515
516 $reports = $this->pushReportData('closed', $resolves, $reports, 'agent_id');
517
518 $openTickets = $this->db()->table('fs_tickets')
519 ->select([
520 $this->db()->raw('COUNT(id) AS count'),
521 'agent_id'
522 ])
523 ->groupBy('agent_id')
524 ->where('status', '!=', 'closed');
525
526 $openTickets = $access->applyMailboxRestrictionScope($openTickets)->get();
527
528 $reports = $this->pushReportData('opens', $openTickets, $reports, 'agent_id');
529
530 $responses = $access->applyMailboxRestrictionScopeViaTicket(
531 Conversation::select([
532 $this->db()->raw('COUNT(id) AS count'),
533 $this->db()->raw('person_id as agent_id'),
534 ])
535 ->whereIn('person_id', Agent::getAgentIds())
536 ->whereBetween('created_at', [$from, $to])
537 ->where('conversation_type', 'response')
538 ->groupBy('agent_id')
539 )->get();
540
541 $reports = $this->pushReportData('responses', $responses, $reports, 'agent_id');
542
543 foreach ($this->interactionCountsByAgent($from, $to, $access) as $row) {
544 if (isset($reports[$row->agent_id])) {
545 $reports[$row->agent_id]['interactions'] = (int) $row->count;
546 }
547 }
548
549 $groupAgentMap = TagPivot::where('source_type', 'agent_group')
550 ->select(['tag_id', 'source_id'])
551 ->get()
552 ->groupBy('tag_id');
553
554 $groups = AgentGroup::select(['id', 'title'])->get();
555
556 $defaultStats = [
557 'responses' => 0,
558 'interactions' => 0,
559 'opens' => 0,
560 'closed' => 0,
561 ];
562
563 foreach ($groups as $group) {
564 $agentIds = isset($groupAgentMap[$group->id])
565 ? array_map('intval', $groupAgentMap[$group->id]->pluck('source_id')->toArray())
566 : [];
567
568 $group->agents_count = count($agentIds);
569
570 $groupStats = $defaultStats;
571
572 foreach ($agentIds as $agentId) {
573 if (isset($reports[$agentId])) {
574 $agentStats = wp_parse_args($reports[$agentId], $defaultStats);
575 $groupStats['responses'] += (int) $agentStats['responses'];
576 $groupStats['interactions'] += (int) $agentStats['interactions'];
577 $groupStats['opens'] += (int) $agentStats['opens'];
578 $groupStats['closed'] += (int) $agentStats['closed'];
579 }
580 }
581
582 $group->stats = $groupStats;
583 }
584
585 return $groups;
586 }
587
588 /**
589 * Merge grouped counts into $reports[groupKey][$type].
590 *
591 * @param $type
592 * @param $tickets
593 * @param $reports
594 * @param $groupByField
595 * @return array
596 */
597 private function pushReportData($type, $tickets, $reports, $groupByField): array
598 {
599 foreach ($tickets as $ticket) {
600 $groupKey = $ticket->{$groupByField};
601
602 if (!$groupKey) {
603 continue;
604 }
605
606 if (!isset($reports[$groupKey])) {
607 $reports[$groupKey] = [];
608 }
609
610 $reports[$groupKey][$type] = $ticket->count;
611 }
612
613 return $reports;
614 }
615
616 /**
617 * Wait-time stats for open tickets awaiting an agent reply.
618 *
619 * @param int|null $productId Narrow to one product's tickets
620 * @return array|false
621 */
622 public function getActiveStats($productId = null)
623 {
624 $query = (new AgentTicketAccess())->applyMailboxRestrictionScope(
625 Ticket::waitingOnly()
626 )
627 ->where('status', '!=', 'closed')
628 ->whereNotNull('waiting_since');
629
630 if ($productId) {
631 $query->where('product_id', $productId);
632 }
633
634 $waitStat = $query
635 ->select([
636 $this->db()->raw('avg(UNIX_TIMESTAMP(waiting_since)) as avg_waiting'),
637 $this->db()->raw('MIN(UNIX_TIMESTAMP(waiting_since)) as max_waiting'),
638 $this->db()->raw('COUNT(*) as total_tickets')
639 ])
640 ->first();
641
642 if(!$waitStat) {
643 return false;
644 }
645
646 $waitStat->avg_waiting = intval($waitStat->avg_waiting);
647 if($waitStat->avg_waiting > 0) {
648 $waitSeconds = time() - $waitStat->avg_waiting;
649 if( $waitSeconds < 172800 && $waitSeconds > 7200) {
650 $avgWait = ceil($waitSeconds / 3600) . ' hours';
651 } else {
652 $avgWait = human_time_diff($waitStat->avg_waiting, time());
653 }
654 } else {
655 $avgWait = 0;
656 }
657
658 return [
659 'average_waiting' => $avgWait,
660 'max_waiting' => (intval($waitStat->max_waiting)) ? human_time_diff(intval($waitStat->max_waiting), time()) : 0,
661 'waiting_tickets' => $waitStat->total_tickets
662 ];
663 }
664
665 /**
666 * Same as getActiveStats() for one agent's tickets.
667 *
668 * @param $agentId
669 * @return array|false
670 */
671 public function getActiveStatByAgent($agentId)
672 {
673 $waitStat = (new AgentTicketAccess())->applyMailboxRestrictionScope(Ticket::waitingOnly())
674 ->where('status', '!=', 'closed')
675 ->whereNotNull('waiting_since')
676 ->where('agent_id', $agentId)
677 ->select([
678 $this->db()->raw('avg(UNIX_TIMESTAMP(waiting_since)) as avg_waiting'),
679 $this->db()->raw('MIN(UNIX_TIMESTAMP(waiting_since)) as max_waiting'),
680 $this->db()->raw('COUNT(*) as total_tickets')
681 ])
682 ->first();
683
684 if(!$waitStat) {
685 return false;
686 }
687
688 return $this->formatActiveStat($waitStat);
689 }
690
691 /**
692 * Batched getActiveStatByAgent() aggregate, keyed by agent id. Agents with
693 * no waiting tickets have no row.
694 * @param array $agentIds
695 * @return \FluentSupport\Framework\Database\Orm\Collection|array
696 */
697 public function getWaitStatsByAgents($agentIds)
698 {
699 if (empty($agentIds)) {
700 return [];
701 }
702
703 return (new AgentTicketAccess())->applyMailboxRestrictionScope(Ticket::waitingOnly())
704 ->where('status', '!=', 'closed')
705 ->whereNotNull('waiting_since')
706 ->whereIn('agent_id', $agentIds)
707 ->select([
708 'agent_id',
709 $this->db()->raw('avg(UNIX_TIMESTAMP(waiting_since)) as avg_waiting'),
710 $this->db()->raw('MIN(UNIX_TIMESTAMP(waiting_since)) as max_waiting'),
711 $this->db()->raw('COUNT(*) as total_tickets')
712 ])
713 ->groupBy('agent_id')
714 ->get()
715 ->keyBy('agent_id');
716 }
717
718 /**
719 * Format a wait-time aggregate row for display.
720 * @param $waitStat
721 * @return array
722 */
723 private function formatActiveStat($waitStat)
724 {
725 $waitStat->avg_waiting = intval($waitStat->avg_waiting);
726 if($waitStat->avg_waiting > 0) {
727 $waitSeconds = time() - $waitStat->avg_waiting;
728 if( $waitSeconds < 172800 && $waitSeconds > 7200) {
729 $avgWait = ceil($waitSeconds / 3600) . ' hours';
730 } else {
731 $avgWait = human_time_diff($waitStat->avg_waiting, time());
732 }
733 } else {
734 $avgWait = 0;
735 }
736
737 return [
738 'average_waiting' => $avgWait,
739 'max_waiting' => (intval($waitStat->max_waiting)) ? human_time_diff(intval($waitStat->max_waiting), time()) : 0,
740 'waiting_tickets' => $waitStat->total_tickets
741 ];
742 }
743
744 public function getQueryResults($from, $to, $filter)
745 {
746 switch ($filter['report_type']) {
747 case 'ticket':
748 return $this->getTicketStats($from, $to);
749 case 'agent_response':
750 return $this->getResponseStats($from, $to, 'agent', $filter['agent_id'] ?? null);
751 case 'customer_response':
752 return $this->getResponseStats($from, $to, 'customer');
753 default:
754 return [];
755 }
756 }
757
758 public function getTicketStats($from, $to, $productId = null)
759 {
760 global $wpdb;
761
762 $whereClause = '';
763 $queryParams = [];
764
765 if ($from && $to) {
766 $start_date = gmdate('Y-m-d 00:00:00', strtotime($from));
767 $end_date = gmdate('Y-m-d 23:59:59', strtotime($to));
768 $whereClause = 'WHERE created_at BETWEEN %s AND %s';
769 $queryParams[] = $start_date;
770 $queryParams[] = $end_date;
771 }
772
773 if ($productId) {
774 $whereClause .= ($whereClause ? ' AND ' : 'WHERE ') . 'product_id = %d';
775 $queryParams[] = intval($productId);
776 }
777
778 // Raw SQL, so the query-builder helper cannot be used.
779 $restrictedMailboxIds = PermissionManager::getRestrictedMailboxIds();
780
781 if ($restrictedMailboxIds) {
782 $restrictedList = implode(',', array_map('intval', $restrictedMailboxIds));
783 $whereClause .= ($whereClause ? ' AND ' : 'WHERE ')
784 . "(mailbox_id NOT IN ({$restrictedList}) OR mailbox_id IS NULL)";
785 }
786
787 // $whereClause holds only literals and int ids. Dates are bound in prepare() below.
788 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
789 $query = "SELECT DAYNAME(created_at) AS weekday, HOUR(created_at) AS hour, COUNT(*) AS count
790 FROM {$wpdb->prefix}fs_tickets
791 {$whereClause}
792 GROUP BY DAYNAME(created_at), HOUR(created_at)
793 ORDER BY FIELD(DAYNAME(created_at), 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'), HOUR(created_at)";
794
795 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $query structure is hardcoded; values are bound via $wpdb->prepare() when $queryParams is non-empty.
796 $results = $queryParams
797 ? $wpdb->get_results($wpdb->prepare($query, $queryParams)) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
798 : $wpdb->get_results($query); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
799
800 $fillData = array_fill(0, 24, 0);
801 $fillData = array_combine(array_map(function ($hour) {
802 return sprintf('%1d:00', $hour);
803 }, array_keys($fillData)), $fillData);
804
805 $report = [
806 'Mon' => $fillData,
807 'Tue' => $fillData,
808 'Wed' => $fillData,
809 'Thu' => $fillData,
810 'Fri' => $fillData,
811 'Sat' => $fillData,
812 'Sun' => $fillData
813 ];
814
815 foreach ($results as $result) {
816 $weekdayName = substr($result->weekday, 0, 3);
817 if (!isset($report[$weekdayName])) {
818 $report[$weekdayName] = $fillData;
819 }
820 $report[$weekdayName][sprintf('%1d:00', $result->hour)] = (int)$result->count;
821 }
822
823 return $report;
824 }
825
826 public function getResponseStats($from, $to, $reportType, $agentId = null, $productId = null)
827 {
828 global $wpdb;
829
830 $whereClause = ' AND p.person_type = %s';
831 $queryParams = [$reportType];
832
833 if ($from && $to) {
834 $start_date = gmdate('Y-m-d 00:00:00', strtotime($from));
835 $end_date = gmdate('Y-m-d 23:59:59', strtotime($to));
836 $whereClause .= ' AND c.created_at BETWEEN %s AND %s';
837 $queryParams[] = $start_date;
838 $queryParams[] = $end_date;
839 }
840
841 if ($agentId) {
842 $whereClause .= ' AND c.person_id = %d';
843 $queryParams[] = intval($agentId);
844 }
845
846 // Mailbox and product scoping go through the parent ticket.
847 $ticketConditions = [];
848 $restrictedMailboxIds = PermissionManager::getRestrictedMailboxIds();
849
850 if ($restrictedMailboxIds) {
851 $restrictedList = implode(',', array_map('intval', $restrictedMailboxIds));
852 $ticketConditions[] = "(t.mailbox_id NOT IN ({$restrictedList}) OR t.mailbox_id IS NULL)";
853 }
854
855 if ($productId) {
856 $ticketConditions[] = 't.product_id = %d';
857 $queryParams[] = intval($productId);
858 }
859
860 if ($ticketConditions) {
861 $whereClause .= " AND EXISTS (SELECT 1 FROM {$wpdb->prefix}fs_tickets t"
862 . " WHERE t.id = c.ticket_id AND " . implode(' AND ', $ticketConditions) . ')';
863 }
864
865 // Responses by weekday and hour, for agents or customers per $reportType
866 $query = $wpdb->prepare(
867 "SELECT DAYNAME(c.created_at) AS weekday, HOUR(c.created_at) AS hour, COUNT(*) AS count
868 FROM {$wpdb->prefix}fs_conversations AS c
869 JOIN {$wpdb->prefix}fs_persons AS p ON c.person_id = p.id
870 WHERE c.conversation_type = 'response'
871 {$whereClause}
872 GROUP BY DAYNAME(c.created_at), HOUR(c.created_at)
873 ORDER BY FIELD(DAYNAME(c.created_at), 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'), HOUR(c.created_at)",
874 $queryParams
875 );
876
877 // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.NotPrepared -- $query is the output of $wpdb->prepare() above; checker incorrectly traces back through the prepared parameters.
878 $results = $wpdb->get_results($query);
879
880 $fillData = array_fill(0, 24, 0);
881 $fillData = array_combine(array_map(function ($hour) {
882 return sprintf('%1d:00', $hour);
883 }, array_keys($fillData)), $fillData);
884
885 $report = [
886 'Mon' => $fillData,
887 'Tue' => $fillData,
888 'Wed' => $fillData,
889 'Thu' => $fillData,
890 'Fri' => $fillData,
891 'Sat' => $fillData,
892 'Sun' => $fillData
893 ];
894
895 foreach ($results as $result) {
896 $weekdayName = substr($result->weekday, 0, 3);
897 if (!isset($report[$weekdayName])) {
898 $report[$weekdayName] = $fillData;
899 }
900 $report[$weekdayName][sprintf('%01d:00', $result->hour)] = (int)$result->count;
901 }
902
903 return $report;
904 }
905
906 public function getTicketResponseStats($from, $to, $filter)
907 {
908 $query = (new AgentTicketAccess())->applyMailboxRestrictionScopeViaTicket(
909 Conversation::query()
910 )
911 ->select('id', 'ticket_id', 'person_id', 'created_at', 'content')
912 ->addSelect([
913 'person_type' => Person::select('person_type')
914 ->whereColumn('id', 'fs_conversations.person_id'),
915 'full_name' => Person::selectRaw("CONCAT(first_name, ' ', last_name)")
916 ->whereColumn('id', 'fs_conversations.person_id')
917 ])
918 ->where('conversation_type', 'response')
919 ->when(Arr::get($filter, 'person_type'), function (Builder $q, $personType) {
920 return $q->whereHas('person', function ($q) use ($personType) {
921 return $q->where('person_type', $personType);
922 });
923 })
924 ->when(($from && $to), function ($q) use ($from, $to) {
925 return $q->whereBetween('created_at', [
926 DateTime::parse($from)->startOfDay(),
927 DateTime::parse($to)->endOfDay()
928 ]);
929 })
930 ->when(Arr::get($filter, 'person_id'), function ($q, $personId) {
931 return $q->where('person_id', $personId);
932 });
933
934 return $query->get();
935 }
936
937 public function applyDateFilter($query, $from, $to)
938 {
939 if ($from && $to) {
940 $from .= ' 00:00:00';
941 $to .= ' 23:59:59';
942
943 $query->whereBetween('created_at', [$from, $to]);
944 }
945 }
946
947 public function finalizeQuery($query)
948 {
949 return $query->groupByRaw('DAYNAME(created_at), HOUR(created_at)')
950 ->orderByRaw("FIELD(DAYNAME(created_at), 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'), HOUR(created_at)")
951 ->get();
952 }
953
954 public function formatResults($results)
955 {
956 $dataItems = [
957 'Mon' => [], 'Tue' => [], 'Wed' => [], 'Thu' => [], 'Fri' => [], 'Sat' => [], 'Sun' => []
958 ];
959
960 $hours = array_map(function ($hour) {
961 return $hour . ":00";
962 }, range(0, 23));
963
964 foreach ($dataItems as $day => $data) {
965 $dataItems[$day] = array_fill_keys($hours, 0);
966 }
967
968 foreach ($results as $row) {
969 $day = substr($row['day_of_week'], 0, 3);
970 $hour = $row['hour_of_day'] . ":00";
971 $dataItems[$day][$hour] = (int) $row['count'];
972 }
973
974 return $dataItems;
975 }
976 }
977