225 lines
7.5 KiB
PHP
225 lines
7.5 KiB
PHP
|
|
<?php
|
||
|
|
|
||
|
|
namespace MintyPHP\Tests\Module\Helpdesk\Service;
|
||
|
|
|
||
|
|
use MintyPHP\Module\Helpdesk\Service\RiskRadarService;
|
||
|
|
use PHPUnit\Framework\TestCase;
|
||
|
|
|
||
|
|
class RiskRadarServiceTest extends TestCase
|
||
|
|
{
|
||
|
|
private static function ticket(
|
||
|
|
string $no,
|
||
|
|
string $customerNo,
|
||
|
|
string $customerName,
|
||
|
|
int $createdHoursAgo,
|
||
|
|
bool $closed = false,
|
||
|
|
string $escalationCode = '',
|
||
|
|
int $resolutionHours = 0
|
||
|
|
): array {
|
||
|
|
$now = time();
|
||
|
|
$createdTs = $now - ($createdHoursAgo * 3600);
|
||
|
|
$activityTs = $closed ? $createdTs + ($resolutionHours * 3600) : $createdTs;
|
||
|
|
|
||
|
|
return [
|
||
|
|
'No' => $no,
|
||
|
|
'Customer_No' => $customerNo,
|
||
|
|
'Cust_Name' => $customerName,
|
||
|
|
'Escalation_Code' => $escalationCode,
|
||
|
|
'Created_On' => gmdate('Y-m-d\TH:i:s\Z', $createdTs),
|
||
|
|
'Last_Activity_Date' => gmdate('Y-m-d\TH:i:s\Z', $activityTs),
|
||
|
|
'Process_Stage' => $closed ? 40 : 10,
|
||
|
|
'Ticket_State' => $closed ? 'Geschlossen' : 'Offen',
|
||
|
|
'Support_User_Name' => 'NKS',
|
||
|
|
'Category_1_Code' => 'FEHLER',
|
||
|
|
'Process_Code' => 'NORMAL',
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
public function testEmptyTicketsReturnsEmptyResult(): void
|
||
|
|
{
|
||
|
|
$result = RiskRadarService::buildRiskRadar([], [], 90);
|
||
|
|
|
||
|
|
$this->assertSame(0, $result['summary']['total']);
|
||
|
|
$this->assertSame([], $result['customers']);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function testSkipsTicketsWithoutCustomerNo(): void
|
||
|
|
{
|
||
|
|
$tickets = [
|
||
|
|
self::ticket('T1', '', 'No Customer', 10),
|
||
|
|
self::ticket('T2', '10610', 'Valid Customer', 10),
|
||
|
|
];
|
||
|
|
|
||
|
|
$result = RiskRadarService::buildRiskRadar($tickets, [], 90);
|
||
|
|
|
||
|
|
$this->assertSame(1, $result['summary']['total']);
|
||
|
|
$this->assertSame('10610', $result['customers'][0]['customer_no']);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function testAggregatesPerCustomerNo(): void
|
||
|
|
{
|
||
|
|
$tickets = [
|
||
|
|
self::ticket('T1', '10610', 'Kunde A', 10),
|
||
|
|
self::ticket('T2', '10610', 'Kunde A', 20),
|
||
|
|
self::ticket('T3', '10620', 'Kunde B', 5),
|
||
|
|
];
|
||
|
|
|
||
|
|
$result = RiskRadarService::buildRiskRadar($tickets, [], 90);
|
||
|
|
|
||
|
|
$this->assertSame(2, $result['summary']['total']);
|
||
|
|
$customerNos = array_column($result['customers'], 'customer_no');
|
||
|
|
$this->assertContains('10610', $customerNos);
|
||
|
|
$this->assertContains('10620', $customerNos);
|
||
|
|
|
||
|
|
$kundeA = $result['customers'][array_search('10610', $customerNos)];
|
||
|
|
$this->assertSame(2, $kundeA['kpis']['open']);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function testHighRiskLevel(): void
|
||
|
|
{
|
||
|
|
// Many open + critical tickets → high score
|
||
|
|
$tickets = [];
|
||
|
|
for ($i = 0; $i < 10; $i++) {
|
||
|
|
$tickets[] = self::ticket('T' . $i, '10610', 'Stressed', 100); // all >48h → critical
|
||
|
|
}
|
||
|
|
|
||
|
|
$result = RiskRadarService::buildRiskRadar($tickets, [], 90);
|
||
|
|
|
||
|
|
$this->assertSame('high', $result['customers'][0]['risk_level']);
|
||
|
|
$this->assertGreaterThanOrEqual(70, $result['customers'][0]['risk_score']);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function testLowRiskLevel(): void
|
||
|
|
{
|
||
|
|
// One recently closed ticket, one just opened
|
||
|
|
$tickets = [
|
||
|
|
self::ticket('T1', '10610', 'Calm', 2),
|
||
|
|
self::ticket('T2', '10610', 'Calm', 10, true, '', 2),
|
||
|
|
];
|
||
|
|
|
||
|
|
$result = RiskRadarService::buildRiskRadar($tickets, [], 90);
|
||
|
|
|
||
|
|
$this->assertSame('low', $result['customers'][0]['risk_level']);
|
||
|
|
$this->assertLessThan(40, $result['customers'][0]['risk_score']);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function testSlaOverdueDetection(): void
|
||
|
|
{
|
||
|
|
$escalationDefs = [
|
||
|
|
['Code' => 'MAX', 'Target_Time' => 'PT2H', 'No_of_Escalation_Level' => 1],
|
||
|
|
];
|
||
|
|
|
||
|
|
// Ticket created 10h ago with MAX escalation, target 2h → overdue
|
||
|
|
$tickets = [
|
||
|
|
self::ticket('T1', '10610', 'SLA Breach', 10, false, 'MAX'),
|
||
|
|
];
|
||
|
|
|
||
|
|
$result = RiskRadarService::buildRiskRadar($tickets, $escalationDefs, 90);
|
||
|
|
|
||
|
|
$this->assertSame(1, $result['customers'][0]['kpis']['sla_overdue']);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function testSlaNotOverdueWhenWithinTarget(): void
|
||
|
|
{
|
||
|
|
$escalationDefs = [
|
||
|
|
['Code' => 'NORMAL', 'Target_Time' => 'P30D', 'No_of_Escalation_Level' => 1],
|
||
|
|
];
|
||
|
|
|
||
|
|
$tickets = [
|
||
|
|
self::ticket('T1', '10610', 'Within SLA', 10, false, 'NORMAL'),
|
||
|
|
];
|
||
|
|
|
||
|
|
$result = RiskRadarService::buildRiskRadar($tickets, $escalationDefs, 90);
|
||
|
|
|
||
|
|
$this->assertSame(0, $result['customers'][0]['kpis']['sla_overdue']);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function testResolutionMedian(): void
|
||
|
|
{
|
||
|
|
$tickets = [
|
||
|
|
self::ticket('T1', '10610', 'Fast', 100, true, '', 10),
|
||
|
|
self::ticket('T2', '10610', 'Fast', 100, true, '', 20),
|
||
|
|
self::ticket('T3', '10610', 'Fast', 100, true, '', 30),
|
||
|
|
self::ticket('T4', '10610', 'Fast', 5), // one open
|
||
|
|
];
|
||
|
|
|
||
|
|
$result = RiskRadarService::buildRiskRadar($tickets, [], 90);
|
||
|
|
|
||
|
|
$this->assertSame(20.0, $result['customers'][0]['kpis']['resolution_median_hours']);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function testResolutionNullRedistributesWeight(): void
|
||
|
|
{
|
||
|
|
// Only open tickets, no closed → resolution is null
|
||
|
|
$tickets = [
|
||
|
|
self::ticket('T1', '10610', 'Open Only', 10),
|
||
|
|
];
|
||
|
|
|
||
|
|
$result = RiskRadarService::buildRiskRadar($tickets, [], 90);
|
||
|
|
|
||
|
|
$customer = $result['customers'][0];
|
||
|
|
$resolutionDim = null;
|
||
|
|
foreach ($customer['dimensions'] as $d) {
|
||
|
|
if ($d['id'] === 'resolution') {
|
||
|
|
$resolutionDim = $d;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
$this->assertNotNull($resolutionDim);
|
||
|
|
$this->assertNull($resolutionDim['dimension_score']);
|
||
|
|
$this->assertSame(0.0, $resolutionDim['weighted_points']);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function testTruncatedFlagPassthrough(): void
|
||
|
|
{
|
||
|
|
$tickets = [self::ticket('T1', '10610', 'Any', 5)];
|
||
|
|
|
||
|
|
$result = RiskRadarService::buildRiskRadar($tickets, [], 90, true);
|
||
|
|
|
||
|
|
$this->assertTrue($result['meta']['truncated']);
|
||
|
|
$this->assertSame('medium', $result['meta']['confidence']);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function testSortsByScoreDescending(): void
|
||
|
|
{
|
||
|
|
$tickets = [
|
||
|
|
self::ticket('T1', '10610', 'Low Risk', 2),
|
||
|
|
self::ticket('T2', '10620', 'High Risk', 100),
|
||
|
|
self::ticket('T3', '10620', 'High Risk', 100),
|
||
|
|
self::ticket('T4', '10620', 'High Risk', 100),
|
||
|
|
];
|
||
|
|
|
||
|
|
$result = RiskRadarService::buildRiskRadar($tickets, [], 90);
|
||
|
|
|
||
|
|
$this->assertSame('10620', $result['customers'][0]['customer_no']);
|
||
|
|
$this->assertSame('10610', $result['customers'][1]['customer_no']);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function testTopDriversSortedByWeightedPoints(): void
|
||
|
|
{
|
||
|
|
$tickets = [];
|
||
|
|
for ($i = 0; $i < 8; $i++) {
|
||
|
|
$tickets[] = self::ticket('T' . $i, '10610', 'Heavy', 200);
|
||
|
|
}
|
||
|
|
|
||
|
|
$result = RiskRadarService::buildRiskRadar($tickets, [], 90);
|
||
|
|
|
||
|
|
$drivers = $result['customers'][0]['drivers'];
|
||
|
|
$this->assertCount(3, $drivers);
|
||
|
|
$this->assertGreaterThanOrEqual($drivers[1]['weighted_points'], $drivers[0]['weighted_points']);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function testExcludesInactiveCustomers(): void
|
||
|
|
{
|
||
|
|
// Ticket created and closed outside the period window
|
||
|
|
$tickets = [
|
||
|
|
self::ticket('T1', '10610', 'Old', 3000, true, '', 10),
|
||
|
|
];
|
||
|
|
|
||
|
|
$result = RiskRadarService::buildRiskRadar($tickets, [], 30);
|
||
|
|
|
||
|
|
// Customer had no activity in the 30-day window and no open tickets
|
||
|
|
$this->assertSame(0, $result['summary']['total']);
|
||
|
|
}
|
||
|
|
}
|