refactor(helpdesk): remove resolution time dimension from risk score

Resolution time measures the past, not current risk. The remaining
4 dimensions (open pressure 35%, trend 30%, SLA overdue 25%,
inactivity 10%) are all current and actionable. Removes resolution
aggregation, median calculation, and weight redistribution logic.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-06 00:47:58 +02:00
parent d5d1e22e87
commit 28cb60efb3
2 changed files with 9 additions and 68 deletions

View File

@@ -9,10 +9,9 @@ namespace MintyPHP\Module\Helpdesk\Service;
final class RiskRadarService
{
// --- Score weights (must sum to 100) ---
private const W_OPEN_PRESSURE = 30;
private const W_TREND = 25;
private const W_SLA_OVERDUE = 20;
private const W_RESOLUTION = 15;
private const W_OPEN_PRESSURE = 35;
private const W_TREND = 30;
private const W_SLA_OVERDUE = 25;
private const W_INACTIVITY = 10;
// --- Level thresholds ---
@@ -63,7 +62,6 @@ final class RiskRadarService
'created_prev' => 0,
'closed_prev' => 0,
'sla_overdue_count' => 0,
'resolution_hours' => [],
'oldest_open_age_hours' => 0,
'open_tickets' => [],
];
@@ -79,9 +77,6 @@ final class RiskRadarService
if ($isClosed) {
if ($activityTs !== null && $activityTs >= $periodStart) {
$customers[$customerNo]['closed_current']++;
if ($createdTs !== null && $activityTs > $createdTs) {
$customers[$customerNo]['resolution_hours'][] = intdiv($activityTs - $createdTs, 3600);
}
}
if ($activityTs !== null && $activityTs >= $prevPeriodStart && $activityTs < $periodStart) {
$customers[$customerNo]['closed_prev']++;
@@ -176,7 +171,6 @@ final class RiskRadarService
});
$netFlow = $c['created_current'] - $c['closed_current'];
$resolutionMedian = self::median($c['resolution_hours']);
$scoredCustomers[] = [
'customer_no' => $c['customer_no'],
@@ -188,7 +182,6 @@ final class RiskRadarService
'critical' => $c['critical'],
'sla_overdue' => $c['sla_overdue_count'],
'net_flow' => $netFlow,
'resolution_median_hours' => $resolutionMedian,
'oldest_open_hours' => $c['oldest_open_age_hours'],
],
'drivers' => $topDrivers,
@@ -241,7 +234,6 @@ final class RiskRadarService
$critical = (int) $c['critical'];
$netFlow = (int) $c['created_current'] - (int) $c['closed_current'];
$slaOverdue = (int) $c['sla_overdue_count'];
$resolutionMedian = self::median($c['resolution_hours']);
$oldestAge = (int) $c['oldest_open_age_hours'];
// 1. Open Pressure: min(100, open*8 + critical*18)
@@ -263,18 +255,7 @@ final class RiskRadarService
default => 100,
};
// 4. Resolution (median hours): null→neutral, <=24:0, 25-48:35, 49-96:70, >96:100
$resolutionScore = null;
if ($resolutionMedian !== null) {
$resolutionScore = match (true) {
$resolutionMedian <= 24 => 0,
$resolutionMedian <= 48 => 35,
$resolutionMedian <= 96 => 70,
default => 100,
};
}
// 5. Inactivity (oldest open): <=24:0, 25-72:35, 73-168:70, >168:100
// 4. Inactivity (oldest open): <=24:0, 25-72:35, 73-168:70, >168:100
$inactivityScore = match (true) {
$oldestAge <= 24 => 0,
$oldestAge <= 72 => 35,
@@ -286,27 +267,11 @@ final class RiskRadarService
['id' => 'open_pressure', 'label' => 'Open pressure', 'raw_value' => $open, 'dimension_score' => $openScore, 'weight' => self::W_OPEN_PRESSURE],
['id' => 'trend', 'label' => 'Trend', 'raw_value' => $netFlow, 'dimension_score' => $trendScore, 'weight' => self::W_TREND],
['id' => 'sla_overdue', 'label' => 'SLA overdue', 'raw_value' => $slaOverdue, 'dimension_score' => $slaScore, 'weight' => self::W_SLA_OVERDUE],
['id' => 'resolution', 'label' => 'Resolution time', 'raw_value' => $resolutionMedian, 'dimension_score' => $resolutionScore, 'weight' => self::W_RESOLUTION],
['id' => 'inactivity', 'label' => 'Inactivity', 'raw_value' => $oldestAge, 'dimension_score' => $inactivityScore, 'weight' => self::W_INACTIVITY],
];
// Compute weighted points; redistribute resolution weight when null
$effectiveWeightSum = 0;
foreach ($dimensions as $d) {
if ($d['dimension_score'] !== null) {
$effectiveWeightSum += $d['weight'];
}
}
foreach ($dimensions as $i => $d) {
if ($d['dimension_score'] === null) {
$dimensions[$i]['weighted_points'] = 0.0;
} else {
$adjustedWeight = $effectiveWeightSum > 0
? ($d['weight'] / $effectiveWeightSum) * 100
: (float) $d['weight'];
$dimensions[$i]['weighted_points'] = round(($d['dimension_score'] / 100) * $adjustedWeight, 1);
}
$dimensions[$i]['weighted_points'] = round(($d['dimension_score'] / 100) * $d['weight'], 1);
}
return $dimensions;

View File

@@ -134,40 +134,16 @@ class RiskRadarServiceTest extends TestCase
$this->assertSame(0, $result['customers'][0]['kpis']['sla_overdue']);
}
public function testResolutionMedian(): void
public function testFourDimensionsInOutput(): 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
self::ticket('T1', '10610', 'Any', 10),
];
$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']);
$dimIds = array_column($result['customers'][0]['dimensions'], 'id');
$this->assertSame(['open_pressure', 'trend', 'sla_overdue', 'inactivity'], $dimIds);
}
public function testTruncatedFlagPassthrough(): void