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
← All changes | app/Modules/Reporting/Reporting.php +756 -65 1.5.2 → 2.4.5 View file →
@@ -2,15 +2,37 @@
2 2
3 3 namespace FluentSupport\App\Modules\Reporting;
4 4
5 5 use FluentSupport\App\Models\Agent;
6 +use FluentSupport\App\Models\AgentGroup;
6 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;
7 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;
8 19
20 +/**
21 + * Ticket, response and agent reports, scoped to the current agent's mailboxes.
22 + */
9 23 class Reporting
10 24 {
11 25 use ReportingHelperTrait;
12 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 + */
13 35 public function getTicketsGrowth($from = false, $to = false, $filters = [])
14 36 {
15 37 $period = $this->makeDatePeriod(
16 38 $from = $this->makeFromDate($from),
@@ -19,15 +41,17 @@
19 41 );
20 42
21 43 list($groupBy, $orderBy) = $this->getGroupAndOrder($frequency);
22 44
23 -
24 45 $query = $this->db()->table('fs_tickets')
25 46 ->select($this->prepareSelect($frequency))
26 - ->whereBetween('created_at', [$from->format('Y-m-d'), $to->format('Y-m-d')])
47 + ->whereBetween('created_at', $this->prepareBetween($frequency, $from, $to))
27 48 ->groupBy($groupBy)
28 - ->orderBy($orderBy, 'ASC');
49 + ->oldest($orderBy);
29 50
51 + // Scope first. A mailbox_id filter below is not proof of access.
52 + (new AgentTicketAccess())->applyMailboxRestrictionScope($query);
53 +
30 54 if ($filters) {
31 55 if (!empty($filters['statuses'])) {
32 56 $query->whereIn('status', $filters['statuses']);
33 57 }
@@ -38,8 +62,16 @@
38 62
39 63 if (!empty($filters['agent_id'])) {
40 64 $query->where('agent_id', $filters['agent_id']);
41 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 + }
42 74 }
43 75
44 76 $items = $query->get();
45 77
@@ -45,9 +77,17 @@
45 77
46 78 return $this->getResult($period, $items);
47 79 }
48 80
49 - public function getTicketResolveGrowth($from = false, $to = false, $filters = [])
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 = '')
50 90 {
51 91 $period = $this->makeDatePeriod(
52 92 $from = $this->makeFromDate($from),
53 93 $to = $this->makeToDate($to),
@@ -55,15 +95,20 @@
55 95 );
56 96
57 97 list($groupBy, $orderBy) = $this->getGroupAndOrder($frequency);
58 98
99 + $filterColumn = (!empty($type)) ? $type.'_id' : 'id';
100 +
59 101 $query = $this->db()->table('fs_tickets')
60 102 ->select($this->prepareSelect($frequency, 'resolved_at'))
61 - ->whereBetween('resolved_at', [$from->format('Y-m-d'), $to->format('Y-m-d')])
103 + ->whereBetween('resolved_at', $this->prepareBetween($frequency, $from, $to))
62 104 ->where('status', 'closed')
105 + ->where($filterColumn, '>', 0)
63 106 ->groupBy($groupBy)
64 - ->orderBy($orderBy, 'ASC');
107 + ->oldest($orderBy);
65 108
109 + (new AgentTicketAccess())->applyMailboxRestrictionScope($query);
110 +
66 111 if ($filters) {
67 112 if (!empty($filters['product_id'])) {
68 113 $query->where('product_id', $filters['product_id']);
69 114 }
@@ -70,8 +115,16 @@
70 115
71 116 if (!empty($filters['agent_id'])) {
72 117 $query->where('agent_id', $filters['agent_id']);
73 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 + }
74 127 }
75 128
76 129 $items = $query->get();
77 130
@@ -77,8 +130,16 @@
77 130
78 131 return $this->getResult($period, $items);
79 132 }
80 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 + */
81 142 public function getResponseGrowth($from = false, $to = false, $filters = [])
82 143 {
83 144 $period = $this->makeDatePeriod(
84 145 $from = $this->makeFromDate($from),
@@ -87,19 +148,30 @@
87 148 );
88 149
89 150 list($groupBy, $orderBy) = $this->getGroupAndOrder($frequency);
90 151
91 - $query = $this->db()->table('fs_conversations')
92 - ->select($this->prepareSelect($frequency, 'created_at'))
93 - ->whereBetween('created_at', [$from->format('Y-m-d'), $to->format('Y-m-d')])
94 - ->where('conversation_type', 'response')
95 - ->groupBy($groupBy)
96 - ->orderBy($orderBy, 'ASC');
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 + );
97 161
98 162 if ($filters) {
99 163 if (!empty($filters['person_id'])) {
100 164 $query->where('person_id', $filters['person_id']);
101 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 + }
102 174 }
103 175
104 176 $items = $query->get();
105 177
@@ -105,37 +177,98 @@
105 177
106 178 return $this->getResult($period, $items);
107 179 }
108 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 + */
109 241 public function agentSummary($from = false, $to = false, $agent = false)
110 242 {
111 243 if(!$from) {
112 - $from = current_time('mysql');
244 + $from = current_time('Y-m-d');
113 245 }
114 246
115 247 if(!$to) {
116 - $to = date('Y-m-d', current_time('timestamp') + 86400);
117 - } else {
118 - $to = date('Y-m-d', strtotime($to) + 86400);
248 + $to = current_time('Y-m-d');
119 249 }
120 250
121 - $from = $this->makeFromDate($from);
122 - $to = $this->makeToDate($to);
251 + $from .= ' 00:00:00';
252 + $to .= ' 23:59:59';
253 + $reports = [];
123 254
124 - $reports = [];
255 + $access = new AgentTicketAccess();
125 256
126 257 $resolves = $this->db()->table('fs_tickets')
127 258 ->select([
128 259 $this->db()->raw('COUNT(id) AS count'),
129 - 'agent_id'
260 + 'agent_id',
130 261 ])
131 - ->whereBetween('resolved_at', [$from->format('Y-m-d'), $to->format('Y-m-d')])
132 262 ->groupBy('agent_id')
133 263 ->where('status', 'closed')
134 - ->get();
264 + ->whereBetween('resolved_at', [$from, $to]);
135 265
136 - $reports = $this->pushAgentsReport('closed', $resolves, $reports);
266 + $resolves = $access->applyMailboxRestrictionScope($resolves)->get();
137 267
268 + $reports = $this->pushReportData('closed', $resolves, $reports, 'agent_id');
269 +
270 + // Open tickets aren't limited to the date range
138 271 $openTickets = $this->db()->table('fs_tickets')
139 272 ->select([
140 273 $this->db()->raw('COUNT(id) AS count'),
141 274 'agent_id'
@@ -140,34 +273,30 @@
140 273 $this->db()->raw('COUNT(id) AS count'),
141 274 'agent_id'
142 275 ])
143 276 ->groupBy('agent_id')
144 - ->where('status', '!=', 'closed')
145 - ->get();
277 + ->where('status', '!=', 'closed');
146 278
147 - $reports = $this->pushAgentsReport('opens', $openTickets, $reports);
279 + $openTickets = $access->applyMailboxRestrictionScope($openTickets)->get();
148 280
149 - $responses = Conversation::select([
150 - $this->db()->raw('COUNT(id) AS count'),
151 - $this->db()->raw('person_id as agent_id'),
152 - ])
153 - ->whereHas('person', function ($q) {
154 - $q->where('person_type', '=', 'agent');
155 - })
156 - ->whereBetween('created_at', [$from->format('Y-m-d'), $to->format('Y-m-d')])
157 - ->where('conversation_type', 'response')
158 - ->groupBy('agent_id')
159 - ->get();
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();
160 293
161 - $reports = $this->pushAgentsReport('responses', $responses, $reports);
162 -
163 - foreach ($responses as $response) {
164 - $reports[$response->agent_id]['interactions'] = Conversation::where('person_id', $response->agent_id)
165 - ->where('conversation_type', 'response')
166 - ->whereBetween('created_at', [$from->format('Y-m-d'), $to->format('Y-m-d')])
167 - ->groupBy('ticket_id')
168 - ->get()
169 - ->count();
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 + }
170 299 }
171 300
172 301 $agentIds = array_keys($reports);
173 302
@@ -174,50 +303,336 @@
174 303 if ($agent) {
175 304 $agentIds = array_map('intval', explode(',', $agent));
176 305 }
177 306
178 - $agents = Agent::select(['id', 'first_name', 'last_name'])
179 - ->whereIn('id', $agentIds)
180 - ->get();
307 + $agentFeedbackRatingEnabled = Helper::getBusinessSettings('agent_feedback_rating') === 'yes';
308 + if (defined('FLUENTSUPPORTPRO_PLUGIN_VERSION') && $agentFeedbackRatingEnabled) {
309 + global $wpdb;
310 + $prefix = $wpdb->prefix;
181 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 +
182 361 foreach ($agents as $agent) {
183 - $report = wp_parse_args($reports[$agent->id], [
184 - 'interactions' => 0,
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, [
185 470 'responses' => 0,
186 471 'opens' => 0,
187 472 'closed' => 0,
188 - 'waiting_tickets' => 0
473 + 'interactions' => 0
189 474 ]);
190 - $agent->stats = $report;
191 - $agent->active_stat = $this->getActiveStatByAgent($agent->id);
475 + $item->stats = $report;
476 + $item->active_stat = '';
192 477 }
193 478
194 - return $agents;
479 + return $items;
195 480 }
196 481
197 - private function pushAgentsReport($type, $tickets, $reports)
482 + /**
483 + * @param string|false $from
484 + * @param string|false $to
485 + * @return mixed
486 + */
487 + public function agentGroupSummary($from = false, $to = false)
198 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 + {
199 599 foreach ($tickets as $ticket) {
200 - if(!$ticket->agent_id) {
600 + $groupKey = $ticket->{$groupByField};
601 +
602 + if (!$groupKey) {
201 603 continue;
202 604 }
203 605
204 - if(!isset($reports[$ticket->agent_id])) {
205 - $reports[$ticket->agent_id] = [];
606 + if (!isset($reports[$groupKey])) {
607 + $reports[$groupKey] = [];
206 608 }
207 609
208 - $reports[$ticket->agent_id][$type] = $ticket->count;
610 + $reports[$groupKey][$type] = $ticket->count;
209 611 }
210 612
211 613 return $reports;
212 614 }
213 615
214 - public function getActiveStats()
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)
215 623 {
216 - // We will calculate the wait times for open waiting tickets
217 - $waitStat = Ticket::waitingOnly()
624 + $query = (new AgentTicketAccess())->applyMailboxRestrictionScope(
625 + Ticket::waitingOnly()
626 + )
218 627 ->where('status', '!=', 'closed')
219 - ->whereNotNull('waiting_since')
628 + ->whereNotNull('waiting_since');
629 +
630 + if ($productId) {
631 + $query->where('product_id', $productId);
632 + }
633 +
634 + $waitStat = $query
220 635 ->select([
221 636 $this->db()->raw('avg(UNIX_TIMESTAMP(waiting_since)) as avg_waiting'),
222 637 $this->db()->raw('MIN(UNIX_TIMESTAMP(waiting_since)) as max_waiting'),
223 638 $this->db()->raw('COUNT(*) as total_tickets')
@@ -246,11 +661,17 @@
246 661 'waiting_tickets' => $waitStat->total_tickets
247 662 ];
248 663 }
249 664
665 + /**
666 + * Same as getActiveStats() for one agent's tickets.
667 + *
668 + * @param $agentId
669 + * @return array|false
670 + */
250 671 public function getActiveStatByAgent($agentId)
251 672 {
252 - $waitStat = Ticket::waitingOnly()
673 + $waitStat = (new AgentTicketAccess())->applyMailboxRestrictionScope(Ticket::waitingOnly())
253 674 ->where('status', '!=', 'closed')
254 675 ->whereNotNull('waiting_since')
255 676 ->where('agent_id', $agentId)
256 677 ->select([
@@ -263,8 +684,45 @@
263 684 if(!$waitStat) {
264 685 return false;
265 686 }
266 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 + {
267 725 $waitStat->avg_waiting = intval($waitStat->avg_waiting);
268 726 if($waitStat->avg_waiting > 0) {
269 727 $waitSeconds = time() - $waitStat->avg_waiting;
270 728 if( $waitSeconds < 172800 && $waitSeconds > 7200) {
@@ -280,6 +738,239 @@
280 738 'average_waiting' => $avgWait,
281 739 'max_waiting' => (intval($waitStat->max_waiting)) ? human_time_diff(intval($waitStat->max_waiting), time()) : 0,
282 740 'waiting_tickets' => $waitStat->total_tickets
283 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;
284 975 }
285 976 }