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,235 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Helpdesk\Service;
|
||||
|
||||
/**
|
||||
* Customer engagement analytics — aggregates communication signals per customer
|
||||
* to surface who is actively engaged and who has gone quiet ("kein Kontakt").
|
||||
*
|
||||
* "Communication" is any ticket activity in the period (a created or updated
|
||||
* ticket counts as contact). The most recent ticket activity date is the
|
||||
* customer's "last contact". Customers whose last contact is older than the
|
||||
* configured inactivity threshold are flagged as silent so the team can decide
|
||||
* to reach out proactively.
|
||||
*
|
||||
* Pure transform (no I/O, no DI): the data endpoint fetches the global ticket
|
||||
* snapshot and passes it in, this aggregates and scores. Mirrors the design of
|
||||
* RiskRadarService (per-customer aggregation over one global ticket pull).
|
||||
*/
|
||||
final class CustomerEngagementService
|
||||
{
|
||||
private const SECONDS_PER_DAY = 86400;
|
||||
|
||||
/**
|
||||
* Build the complete engagement dataset.
|
||||
*
|
||||
* @api Used by the helpdesk/analytics-data endpoint
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $tickets Raw OData tickets (global snapshot)
|
||||
* @param int $periodDays Look-back window in days (e.g. 30/60/90/180/365)
|
||||
* @param int $inactivityDays Silence threshold; last contact older than this => "no contact"
|
||||
* @param int $nowTs Current unix timestamp (injected for testability)
|
||||
* @param bool $truncated Whether the ticket set was capped
|
||||
*
|
||||
* @return array{
|
||||
* summary: array{total_customers: int, active: int, silent: int, no_contact_ever: int, tickets_total: int},
|
||||
* silent: list<array<string, mixed>>,
|
||||
* top_communication: list<array<string, mixed>>,
|
||||
* meta: array{period_days: int, inactivity_days: int, tickets_total: int, truncated: bool, confidence: string, generated_ts: int}
|
||||
* }
|
||||
*/
|
||||
public static function buildEngagement(
|
||||
array $tickets,
|
||||
int $periodDays,
|
||||
int $inactivityDays,
|
||||
int $nowTs,
|
||||
bool $truncated = false
|
||||
): array {
|
||||
$periodStart = $nowTs - ($periodDays * self::SECONDS_PER_DAY);
|
||||
$inactivityCutoff = $nowTs - ($inactivityDays * self::SECONDS_PER_DAY);
|
||||
|
||||
// Aggregate per customer across the whole snapshot.
|
||||
$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'] ?? '')),
|
||||
'ticket_count_period' => 0,
|
||||
'ticket_count_total' => 0,
|
||||
'last_contact_ts' => null,
|
||||
];
|
||||
}
|
||||
|
||||
// Keep the first non-empty name we encounter.
|
||||
if ($customers[$customerNo]['customer_name'] === '') {
|
||||
$customers[$customerNo]['customer_name'] = trim((string) ($ticket['Cust_Name'] ?? ''));
|
||||
}
|
||||
|
||||
$customers[$customerNo]['ticket_count_total']++;
|
||||
|
||||
$activityTs = self::resolveActivityTimestamp($ticket);
|
||||
if ($activityTs !== null) {
|
||||
if ($activityTs >= $periodStart) {
|
||||
$customers[$customerNo]['ticket_count_period']++;
|
||||
}
|
||||
if ($customers[$customerNo]['last_contact_ts'] === null
|
||||
|| $activityTs > $customers[$customerNo]['last_contact_ts']) {
|
||||
$customers[$customerNo]['last_contact_ts'] = $activityTs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$active = 0;
|
||||
$silent = 0;
|
||||
$noContactEver = 0;
|
||||
$silentRows = [];
|
||||
$topRows = [];
|
||||
|
||||
foreach ($customers as $c) {
|
||||
$lastContactTs = $c['last_contact_ts'];
|
||||
$daysSinceContact = $lastContactTs !== null
|
||||
? intdiv(max(0, $nowTs - $lastContactTs), self::SECONDS_PER_DAY)
|
||||
: null;
|
||||
|
||||
$status = self::classify($lastContactTs, $inactivityCutoff);
|
||||
if ($status === 'active') {
|
||||
$active++;
|
||||
} elseif ($status === 'no_contact_ever') {
|
||||
$noContactEver++;
|
||||
} else {
|
||||
$silent++;
|
||||
}
|
||||
|
||||
$row = [
|
||||
'customer_no' => $c['customer_no'],
|
||||
'customer_name' => $c['customer_name'],
|
||||
'ticket_count_period' => $c['ticket_count_period'],
|
||||
'ticket_count_total' => $c['ticket_count_total'],
|
||||
'last_contact_ts' => $lastContactTs,
|
||||
'last_contact_iso' => $lastContactTs !== null ? gmdate('Y-m-d', $lastContactTs) : null,
|
||||
'days_since_contact' => $daysSinceContact,
|
||||
'status' => $status,
|
||||
];
|
||||
|
||||
if ($status !== 'active') {
|
||||
$silentRows[] = $row;
|
||||
}
|
||||
$topRows[] = $row;
|
||||
}
|
||||
|
||||
// Silent list: longest silence first; "never" customers sink to the bottom
|
||||
// (no actionable last-contact date). Tie-break by customer_no.
|
||||
usort($silentRows, static function (array $a, array $b): int {
|
||||
$aDays = $a['days_since_contact'];
|
||||
$bDays = $b['days_since_contact'];
|
||||
if ($aDays === null && $bDays === null) {
|
||||
return strcmp((string) $a['customer_no'], (string) $b['customer_no']);
|
||||
}
|
||||
if ($aDays === null) {
|
||||
return 1;
|
||||
}
|
||||
if ($bDays === null) {
|
||||
return -1;
|
||||
}
|
||||
if ($aDays !== $bDays) {
|
||||
return $bDays <=> $aDays;
|
||||
}
|
||||
|
||||
return strcmp((string) $a['customer_no'], (string) $b['customer_no']);
|
||||
});
|
||||
|
||||
// Top by communication: most tickets in the period first, then total,
|
||||
// then most recent contact. Tie-break by customer_no.
|
||||
usort($topRows, static function (array $a, array $b): int {
|
||||
if ($a['ticket_count_period'] !== $b['ticket_count_period']) {
|
||||
return $b['ticket_count_period'] <=> $a['ticket_count_period'];
|
||||
}
|
||||
if ($a['ticket_count_total'] !== $b['ticket_count_total']) {
|
||||
return $b['ticket_count_total'] <=> $a['ticket_count_total'];
|
||||
}
|
||||
$aTs = $a['last_contact_ts'] ?? 0;
|
||||
$bTs = $b['last_contact_ts'] ?? 0;
|
||||
if ($aTs !== $bTs) {
|
||||
return $bTs <=> $aTs;
|
||||
}
|
||||
|
||||
return strcmp((string) $a['customer_no'], (string) $b['customer_no']);
|
||||
});
|
||||
|
||||
// Top list only makes sense for customers with communication in the period.
|
||||
$topRows = array_values(array_filter(
|
||||
$topRows,
|
||||
static fn (array $r): bool => $r['ticket_count_period'] > 0
|
||||
));
|
||||
|
||||
return [
|
||||
'summary' => [
|
||||
'total_customers' => count($customers),
|
||||
'active' => $active,
|
||||
'silent' => $silent,
|
||||
'no_contact_ever' => $noContactEver,
|
||||
'tickets_total' => count($tickets),
|
||||
],
|
||||
'silent' => array_slice($silentRows, 0, 100),
|
||||
'top_communication' => array_slice($topRows, 0, 25),
|
||||
'meta' => [
|
||||
'period_days' => $periodDays,
|
||||
'inactivity_days' => $inactivityDays,
|
||||
'tickets_total' => count($tickets),
|
||||
'truncated' => $truncated,
|
||||
'confidence' => $truncated ? 'medium' : 'high',
|
||||
'generated_ts' => $nowTs,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a customer's engagement from their last contact timestamp.
|
||||
*
|
||||
* @return 'active'|'silent'|'no_contact_ever'
|
||||
*/
|
||||
private static function classify(?int $lastContactTs, int $inactivityCutoff): string
|
||||
{
|
||||
if ($lastContactTs === null) {
|
||||
return 'no_contact_ever';
|
||||
}
|
||||
|
||||
return $lastContactTs >= $inactivityCutoff ? 'active' : 'silent';
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Most recent signal for a ticket: prefer Last_Activity_Date, fall back to Created_On.
|
||||
*
|
||||
* @param array<string, mixed> $ticket
|
||||
*/
|
||||
private static function resolveActivityTimestamp(array $ticket): ?int
|
||||
{
|
||||
$lastActivity = trim((string) ($ticket['Last_Activity_Date'] ?? ''));
|
||||
if (!str_starts_with($lastActivity, '0001-01-01')) {
|
||||
$ts = strtotime($lastActivity);
|
||||
if (is_int($ts)) {
|
||||
return $ts;
|
||||
}
|
||||
}
|
||||
|
||||
return self::parseTimestamp((string) ($ticket['Created_On'] ?? ''));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user