forked from fa/breadcrumb-the-shire
Resolve all non-false-positive PHPStan 2 findings, reducing the baseline from 486 to 382 entries (only unused-public false positives remain). Categories fixed: - Remove 39 redundant type checks (is_string, is_array, is_object, method_exists) - Remove 12 no-op array_values() on already-sequential lists - Simplify 13 null-coalesce on guaranteed offsets - Remove 8 always-true instanceof checks - Replace 12 redundant test assertions (assertTrue(true) → addToAssertionCount) - Simplify 6 unnecessary nullsafe operators (?-> → ->) - Fix 6 always-true !== '' comparisons - Fix remaining: unset.offset, return.unusedType, staticMethod narrowing 53 files changed across core/, modules/, pages/, and tests/. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
470 lines
16 KiB
PHP
470 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_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,
|
|
];
|
|
}
|
|
}
|