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

View File

@@ -97,6 +97,11 @@ final class DebitorCacheControl
return 'module.helpdesk.risk-radar.v1.' . $tenantScope . '.' . $periodDays;
}
public static function engagementKey(string $tenantScope, int $periodDays): string
{
return 'module.helpdesk.engagement.v1.' . $tenantScope . '.' . $periodDays;
}
/** @api Used by BcODataGateway::fetchUpdateTickets() */
public static function updateTicketsKey(string $tenantScope): string
{

View File

@@ -24,6 +24,13 @@ class HelpdeskSettingsGateway
public const KEY_OAUTH_TOKEN_ENDPOINT = 'helpdesk.bc_oauth_token_endpoint';
public const KEY_SYSTEM_RECOMMENDATIONS_CONFIG = 'helpdesk.system_recommendations_config';
public const KEY_CONTROLLING_RISK_CONFIG = 'helpdesk.controlling_risk_config';
/** @api Read in helpdesk/settings + analytics pages */
public const KEY_INACTIVITY_THRESHOLD_DAYS = 'helpdesk.inactivity_threshold_days';
/** @api Default surfaced in views + tests */
public const DEFAULT_INACTIVITY_THRESHOLD_DAYS = 60;
private const MIN_INACTIVITY_THRESHOLD_DAYS = 7;
private const MAX_INACTIVITY_THRESHOLD_DAYS = 365;
public const AUTH_MODE_BASIC = 'basic';
public const AUTH_MODE_OAUTH2 = 'oauth2';
@@ -518,6 +525,49 @@ class HelpdeskSettingsGateway
return $normalized;
}
/**
* Days of silence after which a customer is flagged as "no contact".
* Clamped to a sane range; falls back to the default on invalid/empty values.
*
* @api Used by helpdesk/analytics + helpdesk/settings pages
*/
public function getInactivityThresholdDays(): int
{
$value = $this->settingsMetadataGateway->getValue(self::KEY_INACTIVITY_THRESHOLD_DAYS);
$value = trim((string) ($value ?? ''));
if ($value === '' || !is_numeric($value)) {
return self::DEFAULT_INACTIVITY_THRESHOLD_DAYS;
}
return max(
self::MIN_INACTIVITY_THRESHOLD_DAYS,
min(self::MAX_INACTIVITY_THRESHOLD_DAYS, (int) $value)
);
}
/** @api Used by the helpdesk/settings page */
public function setInactivityThresholdDays(?int $days): bool
{
if ($days === null) {
return $this->settingsMetadataGateway->set(
self::KEY_INACTIVITY_THRESHOLD_DAYS,
null,
'helpdesk.inactivity_threshold_days'
);
}
$clamped = max(
self::MIN_INACTIVITY_THRESHOLD_DAYS,
min(self::MAX_INACTIVITY_THRESHOLD_DAYS, $days)
);
return $this->settingsMetadataGateway->set(
self::KEY_INACTIVITY_THRESHOLD_DAYS,
(string) $clamped,
'helpdesk.inactivity_threshold_days'
);
}
/**
* Build the full OData entity URL for a given entity set.
*/