Restructure Support dashboard into 2 KPI groups (Tickets + Contracts) with section dividers, merge escalations and recommendations into "Attention needed". Redesign Sales dashboard with clickable contract timeline and dialog. Add CSS shimmer skeleton loading for all dashboard tabs and aside. Unify vertical rhythm via * + * sibling combinator on tab content containers. Remove ~265 lines of dead CSS (contract cards, escalation styles) and unused JS functions. Refactor hydrateTicketCategoryFilter into generic hydrateSelectFilter. Fix role="button" CSS bleed from shell and remove extra future year from timeline. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
473 lines
16 KiB
PHP
473 lines
16 KiB
PHP
<?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($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,
|
|
];
|
|
}
|
|
}
|