*/ private const CLOSED_TICKET_STATES = ['Erledigt', 'Resolved', 'Closed', 'Geschlossen']; /** * @param array> $tickets * @param array|null $escalationHealth * @param array $config * @return array{ * recommendations: array, * priority: int, * urgency_score: int, * age_hours: int|null, * source: string * }>, * meta: array{ * applied_rules: array, * 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, * priority: int, * urgency_score: int, * age_hours: int|null, * source: string * }> $candidates */ $candidates = []; /** @var array $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 $rules * @return array */ private function getRuleConfig(array $rules, string $ruleId): array { $rule = $rules[$ruleId] ?? []; if (!is_array($rule)) { $rule = []; } return $rule; } /** * @param array> $matches * @return array */ 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|string $codesValue * @return array */ 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 $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, * priority: int, * urgency_score: int, * age_hours: int|null, * source: string * }> $candidates * @return array, * 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> $tickets * @return array */ 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 $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 $messageParams * @return array{ * recommendation_id: string, * rule_id: string, * ticket_no: string, * message_key: string, * message_params: array, * 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, ]; } }