Files
breadcrumb-the-shire/modules/helpdesk/lib/Module/Helpdesk/Service/SystemRecommendationEngine.php

470 lines
16 KiB
PHP
Raw Normal View History

<?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_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) {
$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,
];
}
}