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>
184 lines
6.7 KiB
PHP
184 lines
6.7 KiB
PHP
<?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']);
|
|
}
|
|
}
|