5, 'rules' => [ 'escalation_overdue' => ['enabled' => true, 'priority' => 100, 'min_overdue_minutes' => 0], 'high_risk_aging' => ['enabled' => true, 'priority' => 90, 'codes' => ['MAX', 'HOCH'], 'min_age_hours' => 8], 'unassigned_open' => ['enabled' => true, 'priority' => 80, 'min_age_hours' => 2], 'stale_open' => ['enabled' => true, 'priority' => 70, 'min_age_hours' => 48], 'customer_backlog' => ['enabled' => true, 'priority' => 60, 'min_open_tickets' => 3], ], ]; $tickets = [ $this->openTicket('T001', 80, 'USR1'), $this->openTicket('T002', 10, 'USR2'), $this->openTicket('T003', 6, ''), $this->openTicket('T004', 60, 'USR3'), $this->openTicket('T005', 5, 'USR4'), ['No' => 'T999', 'Ticket_State' => 'Closed', 'Last_Activity_Date' => $this->isoHoursAgo(120)], ]; $escalationHealth = [ 'ok' => true, 'entries' => [ ['ticket_no' => 'T001', 'escalation_code' => 'MAX', 'overdue_seconds' => 72000], ['ticket_no' => 'T004', 'escalation_code' => 'NORMAL', 'overdue_seconds' => 600], ['ticket_no' => 'T001', 'escalation_code' => 'MAX', 'overdue_seconds' => 72000], // duplicate ], 'matches' => [ ['ticket_no' => 'T001', 'escalation_code' => 'MAX', 'age_hours' => 80, 'is_overdue' => true], ['ticket_no' => 'T002', 'escalation_code' => 'HOCH', 'age_hours' => 10, 'is_overdue' => false], ['ticket_no' => 'T004', 'escalation_code' => 'NORMAL', 'age_hours' => 60, 'is_overdue' => true], ], ]; $result = $engine->buildRecommendations($tickets, $escalationHealth, $config); $this->assertCount(5, $result['recommendations']); $this->assertSame('escalation_overdue', $result['recommendations'][0]['rule_id']); $this->assertSame('T001', $result['recommendations'][0]['ticket_no']); $this->assertSame('escalation_overdue', $result['recommendations'][1]['rule_id']); $this->assertSame('high_risk_aging', $result['recommendations'][2]['rule_id']); $this->assertSame('helpdesk.recommendation.high_risk_aging', $result['recommendations'][2]['message_key']); $this->assertSame( ['escalation_overdue', 'high_risk_aging', 'unassigned_open', 'stale_open', 'customer_backlog'], $result['meta']['applied_rules'] ); $this->assertTrue($result['meta']['escalation_data_available']); } public function testBuildRecommendationsSkipsEscalationRulesWhenUnavailable(): void { $engine = new SystemRecommendationEngine(); $config = [ 'max_items' => 10, 'rules' => [ 'escalation_overdue' => ['enabled' => true, 'priority' => 100, 'min_overdue_minutes' => 0], 'high_risk_aging' => ['enabled' => true, 'priority' => 90, 'codes' => ['MAX', 'HOCH'], 'min_age_hours' => 8], 'unassigned_open' => ['enabled' => true, 'priority' => 80, 'min_age_hours' => 2], 'stale_open' => ['enabled' => true, 'priority' => 70, 'min_age_hours' => 48], 'customer_backlog' => ['enabled' => true, 'priority' => 60, 'min_open_tickets' => 1], ], ]; $tickets = [ $this->openTicket('T010', 72, ''), ]; $result = $engine->buildRecommendations($tickets, null, $config); $ruleIds = array_map(static fn (array $item): string => (string) $item['rule_id'], $result['recommendations']); $this->assertFalse($result['meta']['escalation_data_available']); $this->assertNotContains('escalation_overdue', $ruleIds); $this->assertNotContains('high_risk_aging', $ruleIds); $this->assertContains('unassigned_open', $ruleIds); $this->assertContains('stale_open', $ruleIds); $this->assertContains('customer_backlog', $ruleIds); } public function testBuildRecommendationsCreatesBacklogRecommendationWithOldestTicket(): void { $engine = new SystemRecommendationEngine(); $config = [ 'max_items' => 5, 'rules' => [ 'escalation_overdue' => ['enabled' => false, 'priority' => 100, 'min_overdue_minutes' => 0], 'high_risk_aging' => ['enabled' => false, 'priority' => 90, 'codes' => ['MAX', 'HOCH'], 'min_age_hours' => 8], 'unassigned_open' => ['enabled' => false, 'priority' => 80, 'min_age_hours' => 2], 'stale_open' => ['enabled' => false, 'priority' => 70, 'min_age_hours' => 48], 'customer_backlog' => ['enabled' => true, 'priority' => 60, 'min_open_tickets' => 3], ], ]; $tickets = [ $this->openTicket('T100', 12, 'A'), $this->openTicket('T101', 44, 'B'), $this->openTicket('T102', 9, 'C'), ]; $result = $engine->buildRecommendations($tickets, null, $config); $this->assertCount(1, $result['recommendations']); $this->assertSame('customer_backlog', $result['recommendations'][0]['rule_id']); $this->assertSame('T101', $result['recommendations'][0]['ticket_no']); $this->assertSame(3, $result['recommendations'][0]['message_params']['open_tickets']); } /** * @return array */ private function openTicket(string $ticketNo, int $ageHours, string $supportUserName): array { return [ 'No' => $ticketNo, 'Ticket_State' => 'Open', 'Support_User_Name' => $supportUserName, 'Last_Activity_Date' => $this->isoHoursAgo($ageHours), ]; } private function isoHoursAgo(int $hours): string { $dt = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); $dt = $dt->modify('-' . max(0, $hours) . ' hours'); return $dt->format('Y-m-d\\TH:i:00\\Z'); } }