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,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'] ?? ''));
}
}