feat(helpdesk): add customer engagement analytics page [skip ci]

New "Customer analytics" page under Monitoring: surfaces who is actively in
contact vs. who has gone quiet, so the team can reach out before a customer
slips away.

- KPI tiles: total / active / no-contact / never-in-contact
- "Customers without recent contact" — longest silence first
- "Top customers by communication" — most ticket activity in the period
- Configurable no-contact threshold (helpdesk settings, default 60 days)

CustomerEngagementService aggregates the global ticket snapshot per customer
(same single-pull model as RiskRadarService — no N+1 to BC). Last contact is
the most recent ticket activity; silence beyond the threshold flags the
customer. Revenue ranking deferred (BC OData exposes no per-customer ledger).

All within modules/helpdesk/. Tests + PHPStan green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
aminovfariz
2026-06-25 10:30:05 +02:00
parent 2924407450
commit 14da36e83a
16 changed files with 1150 additions and 46 deletions

View File

@@ -0,0 +1,183 @@
<?php
namespace MintyPHP\Tests\Module\Helpdesk\Service;
use MintyPHP\Module\Helpdesk\Service\CustomerEngagementService;
use PHPUnit\Framework\TestCase;
class CustomerEngagementServiceTest extends TestCase
{
// Fixed "now" so tests are deterministic: 2026-06-25 12:00:00 UTC.
private const NOW_TS = 1782475200;
/**
* @return array<string, mixed>
*/
private static function ticket(
string $no,
string $customerNo,
string $customerName,
int $createdDaysAgo,
?int $lastActivityDaysAgo = null
): array {
$createdTs = self::NOW_TS - ($createdDaysAgo * 86400);
$activityTs = $lastActivityDaysAgo !== null
? self::NOW_TS - ($lastActivityDaysAgo * 86400)
: $createdTs;
return [
'No' => $no,
'Customer_No' => $customerNo,
'Cust_Name' => $customerName,
'Created_On' => gmdate('Y-m-d\TH:i:s\Z', $createdTs),
'Last_Activity_Date' => gmdate('Y-m-d\TH:i:s\Z', $activityTs),
'Ticket_State' => 'Offen',
];
}
public function testEmptyTicketsReturnsEmptyResult(): void
{
$result = CustomerEngagementService::buildEngagement([], 90, 60, self::NOW_TS);
$this->assertSame(0, $result['summary']['total_customers']);
$this->assertSame(0, $result['summary']['active']);
$this->assertSame(0, $result['summary']['silent']);
$this->assertSame([], $result['silent']);
$this->assertSame([], $result['top_communication']);
}
public function testSkipsTicketsWithoutCustomerNo(): void
{
$tickets = [
self::ticket('T1', '', 'No Customer', 5),
self::ticket('T2', '10610', 'Valid', 5),
];
$result = CustomerEngagementService::buildEngagement($tickets, 90, 60, self::NOW_TS);
$this->assertSame(1, $result['summary']['total_customers']);
}
public function testAggregatesTicketCountsPerCustomer(): void
{
$tickets = [
self::ticket('T1', '10610', 'Kunde A', 5),
self::ticket('T2', '10610', 'Kunde A', 10),
self::ticket('T3', '10620', 'Kunde B', 3),
];
$result = CustomerEngagementService::buildEngagement($tickets, 90, 60, self::NOW_TS);
$this->assertSame(2, $result['summary']['total_customers']);
$top = $result['top_communication'];
$byCustomer = [];
foreach ($top as $row) {
$byCustomer[$row['customer_no']] = $row;
}
$this->assertSame(2, $byCustomer['10610']['ticket_count_period']);
$this->assertSame(1, $byCustomer['10620']['ticket_count_period']);
}
public function testActiveCustomerWithinThreshold(): void
{
// Last contact 10 days ago, threshold 60 => active.
$tickets = [self::ticket('T1', '10610', 'Kunde A', 10)];
$result = CustomerEngagementService::buildEngagement($tickets, 90, 60, self::NOW_TS);
$this->assertSame(1, $result['summary']['active']);
$this->assertSame(0, $result['summary']['silent']);
$this->assertSame([], $result['silent']);
}
public function testSilentCustomerBeyondThreshold(): void
{
// Last contact 90 days ago, threshold 60 => silent.
$tickets = [self::ticket('T1', '10610', 'Kunde A', 90)];
$result = CustomerEngagementService::buildEngagement($tickets, 365, 60, self::NOW_TS);
$this->assertSame(0, $result['summary']['active']);
$this->assertSame(1, $result['summary']['silent']);
$this->assertCount(1, $result['silent']);
$this->assertSame('10610', $result['silent'][0]['customer_no']);
$this->assertSame('silent', $result['silent'][0]['status']);
$this->assertSame(90, $result['silent'][0]['days_since_contact']);
}
public function testLastContactUsesMostRecentActivity(): void
{
// Two tickets: one created 100 days ago but updated 5 days ago, one created 50 days ago.
// Most recent activity is 5 days ago => active.
$tickets = [
self::ticket('T1', '10610', 'Kunde A', 100, 5),
self::ticket('T2', '10610', 'Kunde A', 50, 50),
];
$result = CustomerEngagementService::buildEngagement($tickets, 365, 60, self::NOW_TS);
$this->assertSame(1, $result['summary']['active']);
$row = $result['top_communication'][0];
$this->assertSame(5, $row['days_since_contact']);
}
public function testSilentListSortedByLongestSilenceFirst(): void
{
$tickets = [
self::ticket('T1', '10610', 'Recently Silent', 70),
self::ticket('T2', '10620', 'Long Silent', 200),
self::ticket('T3', '10630', 'Mid Silent', 120),
];
$result = CustomerEngagementService::buildEngagement($tickets, 365, 60, self::NOW_TS);
$order = array_column($result['silent'], 'customer_no');
$this->assertSame(['10620', '10630', '10610'], $order);
}
public function testTopCommunicationExcludesCustomersWithNoPeriodActivity(): void
{
// Ticket older than the 30-day period => not counted in top list.
$tickets = [
self::ticket('T1', '10610', 'Active', 5),
self::ticket('T2', '10620', 'Old', 100),
];
$result = CustomerEngagementService::buildEngagement($tickets, 30, 60, self::NOW_TS);
$tops = array_column($result['top_communication'], 'customer_no');
$this->assertContains('10610', $tops);
$this->assertNotContains('10620', $tops);
}
public function testIgnoresUninitializedBcDates(): void
{
// BC sentinel date 0001-01-01 must be treated as "no contact".
$tickets = [[
'No' => 'T1',
'Customer_No' => '10610',
'Cust_Name' => 'Kunde A',
'Created_On' => '0001-01-01T00:00:00Z',
'Last_Activity_Date' => '0001-01-01T00:00:00Z',
'Ticket_State' => 'Offen',
]];
$result = CustomerEngagementService::buildEngagement($tickets, 90, 60, self::NOW_TS);
$this->assertSame(1, $result['summary']['no_contact_ever']);
$this->assertNull($result['silent'][0]['last_contact_ts']);
$this->assertSame('no_contact_ever', $result['silent'][0]['status']);
}
public function testMetaReflectsInputs(): void
{
$result = CustomerEngagementService::buildEngagement([], 180, 45, self::NOW_TS, true);
$this->assertSame(180, $result['meta']['period_days']);
$this->assertSame(45, $result['meta']['inactivity_days']);
$this->assertTrue($result['meta']['truncated']);
$this->assertSame('medium', $result['meta']['confidence']);
$this->assertSame(self::NOW_TS, $result['meta']['generated_ts']);
}
}

