feat(helpdesk): add dashboards, communication feed, settings UI and fix routing
Add support/sales/controlling dashboards with KPIs, trend charts and risk indicators. Add debitor communication timeline, contact filters, system recommendations engine, and configurable controlling risk rules. Rename search→index to fix query-string preservation on back navigation. Remove fake escalation rate metric, add KPI info tooltips, and switch trend chart colors to red (created) / green (closed) for clarity. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,472 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Helpdesk\Service;
|
||||
|
||||
/**
|
||||
* Rule-template based recommendation engine for support dashboard actions.
|
||||
*/
|
||||
class SystemRecommendationEngine
|
||||
{
|
||||
/** @var array<int, string> */
|
||||
private const CLOSED_TICKET_STATES = ['Erledigt', 'Resolved', 'Closed', 'Geschlossen'];
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $tickets
|
||||
* @param array<string, mixed>|null $escalationHealth
|
||||
* @param array<string, mixed> $config
|
||||
* @return array{
|
||||
* recommendations: array<int, array{
|
||||
* recommendation_id: string,
|
||||
* rule_id: string,
|
||||
* ticket_no: string,
|
||||
* message_key: string,
|
||||
* message_params: array<string, string|int|float|bool>,
|
||||
* priority: int,
|
||||
* urgency_score: int,
|
||||
* age_hours: int|null,
|
||||
* source: string
|
||||
* }>,
|
||||
* meta: array{
|
||||
* applied_rules: array<int, string>,
|
||||
* max_items: int,
|
||||
* escalation_data_available: bool
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
public function buildRecommendations(array $tickets, ?array $escalationHealth, array $config): array
|
||||
{
|
||||
$maxItems = max(1, min(20, (int) ($config['max_items'] ?? 5)));
|
||||
$rules = is_array($config['rules'] ?? null) ? $config['rules'] : [];
|
||||
|
||||
$openTickets = $this->normalizeOpenTickets($tickets);
|
||||
$openTicketCount = count($openTickets);
|
||||
|
||||
$escalationAvailable = is_array($escalationHealth) && (($escalationHealth['ok'] ?? false) === true);
|
||||
$escalationOverdueEntries = $escalationAvailable && is_array($escalationHealth['entries'] ?? null)
|
||||
? $escalationHealth['entries']
|
||||
: [];
|
||||
$escalationMatches = $escalationAvailable && is_array($escalationHealth['matches'] ?? null)
|
||||
? $escalationHealth['matches']
|
||||
: [];
|
||||
$escalationByTicket = $this->buildEscalationByTicketMap($escalationMatches);
|
||||
|
||||
/** @var array<int, array{
|
||||
* recommendation_id: string,
|
||||
* rule_id: string,
|
||||
* ticket_no: string,
|
||||
* message_key: string,
|
||||
* message_params: array<string, string|int|float|bool>,
|
||||
* priority: int,
|
||||
* urgency_score: int,
|
||||
* age_hours: int|null,
|
||||
* source: string
|
||||
* }> $candidates
|
||||
*/
|
||||
$candidates = [];
|
||||
/** @var array<string, true> $appliedRulesMap */
|
||||
$appliedRulesMap = [];
|
||||
|
||||
// Rule 1: escalation_overdue
|
||||
$rule = $this->getRuleConfig($rules, 'escalation_overdue');
|
||||
if ($rule['enabled'] && $escalationAvailable) {
|
||||
$appliedRulesMap['escalation_overdue'] = true;
|
||||
$minOverdueMinutes = max(0, (int) ($rule['min_overdue_minutes'] ?? 0));
|
||||
$minOverdueSeconds = $minOverdueMinutes * 60;
|
||||
|
||||
foreach ($escalationOverdueEntries as $entry) {
|
||||
if (!is_array($entry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ticketNo = trim((string) ($entry['ticket_no'] ?? ''));
|
||||
if ($ticketNo === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$overdueSeconds = max(0, (int) ($entry['overdue_seconds'] ?? 0));
|
||||
if ($overdueSeconds < $minOverdueSeconds) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ageHours = $openTickets[$ticketNo]['age_hours'] ?? null;
|
||||
$overdueHours = max(1, (int) ceil($overdueSeconds / 3600));
|
||||
$escalationCode = trim((string) ($entry['escalation_code'] ?? ''));
|
||||
|
||||
$candidates[] = $this->createCandidate(
|
||||
'escalation_overdue',
|
||||
$ticketNo,
|
||||
'helpdesk.recommendation.escalation_overdue',
|
||||
[
|
||||
'overdue_hours' => $overdueHours,
|
||||
'escalation_code' => $escalationCode,
|
||||
],
|
||||
(int) $rule['priority'],
|
||||
$overdueSeconds,
|
||||
$ageHours,
|
||||
'escalation'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 2: high_risk_aging
|
||||
$rule = $this->getRuleConfig($rules, 'high_risk_aging');
|
||||
if ($rule['enabled'] && $escalationAvailable) {
|
||||
$appliedRulesMap['high_risk_aging'] = true;
|
||||
$minAgeHours = max(0, (int) ($rule['min_age_hours'] ?? 8));
|
||||
$codes = $this->normalizeCodes($rule['codes'] ?? []);
|
||||
$codeMap = array_fill_keys($codes, true);
|
||||
|
||||
foreach ($openTickets as $ticketNo => $ticket) {
|
||||
$ageHours = $ticket['age_hours'];
|
||||
if (!is_int($ageHours) || $ageHours < $minAgeHours) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$match = $escalationByTicket[$ticketNo] ?? null;
|
||||
if (!is_array($match)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$escalationCode = strtoupper(trim((string) ($match['escalation_code'] ?? '')));
|
||||
if ($escalationCode === '' || !isset($codeMap[$escalationCode])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$candidates[] = $this->createCandidate(
|
||||
'high_risk_aging',
|
||||
$ticketNo,
|
||||
'helpdesk.recommendation.high_risk_aging',
|
||||
[
|
||||
'age_hours' => $ageHours,
|
||||
'escalation_code' => $escalationCode,
|
||||
],
|
||||
(int) $rule['priority'],
|
||||
$ageHours,
|
||||
$ageHours,
|
||||
'tickets'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 3: unassigned_open
|
||||
$rule = $this->getRuleConfig($rules, 'unassigned_open');
|
||||
if ($rule['enabled']) {
|
||||
$appliedRulesMap['unassigned_open'] = true;
|
||||
$minAgeHours = max(0, (int) ($rule['min_age_hours'] ?? 2));
|
||||
|
||||
foreach ($openTickets as $ticketNo => $ticket) {
|
||||
$ageHours = $ticket['age_hours'];
|
||||
if (!is_int($ageHours) || $ageHours < $minAgeHours) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($ticket['support_user_name'] !== '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$candidates[] = $this->createCandidate(
|
||||
'unassigned_open',
|
||||
$ticketNo,
|
||||
'helpdesk.recommendation.unassigned_open',
|
||||
['age_hours' => $ageHours],
|
||||
(int) $rule['priority'],
|
||||
$ageHours,
|
||||
$ageHours,
|
||||
'tickets'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 4: stale_open
|
||||
$rule = $this->getRuleConfig($rules, 'stale_open');
|
||||
if ($rule['enabled']) {
|
||||
$appliedRulesMap['stale_open'] = true;
|
||||
$minAgeHours = max(0, (int) ($rule['min_age_hours'] ?? 48));
|
||||
|
||||
foreach ($openTickets as $ticketNo => $ticket) {
|
||||
$ageHours = $ticket['age_hours'];
|
||||
if (!is_int($ageHours) || $ageHours < $minAgeHours) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$candidates[] = $this->createCandidate(
|
||||
'stale_open',
|
||||
$ticketNo,
|
||||
'helpdesk.recommendation.stale_open',
|
||||
['age_hours' => $ageHours],
|
||||
(int) $rule['priority'],
|
||||
$ageHours,
|
||||
$ageHours,
|
||||
'tickets'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 5: customer_backlog
|
||||
$rule = $this->getRuleConfig($rules, 'customer_backlog');
|
||||
if ($rule['enabled']) {
|
||||
$appliedRulesMap['customer_backlog'] = true;
|
||||
$minOpenTickets = max(1, (int) ($rule['min_open_tickets'] ?? 6));
|
||||
if ($openTicketCount >= $minOpenTickets) {
|
||||
$oldest = $this->findOldestOpenTicket($openTickets);
|
||||
if (is_array($oldest)) {
|
||||
$ageHours = $oldest['age_hours'];
|
||||
$urgencyScore = ($openTicketCount * 1000) + (is_int($ageHours) ? $ageHours : 0);
|
||||
$candidates[] = $this->createCandidate(
|
||||
'customer_backlog',
|
||||
(string) $oldest['ticket_no'],
|
||||
'helpdesk.recommendation.customer_backlog',
|
||||
['open_tickets' => $openTicketCount],
|
||||
(int) $rule['priority'],
|
||||
$urgencyScore,
|
||||
is_int($ageHours) ? $ageHours : null,
|
||||
'tickets'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$deduped = $this->dedupeByRuleAndTicket($candidates);
|
||||
usort($deduped, static function (array $a, array $b): int {
|
||||
$priorityCmp = (int) $b['priority'] <=> (int) $a['priority'];
|
||||
if ($priorityCmp !== 0) {
|
||||
return $priorityCmp;
|
||||
}
|
||||
|
||||
$urgencyCmp = (int) $b['urgency_score'] <=> (int) $a['urgency_score'];
|
||||
if ($urgencyCmp !== 0) {
|
||||
return $urgencyCmp;
|
||||
}
|
||||
|
||||
return strcmp((string) $a['ticket_no'], (string) $b['ticket_no']);
|
||||
});
|
||||
|
||||
return [
|
||||
'recommendations' => array_slice($deduped, 0, $maxItems),
|
||||
'meta' => [
|
||||
'applied_rules' => array_values(array_keys($appliedRulesMap)),
|
||||
'max_items' => $maxItems,
|
||||
'escalation_data_available' => $escalationAvailable,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $rules
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function getRuleConfig(array $rules, string $ruleId): array
|
||||
{
|
||||
$rule = $rules[$ruleId] ?? [];
|
||||
if (!is_array($rule)) {
|
||||
$rule = [];
|
||||
}
|
||||
|
||||
return $rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $matches
|
||||
* @return array<string, array{escalation_code: string, age_hours: int|null}>
|
||||
*/
|
||||
private function buildEscalationByTicketMap(array $matches): array
|
||||
{
|
||||
$map = [];
|
||||
foreach ($matches as $match) {
|
||||
$ticketNo = trim((string) ($match['ticket_no'] ?? ''));
|
||||
if ($ticketNo === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$map[$ticketNo] = [
|
||||
'escalation_code' => trim((string) ($match['escalation_code'] ?? '')),
|
||||
'age_hours' => is_numeric($match['age_hours'] ?? null) ? (int) $match['age_hours'] : null,
|
||||
];
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string>|string $codesValue
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function normalizeCodes(array|string $codesValue): array
|
||||
{
|
||||
if (is_string($codesValue)) {
|
||||
$codesValue = explode(',', $codesValue);
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
foreach ($codesValue as $code) {
|
||||
$value = strtoupper(trim((string) $code));
|
||||
if ($value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[] = $value;
|
||||
}
|
||||
|
||||
return array_values(array_unique($normalized));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{ticket_no: string, support_user_name: string, age_hours: int|null}> $openTickets
|
||||
* @return array{ticket_no: string, support_user_name: string, age_hours: int|null}|null
|
||||
*/
|
||||
private function findOldestOpenTicket(array $openTickets): ?array
|
||||
{
|
||||
$oldest = null;
|
||||
$oldestAge = -1;
|
||||
|
||||
foreach ($openTickets as $ticket) {
|
||||
$ageHours = is_int($ticket['age_hours']) ? $ticket['age_hours'] : -1;
|
||||
if ($oldest === null || $ageHours > $oldestAge) {
|
||||
$oldest = $ticket;
|
||||
$oldestAge = $ageHours;
|
||||
}
|
||||
}
|
||||
|
||||
return $oldest;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{
|
||||
* recommendation_id: string,
|
||||
* rule_id: string,
|
||||
* ticket_no: string,
|
||||
* message_key: string,
|
||||
* message_params: array<string, string|int|float|bool>,
|
||||
* priority: int,
|
||||
* urgency_score: int,
|
||||
* age_hours: int|null,
|
||||
* source: string
|
||||
* }> $candidates
|
||||
* @return array<int, array{
|
||||
* recommendation_id: string,
|
||||
* rule_id: string,
|
||||
* ticket_no: string,
|
||||
* message_key: string,
|
||||
* message_params: array<string, string|int|float|bool>,
|
||||
* priority: int,
|
||||
* urgency_score: int,
|
||||
* age_hours: int|null,
|
||||
* source: string
|
||||
* }>
|
||||
*/
|
||||
private function dedupeByRuleAndTicket(array $candidates): array
|
||||
{
|
||||
$unique = [];
|
||||
foreach ($candidates as $candidate) {
|
||||
$key = (string) $candidate['rule_id'] . '|' . (string) $candidate['ticket_no'];
|
||||
if (isset($unique[$key])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$unique[$key] = $candidate;
|
||||
}
|
||||
|
||||
return array_values($unique);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $tickets
|
||||
* @return array<string, array{ticket_no: string, support_user_name: string, age_hours: int|null}>
|
||||
*/
|
||||
private function normalizeOpenTickets(array $tickets): array
|
||||
{
|
||||
$nowTs = time();
|
||||
$openTickets = [];
|
||||
|
||||
foreach ($tickets as $ticket) {
|
||||
if (!is_array($ticket)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$state = trim((string) ($ticket['Ticket_State'] ?? ''));
|
||||
if (in_array($state, self::CLOSED_TICKET_STATES, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ticketNo = trim((string) ($ticket['No'] ?? ''));
|
||||
if ($ticketNo === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$activityTs = $this->resolveActivityTimestamp($ticket);
|
||||
$ageHours = null;
|
||||
if ($activityTs !== null) {
|
||||
$ageHours = max(0, intdiv(max(0, $nowTs - $activityTs), 3600));
|
||||
}
|
||||
|
||||
$openTickets[$ticketNo] = [
|
||||
'ticket_no' => $ticketNo,
|
||||
'support_user_name' => trim((string) ($ticket['Support_User_Name'] ?? '')),
|
||||
'age_hours' => $ageHours,
|
||||
];
|
||||
}
|
||||
|
||||
return $openTickets;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $ticket
|
||||
*/
|
||||
private function resolveActivityTimestamp(array $ticket): ?int
|
||||
{
|
||||
$lastActivity = trim((string) ($ticket['Last_Activity_Date'] ?? ''));
|
||||
if ($lastActivity !== '' && $lastActivity !== '0001-01-01T00:00:00Z') {
|
||||
$ts = strtotime($lastActivity);
|
||||
if (is_int($ts)) {
|
||||
return $ts;
|
||||
}
|
||||
}
|
||||
|
||||
$createdOn = trim((string) ($ticket['Created_On'] ?? ''));
|
||||
if ($createdOn !== '' && $createdOn !== '0001-01-01T00:00:00Z') {
|
||||
$ts = strtotime($createdOn);
|
||||
if (is_int($ts)) {
|
||||
return $ts;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string|int|float|bool> $messageParams
|
||||
* @return array{
|
||||
* recommendation_id: string,
|
||||
* rule_id: string,
|
||||
* ticket_no: string,
|
||||
* message_key: string,
|
||||
* message_params: array<string, string|int|float|bool>,
|
||||
* priority: int,
|
||||
* urgency_score: int,
|
||||
* age_hours: int|null,
|
||||
* source: string
|
||||
* }
|
||||
*/
|
||||
private function createCandidate(
|
||||
string $ruleId,
|
||||
string $ticketNo,
|
||||
string $messageKey,
|
||||
array $messageParams,
|
||||
int $priority,
|
||||
int $urgencyScore,
|
||||
?int $ageHours,
|
||||
string $source
|
||||
): array {
|
||||
return [
|
||||
'recommendation_id' => $ruleId . '-' . $ticketNo,
|
||||
'rule_id' => $ruleId,
|
||||
'ticket_no' => $ticketNo,
|
||||
'message_key' => $messageKey,
|
||||
'message_params' => $messageParams,
|
||||
'priority' => $priority,
|
||||
'urgency_score' => max(0, $urgencyScore),
|
||||
'age_hours' => $ageHours,
|
||||
'source' => $source,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user