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:
@@ -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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user