Introduce per-tenant override for helpdesk BC connection config. New table helpdesk_tenant_settings stores tenant-specific credentials with encrypted secrets (AES-256-GCM). EffectiveHelpdeskSettingsService resolves global vs tenant config per request. Settings UI extended with tenant override tab, toggle, and dual editing mode. All runtime consumers (OData, SOAP, OAuth) read through the new resolver. Token cache flushed on any config change. Default behavior unchanged — tenants use global config until override explicitly enabled. Also includes risk radar UI refinements: Stripe-style card redesign with accent borders and score pills, removal of redundant KPI tiles and search, and filtering of zero-score entries. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
386 lines
14 KiB
PHP
386 lines
14 KiB
PHP
<?php
|
||
|
||
namespace MintyPHP\Module\Helpdesk\Service;
|
||
|
||
/**
|
||
* Risk Radar scoring engine — aggregates tickets per customer and computes
|
||
* a 0–100 risk score from five weighted dimensions.
|
||
*/
|
||
final class RiskRadarService
|
||
{
|
||
// --- Score weights (must sum to 100) ---
|
||
private const W_OPEN_PRESSURE = 35;
|
||
private const W_TREND = 30;
|
||
private const W_SLA_OVERDUE = 25;
|
||
private const W_INACTIVITY = 10;
|
||
|
||
// --- Level thresholds ---
|
||
private const LEVEL_HIGH = 55;
|
||
private const LEVEL_MEDIUM = 25;
|
||
|
||
private const CLOSED_STATES = ['Erledigt', 'Resolved', 'Closed', 'Geschlossen'];
|
||
|
||
/**
|
||
* Build the complete risk radar dataset.
|
||
*
|
||
* @param array<int, array<string, mixed>> $tickets Raw OData tickets
|
||
* @param array<int, array<string, mixed>> $escalationDefs Escalation definitions with Code + Target_Time
|
||
* @param int $periodDays One of 30, 90, 180, 365
|
||
* @param bool $truncated Whether the ticket set was capped
|
||
*
|
||
* @return array{summary: array<string, int>, customers: array<int, array<string, mixed>>, meta: array<string, mixed>}
|
||
*/
|
||
public static function buildRiskRadar(
|
||
array $tickets,
|
||
array $escalationDefs,
|
||
int $periodDays,
|
||
bool $truncated = false
|
||
): array {
|
||
$nowTs = time();
|
||
$periodStart = $nowTs - ($periodDays * 86400);
|
||
$prevPeriodStart = $periodStart - ($periodDays * 86400);
|
||
|
||
// Build escalation target map: Code → seconds
|
||
$slaTargets = self::buildSlaTargetMap($escalationDefs);
|
||
|
||
// Aggregate per customer
|
||
$customers = [];
|
||
foreach ($tickets as $ticket) {
|
||
$customerNo = trim((string) ($ticket['Customer_No'] ?? ''));
|
||
if ($customerNo === '') {
|
||
continue;
|
||
}
|
||
|
||
if (!isset($customers[$customerNo])) {
|
||
$customers[$customerNo] = [
|
||
'customer_no' => $customerNo,
|
||
'customer_name' => trim((string) ($ticket['Cust_Name'] ?? '')),
|
||
'open' => 0,
|
||
'critical' => 0,
|
||
'created_current' => 0,
|
||
'closed_current' => 0,
|
||
'created_prev' => 0,
|
||
'closed_prev' => 0,
|
||
'sla_overdue_count' => 0,
|
||
'oldest_open_age_hours' => 0,
|
||
'open_tickets' => [],
|
||
];
|
||
}
|
||
|
||
$state = trim((string) ($ticket['Ticket_State'] ?? ''));
|
||
$isClosed = in_array($state, self::CLOSED_STATES, true)
|
||
|| (isset($ticket['Process_Stage']) && (int) $ticket['Process_Stage'] === 40);
|
||
|
||
$createdTs = self::parseTimestamp((string) ($ticket['Created_On'] ?? ''));
|
||
$activityTs = self::resolveActivityTimestamp($ticket);
|
||
|
||
if ($isClosed) {
|
||
if ($activityTs !== null && $activityTs >= $periodStart) {
|
||
$customers[$customerNo]['closed_current']++;
|
||
}
|
||
if ($activityTs !== null && $activityTs >= $prevPeriodStart && $activityTs < $periodStart) {
|
||
$customers[$customerNo]['closed_prev']++;
|
||
}
|
||
if ($createdTs !== null && $createdTs >= $periodStart) {
|
||
$customers[$customerNo]['created_current']++;
|
||
}
|
||
if ($createdTs !== null && $createdTs >= $prevPeriodStart && $createdTs < $periodStart) {
|
||
$customers[$customerNo]['created_prev']++;
|
||
}
|
||
} else {
|
||
$customers[$customerNo]['open']++;
|
||
|
||
if ($createdTs !== null && $createdTs >= $periodStart) {
|
||
$customers[$customerNo]['created_current']++;
|
||
}
|
||
if ($createdTs !== null && $createdTs >= $prevPeriodStart && $createdTs < $periodStart) {
|
||
$customers[$customerNo]['created_prev']++;
|
||
}
|
||
|
||
// Critical: open > 48h
|
||
if ($activityTs !== null) {
|
||
$ageHours = intdiv(max(0, $nowTs - $activityTs), 3600);
|
||
if ($ageHours > 48) {
|
||
$customers[$customerNo]['critical']++;
|
||
}
|
||
if ($ageHours > $customers[$customerNo]['oldest_open_age_hours']) {
|
||
$customers[$customerNo]['oldest_open_age_hours'] = $ageHours;
|
||
}
|
||
}
|
||
|
||
// SLA overdue check
|
||
$escalationCode = trim((string) ($ticket['Escalation_Code'] ?? ''));
|
||
if ($escalationCode !== '' && $createdTs !== null && isset($slaTargets[$escalationCode])) {
|
||
$elapsed = $nowTs - $createdTs;
|
||
if ($elapsed > $slaTargets[$escalationCode]) {
|
||
$customers[$customerNo]['sla_overdue_count']++;
|
||
}
|
||
}
|
||
|
||
// Collect open ticket info for detail dialog
|
||
$customers[$customerNo]['open_tickets'][] = [
|
||
'no' => trim((string) ($ticket['No'] ?? '')),
|
||
'state' => $state,
|
||
'age_hours' => $activityTs !== null ? intdiv(max(0, $nowTs - $activityTs), 3600) : 0,
|
||
'escalation_code' => $escalationCode,
|
||
'category' => trim((string) ($ticket['Category_1_Code'] ?? '')),
|
||
];
|
||
}
|
||
}
|
||
|
||
// Score each customer
|
||
$scoredCustomers = [];
|
||
$summaryHigh = 0;
|
||
$summaryMedium = 0;
|
||
$summaryLow = 0;
|
||
|
||
foreach ($customers as $customerNo => $c) {
|
||
$hasActivity = $c['open'] > 0 || $c['created_current'] > 0 || $c['closed_current'] > 0;
|
||
if (!$hasActivity) {
|
||
continue;
|
||
}
|
||
|
||
$dimensions = self::scoreDimensions($c);
|
||
$totalScore = self::computeTotalScore($dimensions);
|
||
if ($totalScore === 0) {
|
||
continue;
|
||
}
|
||
$level = $totalScore >= self::LEVEL_HIGH ? 'high'
|
||
: ($totalScore >= self::LEVEL_MEDIUM ? 'medium' : 'low');
|
||
|
||
if ($level === 'high') {
|
||
$summaryHigh++;
|
||
} elseif ($level === 'medium') {
|
||
$summaryMedium++;
|
||
} else {
|
||
$summaryLow++;
|
||
}
|
||
|
||
// Top-3 drivers sorted by weighted contribution
|
||
$drivers = $dimensions;
|
||
usort($drivers, static fn (array $a, array $b): int => $b['weighted_points'] <=> $a['weighted_points']);
|
||
$topDrivers = array_slice($drivers, 0, 3);
|
||
|
||
// Sort open tickets: critical first, then by age
|
||
$openTickets = $c['open_tickets'];
|
||
usort($openTickets, static function (array $a, array $b): int {
|
||
$aCrit = $a['age_hours'] > 48 ? 1 : 0;
|
||
$bCrit = $b['age_hours'] > 48 ? 1 : 0;
|
||
if ($aCrit !== $bCrit) {
|
||
return $bCrit <=> $aCrit;
|
||
}
|
||
|
||
return $b['age_hours'] <=> $a['age_hours'];
|
||
});
|
||
|
||
$netFlow = $c['created_current'] - $c['closed_current'];
|
||
|
||
$scoredCustomers[] = [
|
||
'customer_no' => $c['customer_no'],
|
||
'customer_name' => $c['customer_name'],
|
||
'risk_score' => $totalScore,
|
||
'risk_level' => $level,
|
||
'kpis' => [
|
||
'open' => $c['open'],
|
||
'critical' => $c['critical'],
|
||
'sla_overdue' => $c['sla_overdue_count'],
|
||
'net_flow' => $netFlow,
|
||
'oldest_open_hours' => $c['oldest_open_age_hours'],
|
||
],
|
||
'drivers' => $topDrivers,
|
||
'dimensions' => $dimensions,
|
||
'open_tickets' => array_slice($openTickets, 0, 10),
|
||
];
|
||
}
|
||
|
||
// Sort: score desc, sla_overdue desc, open desc, customer_no asc
|
||
usort($scoredCustomers, static function (array $a, array $b): int {
|
||
if ($a['risk_score'] !== $b['risk_score']) {
|
||
return $b['risk_score'] <=> $a['risk_score'];
|
||
}
|
||
if ($a['kpis']['sla_overdue'] !== $b['kpis']['sla_overdue']) {
|
||
return $b['kpis']['sla_overdue'] <=> $a['kpis']['sla_overdue'];
|
||
}
|
||
if ($a['kpis']['open'] !== $b['kpis']['open']) {
|
||
return $b['kpis']['open'] <=> $a['kpis']['open'];
|
||
}
|
||
|
||
return strcmp($a['customer_no'], $b['customer_no']);
|
||
});
|
||
|
||
return [
|
||
'summary' => [
|
||
'total' => count($scoredCustomers),
|
||
'high' => $summaryHigh,
|
||
'medium' => $summaryMedium,
|
||
'low' => $summaryLow,
|
||
],
|
||
'customers' => $scoredCustomers,
|
||
'meta' => [
|
||
'period_days' => $periodDays,
|
||
'tickets_total' => count($tickets),
|
||
'truncated' => $truncated,
|
||
'confidence' => $truncated ? 'medium' : 'high',
|
||
],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Score all five dimensions for a single customer.
|
||
*
|
||
* @param array<string, mixed> $c Customer aggregate data
|
||
* @return array<int, array{id: string, label: string, raw_value: int|float|null, dimension_score: int, weight: int, weighted_points: float}>
|
||
*/
|
||
private static function scoreDimensions(array $c): array
|
||
{
|
||
$open = (int) $c['open'];
|
||
$critical = (int) $c['critical'];
|
||
$netFlow = (int) $c['created_current'] - (int) $c['closed_current'];
|
||
$slaOverdue = (int) $c['sla_overdue_count'];
|
||
$oldestAge = (int) $c['oldest_open_age_hours'];
|
||
|
||
// 1. Open Pressure: min(100, open*8 + critical*18)
|
||
$openScore = min(100, $open * 8 + $critical * 18);
|
||
|
||
// 2. Trend (net flow): <=0:0, 1-2:35, 3-5:70, >=6:100
|
||
$trendScore = match (true) {
|
||
$netFlow <= 0 => 0,
|
||
$netFlow <= 2 => 35,
|
||
$netFlow <= 5 => 70,
|
||
default => 100,
|
||
};
|
||
|
||
// 3. SLA Overdue: 0:0, 1:50, 2:75, >=3:100
|
||
$slaScore = match (true) {
|
||
$slaOverdue === 0 => 0,
|
||
$slaOverdue === 1 => 50,
|
||
$slaOverdue === 2 => 75,
|
||
default => 100,
|
||
};
|
||
|
||
// 4. Inactivity (oldest open): <=24:0, 25-72:35, 73-168:70, >168:100
|
||
$inactivityScore = match (true) {
|
||
$oldestAge <= 24 => 0,
|
||
$oldestAge <= 72 => 35,
|
||
$oldestAge <= 168 => 70,
|
||
default => 100,
|
||
};
|
||
|
||
$dimensions = [
|
||
['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' => 'inactivity', 'label' => 'Inactivity', 'raw_value' => $oldestAge, 'dimension_score' => $inactivityScore, 'weight' => self::W_INACTIVITY],
|
||
];
|
||
|
||
foreach ($dimensions as $i => $d) {
|
||
$dimensions[$i]['weighted_points'] = round(($d['dimension_score'] / 100) * $d['weight'], 1);
|
||
}
|
||
|
||
return $dimensions;
|
||
}
|
||
|
||
/**
|
||
* Compute total score from dimensions (sum of weighted points, capped at 100).
|
||
*
|
||
* @param array<int, array<string, mixed>> $dimensions
|
||
*/
|
||
private static function computeTotalScore(array $dimensions): int
|
||
{
|
||
$total = 0.0;
|
||
foreach ($dimensions as $d) {
|
||
$total += (float) ($d['weighted_points'] ?? 0);
|
||
}
|
||
|
||
return min(100, (int) round($total));
|
||
}
|
||
|
||
/**
|
||
* @param array<int, array<string, mixed>> $escalationDefs
|
||
* @return array<string, int> Code → target seconds
|
||
*/
|
||
private static function buildSlaTargetMap(array $escalationDefs): array
|
||
{
|
||
$map = [];
|
||
foreach ($escalationDefs as $def) {
|
||
$code = trim((string) ($def['Code'] ?? ''));
|
||
$targetTime = trim((string) ($def['Target_Time'] ?? ''));
|
||
if ($code === '' || $targetTime === '') {
|
||
continue;
|
||
}
|
||
|
||
$seconds = self::parseIsoDuration($targetTime);
|
||
if ($seconds !== null && $seconds > 0) {
|
||
$map[$code] = $seconds;
|
||
}
|
||
}
|
||
|
||
return $map;
|
||
}
|
||
|
||
private static function parseIsoDuration(string $value): ?int
|
||
{
|
||
$value = strtoupper(trim($value));
|
||
if ($value === '' || !str_starts_with($value, 'P')) {
|
||
return null;
|
||
}
|
||
|
||
if (!preg_match('/^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/', $value, $m)) {
|
||
return null;
|
||
}
|
||
|
||
$days = isset($m[1]) && $m[1] !== '' ? (int) $m[1] : 0;
|
||
$hours = isset($m[2]) && $m[2] !== '' ? (int) $m[2] : 0;
|
||
$minutes = isset($m[3]) && $m[3] !== '' ? (int) $m[3] : 0;
|
||
$seconds = isset($m[4]) && $m[4] !== '' ? (int) floor((float) $m[4]) : 0;
|
||
|
||
return ($days * 86400) + ($hours * 3600) + ($minutes * 60) + $seconds;
|
||
}
|
||
|
||
/**
|
||
* @param array<int, int> $values
|
||
*/
|
||
private static function median(array $values): ?float
|
||
{
|
||
if ($values === []) {
|
||
return null;
|
||
}
|
||
|
||
sort($values);
|
||
$count = count($values);
|
||
$mid = intdiv($count, 2);
|
||
|
||
return $count % 2 === 0
|
||
? round(($values[$mid - 1] + $values[$mid]) / 2, 1)
|
||
: (float) $values[$mid];
|
||
}
|
||
|
||
private static function parseTimestamp(string $value): ?int
|
||
{
|
||
$value = trim($value);
|
||
if ($value === '' || str_starts_with($value, '0001-01-01')) {
|
||
return null;
|
||
}
|
||
|
||
$ts = strtotime($value);
|
||
|
||
return is_int($ts) ? $ts : null;
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $ticket
|
||
*/
|
||
private static function resolveActivityTimestamp(array $ticket): ?int
|
||
{
|
||
$lastActivity = trim((string) ($ticket['Last_Activity_Date'] ?? ''));
|
||
if ($lastActivity !== '' && !str_starts_with($lastActivity, '0001-01-01')) {
|
||
$ts = strtotime($lastActivity);
|
||
if (is_int($ts)) {
|
||
return $ts;
|
||
}
|
||
}
|
||
|
||
return self::parseTimestamp((string) ($ticket['Created_On'] ?? ''));
|
||
}
|
||
}
|