> $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>, * top_communication: list>, * 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 $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'] ?? '')); } }