Add support/sales/controlling dashboards with KPIs, trend charts and risk indicators. Add debitor communication timeline, contact filters, system recommendations engine, and configurable controlling risk rules. Rename search→index to fix query-string preservation on back navigation. Remove fake escalation rate metric, add KPI info tooltips, and switch trend chart colors to red (created) / green (closed) for clarity. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
140 lines
6.3 KiB
PHP
140 lines
6.3 KiB
PHP
<?php
|
|
|
|
namespace MintyPHP\Tests\Module\Helpdesk\Service;
|
|
|
|
use MintyPHP\Module\Helpdesk\Service\SystemRecommendationEngine;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
class SystemRecommendationEngineTest extends TestCase
|
|
{
|
|
public function testBuildRecommendationsAppliesRulesSortsAndLimits(): void
|
|
{
|
|
$engine = new SystemRecommendationEngine();
|
|
$config = [
|
|
'max_items' => 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<string, mixed>
|
|
*/
|
|
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');
|
|
}
|
|
}
|