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