1
0
Files
breadcrumb-the-shire/modules/helpdesk/lib/Module/Helpdesk/Service/SystemRecommendationEngine.php
fs 2f0f407268 feat(helpdesk): redesign dashboards with KPI groups, skeleton loading, and unified spacing
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>
2026-04-05 16:32:34 +02:00

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,
];
}
}