View File

@@ -78,6 +78,53 @@ class HelpdeskSettingsGatewayTest extends TestCase
$this->assertNull($gateway->getBasicPassword());
}
public function testGetInactivityThresholdDaysDefaultsWhenUnset(): void
{
$settings = $this->createMock(SettingRepositoryInterface::class);
$settings->method('getValue')->willReturn(null);
$gateway = $this->createGateway($settings);
$this->assertSame(
HelpdeskSettingsGateway::DEFAULT_INACTIVITY_THRESHOLD_DAYS,
$gateway->getInactivityThresholdDays()
);
}
public function testGetInactivityThresholdDaysClampsToRange(): void
{
$settings = $this->createMock(SettingRepositoryInterface::class);
$settings->method('getValue')->willReturn('999');
$gateway = $this->createGateway($settings);
// Clamped to the max (365).
$this->assertSame(365, $gateway->getInactivityThresholdDays());
}
public function testGetInactivityThresholdDaysFallsBackOnNonNumeric(): void
{
$settings = $this->createMock(SettingRepositoryInterface::class);
$settings->method('getValue')->willReturn('not-a-number');
$gateway = $this->createGateway($settings);
$this->assertSame(
HelpdeskSettingsGateway::DEFAULT_INACTIVITY_THRESHOLD_DAYS,
$gateway->getInactivityThresholdDays()
);
}
public function testSetInactivityThresholdDaysClampsAndPersists(): void
{
$settings = $this->createMock(SettingRepositoryInterface::class);
$settings->expects($this->once())
->method('set')
->with(HelpdeskSettingsGateway::KEY_INACTIVITY_THRESHOLD_DAYS, '7', 'helpdesk.inactivity_threshold_days')
->willReturn(true);
$gateway = $this->createGateway($settings);
// 1 is below the minimum (7) => clamped up.
$this->assertTrue($gateway->setInactivityThresholdDays(1));
}
public function testSetBasicPasswordEncryptsAndPersists(): void
{
$settings = $this->createMock(SettingRepositoryInterface::class);