1
0
Files
breadcrumb-the-shire/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php

1729 lines
62 KiB
PHP
Raw Normal View History

<?php
namespace MintyPHP\Module\Helpdesk\Service;
/**
* Service for loading debtor detail data (master data, contacts, tickets).
*
* Provides both a combined loadDetail() method (legacy) and individual
* methods for async loading via data endpoints.
*/
class DebitorDetailService
{
/** @var array<int, string> */
private const CLOSED_TICKET_STATES = ['Erledigt', 'Resolved', 'Closed', 'Geschlossen'];
public function __construct(
private readonly BcODataGateway $bcODataGateway,
private readonly HelpdeskSettingsGateway $settingsGateway
) {
}
/**
* Load only the customer master data (1 OData call).
*
* Used by the main page load contacts and tickets are loaded async.
*
* @return array{status: string, customer?: array<string, mixed>, error?: string}
*/
public function loadCustomer(string $customerNo): array
{
$customerNo = trim($customerNo);
if ($customerNo === '') {
return ['status' => 'not_found'];
}
if (!$this->settingsGateway->isConfigured()) {
return ['status' => 'not_configured', 'error' => 'BC connection not configured'];
}
try {
$customer = $this->bcODataGateway->getCustomer($customerNo);
} catch (\Throwable) {
return ['status' => 'error', 'error' => 'BC connection failed'];
}
if ($customer === null) {
return ['status' => 'not_found'];
}
return [
'status' => 'success',
'customer' => $customer,
];
}
/**
* Load contacts for a customer (for async data endpoint).
*
* @return array{
* ok: bool,
* contacts?: array<int, array<string, mixed>>,
* meta?: array{
* source: string,
* fallback_used: bool,
* fallback_reason: string
* },
* error?: string
* }
*/
public function loadContacts(string $customerNo, string $customerName): array
{
$customerNo = trim($customerNo);
$customerName = trim($customerName);
if ($customerNo === '' || $customerName === '') {
return ['ok' => false, 'error' => 'Missing customer number or name'];
}
if (!$this->settingsGateway->isConfigured()) {
return ['ok' => false, 'error' => 'BC connection not configured'];
}
try {
$result = $this->bcODataGateway->getContactsForCustomerWithMeta($customerNo, $customerName);
} catch (\Throwable $e) {
return ['ok' => false, 'error' => $e->getMessage()];
}
$contacts = $result['contacts'];
$meta = $result['meta'];
return [
'ok' => true,
'contacts' => $contacts,
'meta' => [
'source' => $meta['source'],
'fallback_used' => $meta['fallback_used'],
'fallback_reason' => $meta['fallback_reason'],
],
];
}
/**
* Load tickets for a customer (for async data endpoint).
*
* @return array{
* ok: bool,
* tickets?: array<int, array<string, mixed>>,
* meta?: array{
* source: string,
* fallback_used: bool,
* fallback_reason: string
* },
* error?: string
* }
*/
public function loadTickets(string $customerNo, string $customerName): array
{
$customerNo = trim($customerNo);
$customerName = trim($customerName);
if ($customerNo === '' || $customerName === '') {
return ['ok' => false, 'error' => 'Missing customer number or name'];
}
if (!$this->settingsGateway->isConfigured()) {
return ['ok' => false, 'error' => 'BC connection not configured'];
}
try {
$result = $this->bcODataGateway->getTicketsForCustomerWithMeta($customerNo, $customerName);
} catch (\Throwable $e) {
return ['ok' => false, 'error' => $e->getMessage()];
}
$tickets = $result['tickets'];
$meta = $result['meta'];
return [
'ok' => true,
'tickets' => $tickets,
'meta' => [
'source' => $meta['source'],
'fallback_used' => $meta['fallback_used'],
'fallback_reason' => $meta['fallback_reason'],
],
];
}
/**
* Load a summary of ticket KPIs for a customer (exact counts, not sampled).
*
* @return array{ok: bool, total?: int, open?: int, last_activity?: string, error?: string}
*/
public function loadTicketSummary(string $customerNo, string $customerName): array
{
$result = $this->loadTickets($customerNo, $customerName);
if (!$result['ok']) {
return $result;
}
return self::summarizeTickets($result['tickets'] ?? []);
}
/**
* Load support dashboard data for a debtor.
*
* @return array{
* ok: bool,
* health?: array{
* total_tickets: int,
* open_tickets: int,
* critical_tickets: int,
* oldest_open_age_hours: int|null,
* escalation_count: int|null,
* escalation_available: bool
* },
* actions?: array<int, array{
* action_id: string,
* type: string,
* priority: int,
* ticket_no: string,
* age_hours: int|null
* }>,
* meta?: array{
* critical_threshold_hours: int,
* tickets_considered: int
* },
* error?: string
* }
*/
public function loadSupportDashboard(string $customerNo, string $customerName, int $criticalThresholdHours = 48): array
{
$result = $this->loadTickets($customerNo, $customerName);
if (!$result['ok']) {
return [
'ok' => false,
'error' => (string) $result['error'],
];
}
$tickets = is_array($result['tickets'] ?? null) ? $result['tickets'] : [];
$escalation = $this->loadEscalationHealth($customerNo, $tickets);
if (!$escalation['ok']) {
$escalation = null;
}
return self::buildSupportDashboard($tickets, $criticalThresholdHours, is_array($escalation) ? $escalation : null);
}
/**
* Load escalation health for a debtor by matching ticket escalation codes
* against escalation target definitions.
*
* @param array<int, array<string, mixed>> $tickets Current ticket list used in support dashboard
* @return array{
* ok: bool,
* overdue_count?: int,
* entries?: array<int, array{
* ticket_no: string,
* escalation_code: string,
* target_seconds: int,
* last_activity_iso: string,
* overdue_seconds: int
* }>,
* matches?: array<int, array{
* ticket_no: string,
* escalation_code: string,
* target_seconds: int,
* age_hours: int,
* is_overdue: bool
* }>,
* tickets_considered?: int,
* tickets_matched?: int,
* definitions_count?: int,
* error?: string
* }
*/
public function loadEscalationHealth(string $customerNo, array $tickets): array
{
$customerNo = trim($customerNo);
if ($customerNo === '') {
return ['ok' => false, 'error' => 'Missing customer number'];
}
if (!$this->settingsGateway->isConfigured()) {
return ['ok' => false, 'error' => 'BC connection not configured'];
}
try {
$definitions = $this->bcODataGateway->getEscalationDefinitions();
$ticketEscalations = $this->bcODataGateway->getEscalationTicketsForCustomer($customerNo);
} catch (\Throwable $e) {
return ['ok' => false, 'error' => $e->getMessage()];
}
if ($definitions === []) {
return ['ok' => false, 'error' => 'No escalation definitions available'];
}
$targetSecondsByCode = [];
foreach ($definitions as $definition) {
$code = trim((string) ($definition['Code'] ?? ''));
if ($code === '') {
continue;
}
$targetSeconds = self::parseIsoDurationToSeconds((string) ($definition['Target_Time'] ?? ''));
if ($targetSeconds === null || $targetSeconds <= 0) {
continue;
}
$targetSecondsByCode[$code] = $targetSeconds;
}
if ($targetSecondsByCode === []) {
return ['ok' => false, 'error' => 'No valid escalation targets available'];
}
$escalationByTicketNo = [];
foreach ($ticketEscalations as $ticketEscalation) {
$ticketNo = trim((string) ($ticketEscalation['No'] ?? ''));
if ($ticketNo === '') {
continue;
}
$escalationByTicketNo[$ticketNo] = $ticketEscalation;
}
$ticketsConsidered = 0;
$ticketsMatched = 0;
$overdueCount = 0;
$entries = [];
$matches = [];
$nowTs = time();
foreach ($tickets as $ticket) {
$state = trim((string) ($ticket['Ticket_State'] ?? ''));
if (self::isClosedTicketState($state)) {
continue;
}
$ticketNo = trim((string) ($ticket['No'] ?? ''));
if ($ticketNo === '') {
continue;
}
$ticketsConsidered++;
$escalationTicket = $escalationByTicketNo[$ticketNo] ?? null;
if (!is_array($escalationTicket)) {
continue;
}
$escalationCode = trim((string) ($escalationTicket['Escalation_Code'] ?? ''));
if ($escalationCode === '') {
continue;
}
$targetSeconds = $targetSecondsByCode[$escalationCode] ?? null;
if (!is_int($targetSeconds)) {
continue;
}
$activityTs = self::resolveTicketActivityTimestamp($ticket);
if ($activityTs === null) {
continue;
}
$ticketsMatched++;
$ageSeconds = max(0, $nowTs - $activityTs);
$ageHours = max(0, intdiv($ageSeconds, 3600));
$isOverdue = $ageSeconds > $targetSeconds;
$matches[] = [
'ticket_no' => $ticketNo,
'escalation_code' => $escalationCode,
'target_seconds' => $targetSeconds,
'age_hours' => $ageHours,
'is_overdue' => $isOverdue,
];
if ($isOverdue) {
$overdueCount++;
$lastActivityIso = trim((string) ($ticket['Last_Activity_Date'] ?? ''));
if ($lastActivityIso === '' || $lastActivityIso === '0001-01-01T00:00:00Z') {
$lastActivityIso = trim((string) ($ticket['Created_On'] ?? ''));
}
$entries[] = [
'ticket_no' => $ticketNo,
'escalation_code' => $escalationCode,
'target_seconds' => $targetSeconds,
'last_activity_iso' => $lastActivityIso,
'overdue_seconds' => max(1, $ageSeconds - $targetSeconds),
];
}
}
usort($entries, static function (array $a, array $b): int {
$cmp = $b['overdue_seconds'] <=> $a['overdue_seconds'];
if ($cmp !== 0) {
return $cmp;
}
return strcmp($a['ticket_no'], $b['ticket_no']);
});
return [
'ok' => true,
'overdue_count' => $overdueCount,
'entries' => array_slice($entries, 0, 50),
'matches' => array_slice($matches, 0, 300),
'tickets_considered' => $ticketsConsidered,
'tickets_matched' => $ticketsMatched,
'definitions_count' => count($targetSecondsByCode),
];
}
/**
* Load customer contracts/products for support dashboard widgets.
*
* @return array{
* ok: bool,
* entries?: array<int, array{
* contract_no: string,
* product_type: string,
* description: string,
* state: string,
* next_invoicing_date: string,
* sell_to_customer_no: string,
* sell_to_customer_name: string,
* bill_to_customer_no: string,
* bill_to_customer_name: string,
* total_payoff_amount: float,
* active_positions: int,
* support_hours: float
* }>,
* summary?: array{
* total_contracts: int,
* active_contracts: int,
* product_types: array<int, array{type: string, count: int}>
* },
* error?: string
* }
*/
public function loadContracts(string $customerNo): array
{
$customerNo = trim($customerNo);
if ($customerNo === '') {
return ['ok' => false, 'error' => 'Missing customer number'];
}
if (!$this->settingsGateway->isConfigured()) {
return ['ok' => false, 'error' => 'BC connection not configured'];
}
try {
$contracts = $this->bcODataGateway->getContractsForCustomer($customerNo);
} catch (\Throwable $e) {
return ['ok' => false, 'error' => $e->getMessage()];
}
$normalized = self::buildContractsWidgetData($contracts, $customerNo);
return [
'ok' => true,
'entries' => $normalized['entries'],
'summary' => $normalized['summary'],
];
}
/**
* Aggregate ticket KPIs from a raw ticket array.
*
* Used by both the service method and the data endpoint (which has its own cache layer).
*
* @param array<int, array<string, mixed>> $tickets
* @return array{ok: true, total: int, open: int, last_activity: string}
*/
public static function summarizeTickets(array $tickets): array
{
$openCount = 0;
$lastActivity = '';
foreach ($tickets as $ticket) {
$state = (string) ($ticket['Ticket_State'] ?? '');
if (!self::isClosedTicketState($state)) {
$openCount++;
}
$activityDate = (string) ($ticket['Last_Activity_Date'] ?? '');
if ($activityDate !== '' && $activityDate !== '0001-01-01T00:00:00Z' && $activityDate > $lastActivity) {
$lastActivity = $activityDate;
}
}
return [
'ok' => true,
'total' => count($tickets),
'open' => $openCount,
'last_activity' => $lastActivity,
];
}
/**
* @param array<int, array<string, mixed>> $tickets
* @return array{
* ok: true,
* health: array{
* total_tickets: int,
* open_tickets: int,
* critical_tickets: int,
* oldest_open_age_hours: int|null,
* escalation_count: int|null,
* escalation_available: bool
* },
* actions: array<int, array{
* action_id: string,
* type: string,
* priority: int,
* ticket_no: string,
* age_hours: int|null
* }>,
* meta: array{
* critical_threshold_hours: int,
* tickets_considered: int,
* escalation_tickets_considered?: int,
* escalation_tickets_matched?: int,
* escalation_definitions_count?: int
* }
* }
*/
public static function buildSupportDashboard(array $tickets, int $criticalThresholdHours = 48, ?array $escalationHealth = null): array
{
$criticalThresholdHours = max(1, min(168, $criticalThresholdHours));
$nowTs = time();
$totalTickets = count($tickets);
$openTickets = [];
$criticalTickets = 0;
$oldestOpenAgeHours = 0;
foreach ($tickets as $ticket) {
$state = trim((string) ($ticket['Ticket_State'] ?? ''));
if (self::isClosedTicketState($state)) {
continue;
}
$ticketNo = trim((string) ($ticket['No'] ?? ''));
if ($ticketNo === '') {
continue;
}
$activityTs = self::resolveTicketActivityTimestamp($ticket);
$ageHours = null;
if ($activityTs !== null) {
$ageHours = max(0, intdiv(max(0, $nowTs - $activityTs), 3600));
$oldestOpenAgeHours = max($oldestOpenAgeHours, $ageHours);
if ($ageHours >= $criticalThresholdHours) {
$criticalTickets++;
}
}
$openTickets[] = [
'ticket_no' => $ticketNo,
'support_user_name' => trim((string) ($ticket['Support_User_Name'] ?? '')),
'age_hours' => $ageHours,
];
}
// Trend KPIs: 30-day windows
$trendDays = 30;
$boundary30 = $nowTs - ($trendDays * 86400);
$boundary60 = $nowTs - (2 * $trendDays * 86400);
$createdCurrent = 0;
$createdPrevious = 0;
$closedCurrent = 0;
$closedPrevious = 0;
$openAgeSum = 0;
$openAgeCount = 0;
foreach ($tickets as $ticket) {
$createdRaw = trim((string) ($ticket['Created_On'] ?? ''));
$createdTs = ($createdRaw !== '' && !str_starts_with($createdRaw, '0001')) ? strtotime($createdRaw) : false;
if ($createdTs !== false) {
if ($createdTs >= $boundary30) {
$createdCurrent++;
} elseif ($createdTs >= $boundary60) {
$createdPrevious++;
}
}
$state = trim((string) ($ticket['Ticket_State'] ?? ''));
if (self::isClosedTicketState($state)) {
$activityTs = self::resolveTicketActivityTimestamp($ticket);
if ($activityTs !== null) {
if ($activityTs >= $boundary30) {
$closedCurrent++;
} elseif ($activityTs >= $boundary60) {
$closedPrevious++;
}
}
}
}
foreach ($openTickets as $openTicket) {
if ($openTicket['age_hours'] !== null) {
$openAgeSum += $openTicket['age_hours'];
$openAgeCount++;
}
}
$avgOpenAgeHours = $openAgeCount > 0 ? (int) round($openAgeSum / $openAgeCount) : null;
$escalationCount = null;
$escalationAvailable = false;
$meta = [
'critical_threshold_hours' => $criticalThresholdHours,
'tickets_considered' => $totalTickets,
];
if (is_array($escalationHealth) && ($escalationHealth['ok'] ?? false)) {
$escalationCount = (int) ($escalationHealth['overdue_count'] ?? 0);
$escalationAvailable = true;
$meta['escalation_tickets_considered'] = (int) ($escalationHealth['tickets_considered'] ?? 0);
$meta['escalation_tickets_matched'] = (int) ($escalationHealth['tickets_matched'] ?? 0);
$meta['escalation_definitions_count'] = (int) ($escalationHealth['definitions_count'] ?? 0);
}
return [
'ok' => true,
'health' => [
'total_tickets' => $totalTickets,
'open_tickets' => count($openTickets),
'critical_tickets' => $criticalTickets,
'oldest_open_age_hours' => $openTickets === [] ? null : $oldestOpenAgeHours,
'avg_open_age_hours' => $avgOpenAgeHours,
'created_30d' => $createdCurrent,
'created_prev_30d' => $createdPrevious,
'closed_30d' => $closedCurrent,
'closed_prev_30d' => $closedPrevious,
'escalation_count' => $escalationCount,
'escalation_available' => $escalationAvailable,
],
'actions' => self::buildSupportActions($openTickets, $criticalThresholdHours),
'meta' => $meta,
];
}
/**
* @param array<int, array<string, mixed>> $contracts
* @return array{
* entries: array<int, array{
* contract_no: string,
* product_type: string,
* description: string,
* state: string,
* next_invoicing_date: string,
* sell_to_customer_no: string,
* sell_to_customer_name: string,
* bill_to_customer_no: string,
* bill_to_customer_name: string,
* total_payoff_amount: float,
* active_positions: int,
* support_hours: float
* }>,
* summary: array{
* total_contracts: int,
* active_contracts: int,
* product_types: array<int, array{type: string, count: int}>
* }
* }
*/
public static function buildContractsWidgetData(array $contracts, string $customerNo): array
{
$customerNo = trim($customerNo);
$entries = [];
$productTypeCounts = [];
$activeContracts = 0;
foreach ($contracts as $contract) {
$contractNo = trim((string) ($contract['No'] ?? ''));
if ($contractNo === '') {
continue;
}
$billToNo = trim((string) ($contract['Bill_to_Cust_No'] ?? ''));
$sellToNo = trim((string) ($contract['Sell_to_Cust_No'] ?? ''));
if ($customerNo !== '' && $billToNo !== $customerNo && $sellToNo !== $customerNo) {
continue;
}
$state = trim((string) ($contract['State'] ?? ''));
if (mb_strtolower($state) === 'aktiv') {
$activeContracts++;
}
$productType = trim((string) ($contract['Periodic_Invoicing_Type'] ?? ''));
if ($productType !== '') {
$productTypeCounts[$productType] = (int) ($productTypeCounts[$productType] ?? 0) + 1;
}
$nextInvoicingDate = trim((string) ($contract['Next_Invoicing_Date'] ?? ''));
if ($nextInvoicingDate === '0001-01-01' || $nextInvoicingDate === '0001-01-01T00:00:00Z') {
$nextInvoicingDate = '';
}
$entries[] = [
'contract_no' => $contractNo,
'product_type' => $productType,
'description' => trim((string) ($contract['Description'] ?? '')),
'state' => $state,
'next_invoicing_date' => $nextInvoicingDate,
'sell_to_customer_no' => $sellToNo,
'sell_to_customer_name' => trim((string) ($contract['Sell_to_Cust_Name'] ?? '')),
'bill_to_customer_no' => $billToNo,
'bill_to_customer_name' => trim((string) ($contract['Bill_to_Cust_Name'] ?? '')),
'total_payoff_amount' => (float) ($contract['TotalPayoffAmount'] ?? 0),
'active_positions' => (int) ($contract['No_of_Active_Position'] ?? 0),
'support_hours' => (float) ($contract['Support_Hours'] ?? 0),
];
}
usort($entries, static function (array $a, array $b): int {
$aActive = mb_strtolower((string) $a['state']) === 'aktiv' ? 1 : 0;
$bActive = mb_strtolower((string) $b['state']) === 'aktiv' ? 1 : 0;
if ($aActive !== $bActive) {
return $bActive <=> $aActive;
}
$aDate = (string) $a['next_invoicing_date'];
$bDate = (string) $b['next_invoicing_date'];
if ($aDate === '' && $bDate !== '') {
return 1;
}
if ($aDate !== '' && $bDate === '') {
return -1;
}
if ($aDate !== $bDate) {
return strcmp($aDate, $bDate);
}
return strcmp((string) $a['contract_no'], (string) $b['contract_no']);
});
arsort($productTypeCounts);
$productTypes = [];
foreach ($productTypeCounts as $type => $count) {
$productTypes[] = [
'type' => (string) $type,
'count' => (int) $count,
];
}
return [
'entries' => $entries,
'summary' => [
'total_contracts' => count($entries),
'active_contracts' => $activeContracts,
'product_types' => $productTypes,
],
];
}
/**
* Load sales dashboard data for a debtor: KPIs, contracts with line items, contacts.
*
* @return array{
* ok: bool,
* kpis?: array{active_contracts: int, monthly_volume: float, support_hours: float, next_invoicing_date: string},
* contracts?: array<int, array<string, mixed>>,
* contacts?: array<int, array<string, mixed>>,
* error?: string
* }
*/
public function loadSalesDashboard(string $customerNo, string $customerName): array
{
$customerNo = trim($customerNo);
$customerName = trim($customerName);
if ($customerNo === '' || $customerName === '') {
return ['ok' => false, 'error' => 'Missing customer number or name'];
}
if (!$this->settingsGateway->isConfigured()) {
return ['ok' => false, 'error' => 'BC connection not configured'];
}
$contracts = [];
$lines = [];
$contacts = [];
$contractsError = '';
$linesError = '';
try {
$contracts = $this->bcODataGateway->getContractsForCustomer($customerNo);
} catch (\Throwable $e) {
$contractsError = $e->getMessage();
}
try {
$lines = $this->bcODataGateway->getContractLinesForCustomer($customerNo);
} catch (\Throwable $e) {
$linesError = $e->getMessage();
}
try {
$contacts = $this->bcODataGateway->getContactsForCustomer($customerNo, $customerName);
} catch (\Throwable) {
// Contacts are secondary — don't fail the dashboard
}
if ($contracts === [] && $contractsError !== '') {
return ['ok' => false, 'error' => $contractsError];
}
$dashboard = self::buildSalesDashboard($contracts, $lines, $customerNo);
return [
'ok' => true,
'kpis' => $dashboard['kpis'],
'contracts' => $dashboard['contracts'],
'contacts' => $contacts,
];
}
/**
* Build sales dashboard from raw contract headers and line items.
*
* @param array<int, array<string, mixed>> $contracts
* @param array<int, array<string, mixed>> $lines
* @return array{
* kpis: array{active_contracts: int, monthly_volume: float, support_hours: float, next_invoicing_date: string},
* contracts: array<int, array<string, mixed>>
* }
*/
public static function buildSalesDashboard(array $contracts, array $lines, string $customerNo): array
{
$customerNo = trim($customerNo);
$twoYearsAgo = date('Y-m-d', strtotime('-2 years'));
// Group lines by Header_No
$linesByContract = [];
foreach ($lines as $line) {
$headerNo = trim((string) ($line['Header_No'] ?? ''));
if ($headerNo === '') {
continue;
}
$linesByContract[$headerNo][] = $line;
}
$activeContracts = 0;
$totalContracts = 0;
$monthlyVolume = 0.0;
$supportHours = 0.0;
$nextInvoicingDate = '';
$largestContractVolume = 0.0;
$totalPositions = 0;
// 30-day trend windows for contracts
$trendDays = 30;
$nowTs = time();
$boundary30 = date('Y-m-d', $nowTs - ($trendDays * 86400));
$boundary60 = date('Y-m-d', $nowTs - (2 * $trendDays * 86400));
$startedCurrent = 0;
$startedPrevious = 0;
$endedCurrent = 0;
$endedPrevious = 0;
$volumeNewContracts = 0.0;
$entries = [];
foreach ($contracts as $contract) {
$contractNo = trim((string) ($contract['No'] ?? ''));
if ($contractNo === '') {
continue;
}
$billToNo = trim((string) ($contract['Bill_to_Cust_No'] ?? ''));
$sellToNo = trim((string) ($contract['Sell_to_Cust_No'] ?? ''));
if ($customerNo !== '' && $billToNo !== $customerNo && $sellToNo !== $customerNo) {
continue;
}
$state = trim((string) ($contract['State'] ?? ''));
$stateLower = mb_strtolower($state);
$isActive = $stateLower === 'aktiv';
// Filter ended contracts older than 2 years
if ($stateLower === 'beendet') {
$nextInv = self::normalizeDateField((string) ($contract['Next_Invoicing_Date'] ?? ''));
$startDate = self::normalizeDateField((string) ($contract['Starting_Date'] ?? ''));
$relevantDate = $nextInv !== '' ? $nextInv : $startDate;
if ($relevantDate !== '' && $relevantDate < $twoYearsAgo) {
continue;
}
}
$totalContracts++;
// Trend: count started/ended contracts in 30d windows
$contractStartDate = self::normalizeDateField((string) ($contract['Starting_Date'] ?? ''));
if ($contractStartDate !== '') {
if ($contractStartDate >= $boundary30) {
$startedCurrent++;
if ($isActive) {
$volumeNewContracts += (float) ($contract['MontlyAmount_InvoicingDate'] ?? 0);
}
} elseif ($contractStartDate >= $boundary60) {
$startedPrevious++;
}
}
if ($stateLower === 'beendet') {
$endRelevant = self::normalizeDateField((string) ($contract['Next_Invoicing_Date'] ?? ''));
if ($endRelevant === '') {
$endRelevant = $contractStartDate;
}
if ($endRelevant !== '') {
if ($endRelevant >= $boundary30) {
$endedCurrent++;
} elseif ($endRelevant >= $boundary60) {
$endedPrevious++;
}
}
}
// KPI aggregation (only active)
if ($isActive) {
$activeContracts++;
$contractMonthly = (float) ($contract['MontlyAmount_InvoicingDate'] ?? 0);
$monthlyVolume += $contractMonthly;
$supportHours += (float) ($contract['Support_Hours'] ?? 0);
if ($contractMonthly > $largestContractVolume) {
$largestContractVolume = $contractMonthly;
}
$invDate = self::normalizeDateField((string) ($contract['Next_Invoicing_Date'] ?? ''));
if ($invDate !== '' && ($nextInvoicingDate === '' || $invDate < $nextInvoicingDate)) {
$nextInvoicingDate = $invDate;
}
}
// Normalize line items for this contract
$contractLines = [];
foreach (($linesByContract[$contractNo] ?? []) as $line) {
$lineDesc = trim((string) ($line['Description'] ?? ''));
if ($lineDesc === '') {
continue;
}
$contractLines[] = [
'line_no' => (int) ($line['Line_No'] ?? 0),
'description' => $lineDesc,
'type' => trim((string) ($line['Type'] ?? '')),
'quantity' => (float) ($line['Quantity'] ?? 0),
'unit_price' => (float) ($line['Unit_Price'] ?? 0),
'line_amount' => (float) ($line['Line_Amount'] ?? 0),
'line_discount_percent' => (float) ($line['Line_Discount_Percent'] ?? 0),
'line_state' => trim((string) ($line['Line_State'] ?? '')),
'starting_date' => self::normalizeDateField((string) ($line['Starting_Date'] ?? '')),
'ending_date' => self::normalizeDateField((string) ($line['Ending_Date'] ?? '')),
];
}
$entries[] = [
'contract_no' => $contractNo,
'description' => trim((string) ($contract['Description'] ?? '')),
'state' => $state,
'product_type' => trim((string) ($contract['Periodic_Invoicing_Type'] ?? '')),
'starting_date' => self::normalizeDateField((string) ($contract['Starting_Date'] ?? '')),
'next_invoicing_date' => self::normalizeDateField((string) ($contract['Next_Invoicing_Date'] ?? '')),
'invoicing_period' => trim((string) ($contract['Invoicing_Period'] ?? '')),
'monthly_amount' => (float) ($contract['MontlyAmount_InvoicingDate'] ?? 0),
'total_payoff_amount' => (float) ($contract['TotalPayoffAmount'] ?? 0),
'support_hours' => (float) ($contract['Support_Hours'] ?? 0),
'active_positions' => (int) ($contract['No_of_Active_Position'] ?? 0),
'sell_to_customer_name' => trim((string) ($contract['Sell_to_Cust_Name'] ?? '')),
'bill_to_customer_name' => trim((string) ($contract['Bill_to_Cust_Name'] ?? '')),
'salesperson_code' => trim((string) ($contract['Salesperson_Purchaser_Code'] ?? '')),
'main_contract' => (bool) ($contract['Main_Contract'] ?? false),
'lines' => $contractLines,
];
}
// Sort: active first, then by next invoicing date
usort($entries, static function (array $a, array $b): int {
$aActive = mb_strtolower((string) $a['state']) === 'aktiv' ? 1 : 0;
$bActive = mb_strtolower((string) $b['state']) === 'aktiv' ? 1 : 0;
if ($aActive !== $bActive) {
return $bActive <=> $aActive;
}
$aDate = (string) $a['next_invoicing_date'];
$bDate = (string) $b['next_invoicing_date'];
if ($aDate === '' && $bDate !== '') {
return 1;
}
if ($aDate !== '' && $bDate === '') {
return -1;
}
return strcmp($aDate, $bDate);
});
// Count actual line items from active contracts
foreach ($entries as $entry) {
if (mb_strtolower((string) $entry['state']) === 'aktiv') {
$totalPositions += count($entry['lines']);
}
}
$avgContractValue = $activeContracts > 0 ? round($monthlyVolume / $activeContracts, 2) : 0.0;
// Days until next invoicing
$daysUntilNextInvoicing = null;
if ($nextInvoicingDate !== '') {
$nowDate = new \DateTimeImmutable('today', new \DateTimeZone('UTC'));
$invDateObj = \DateTimeImmutable::createFromFormat('Y-m-d', $nextInvoicingDate, new \DateTimeZone('UTC'));
if ($invDateObj !== false) {
$daysUntilNextInvoicing = (int) $nowDate->diff($invDateObj)->format('%r%a');
}
}
return [
'kpis' => [
'active_contracts' => $activeContracts,
'total_contracts' => $totalContracts,
'monthly_volume' => round($monthlyVolume, 2),
'support_hours' => round($supportHours, 2),
'next_invoicing_date' => $nextInvoicingDate,
'avg_contract_value' => $avgContractValue,
'largest_contract_volume' => round($largestContractVolume, 2),
'total_positions' => $totalPositions,
'days_until_next_invoicing' => $daysUntilNextInvoicing,
'started_30d' => $startedCurrent,
'started_prev_30d' => $startedPrevious,
'ended_30d' => $endedCurrent,
'ended_prev_30d' => $endedPrevious,
'volume_new_contracts' => round($volumeNewContracts, 2),
],
'contracts' => $entries,
];
}
private static function normalizeDateField(string $value): string
{
$value = trim($value);
if ($value === '' || $value === '0001-01-01' || str_starts_with($value, '0001-01-01')) {
return '';
}
// Handle ISO datetime (strip time part for display)
if (str_contains($value, 'T')) {
$value = substr($value, 0, 10);
}
return $value;
}
private static function isClosedTicketState(string $state): bool
{
return in_array(trim($state), self::CLOSED_TICKET_STATES, true);
}
/**
* @param array<string, mixed> $ticket
*/
private static function resolveTicketActivityTimestamp(array $ticket): ?int
{
$lastActivity = trim((string) ($ticket['Last_Activity_Date'] ?? ''));
if ($lastActivity !== '' && $lastActivity !== '0001-01-01T00:00:00Z') {
$ts = strtotime($lastActivity);
if (is_int($ts)) {
return $ts;
}
}
$createdOn = trim((string) ($ticket['Created_On'] ?? ''));
if ($createdOn !== '' && $createdOn !== '0001-01-01T00:00:00Z') {
$ts = strtotime($createdOn);
if (is_int($ts)) {
return $ts;
}
}
return null;
}
private static function parseIsoDurationToSeconds(string $value): ?int
{
$value = strtoupper(trim($value));
if ($value === '' || !str_starts_with($value, 'P')) {
return null;
}
if (!preg_match('/^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/', $value, $matches)) {
return null;
}
$days = isset($matches[1]) && $matches[1] !== '' ? (int) $matches[1] : 0;
$hours = isset($matches[2]) && $matches[2] !== '' ? (int) $matches[2] : 0;
$minutes = isset($matches[3]) && $matches[3] !== '' ? (int) $matches[3] : 0;
$secondsFloat = isset($matches[4]) && $matches[4] !== '' ? (float) $matches[4] : 0.0;
$seconds = (int) floor($secondsFloat);
return ($days * 86400) + ($hours * 3600) + ($minutes * 60) + $seconds;
}
/**
* @param array<int, array{
* ticket_no: string,
* support_user_name: string,
* age_hours: int|null
* }> $openTickets
* @return array<int, array{
* action_id: string,
* type: string,
* priority: int,
* ticket_no: string,
* age_hours: int|null
* }>
*/
private static function buildSupportActions(array $openTickets, int $criticalThresholdHours): array
{
$candidates = [];
foreach ($openTickets as $ticket) {
$ticketNo = $ticket['ticket_no'];
$ageHours = $ticket['age_hours'];
$supportUserName = trim($ticket['support_user_name']);
if (is_int($ageHours) && $ageHours >= $criticalThresholdHours) {
$candidates[] = [
'action_id' => 'critical_stale-' . $ticketNo,
'type' => 'critical_stale',
'priority' => 3,
'ticket_no' => $ticketNo,
'age_hours' => $ageHours,
];
}
if ($supportUserName === '') {
$candidates[] = [
'action_id' => 'unassigned_support-' . $ticketNo,
'type' => 'unassigned_support',
'priority' => 2,
'ticket_no' => $ticketNo,
'age_hours' => $ageHours,
];
}
}
$oldestOpen = null;
foreach ($openTickets as $ticket) {
$ticketNo = $ticket['ticket_no'];
$ageHours = $ticket['age_hours'];
if ($oldestOpen === null || ($ageHours ?? -1) > ((int) ($oldestOpen['age_hours'] ?? -1))) {
$oldestOpen = [
'ticket_no' => $ticketNo,
'age_hours' => $ageHours,
];
}
}
if (is_array($oldestOpen)) {
$candidates[] = [
'action_id' => 'oldest_open_followup-' . $oldestOpen['ticket_no'],
'type' => 'oldest_open_followup',
'priority' => 1,
'ticket_no' => $oldestOpen['ticket_no'],
'age_hours' => $oldestOpen['age_hours'],
];
}
usort($candidates, static function (array $a, array $b): int {
$priorityCmp = $b['priority'] <=> $a['priority'];
if ($priorityCmp !== 0) {
return $priorityCmp;
}
$ageA = is_int($a['age_hours'] ?? null) ? (int) $a['age_hours'] : -1;
$ageB = is_int($b['age_hours'] ?? null) ? (int) $b['age_hours'] : -1;
$ageCmp = $ageB <=> $ageA;
if ($ageCmp !== 0) {
return $ageCmp;
}
return strcmp($a['ticket_no'], $b['ticket_no']);
});
return array_slice($candidates, 0, 3);
}
/**
* Load activity log for a ticket (for async data endpoint).
*
* @return array{ok: bool, log?: array<int, array<string, mixed>>, error?: string}
*/
public function loadTicketLog(string $ticketNo): array
{
$ticketNo = trim($ticketNo);
if ($ticketNo === '') {
return ['ok' => false, 'error' => 'Missing ticket number'];
}
if (!$this->settingsGateway->isConfigured()) {
return ['ok' => false, 'error' => 'BC connection not configured'];
}
try {
$log = $this->bcODataGateway->getTicketLog($ticketNo);
} catch (\Throwable $e) {
return ['ok' => false, 'error' => $e->getMessage()];
}
return ['ok' => true, 'log' => $log];
}
/**
* Load full detail for a debtor: master data, contacts, and tickets.
*
* @deprecated Use loadCustomer() + async endpoints for contacts/tickets instead.
*
* @return array{status: string, customer?: array<string, mixed>, contacts?: array<int, array<string, mixed>>, tickets?: array<int, array<string, mixed>>, error?: string, contactsError?: string, ticketsError?: string}
*/
public function loadDetail(string $customerNo): array
{
$customerNo = trim($customerNo);
if ($customerNo === '') {
return ['status' => 'not_found'];
}
if (!$this->settingsGateway->isConfigured()) {
return ['status' => 'not_configured', 'error' => 'BC connection not configured'];
}
try {
$customer = $this->bcODataGateway->getCustomer($customerNo);
} catch (\Throwable) {
return ['status' => 'error', 'error' => 'BC connection failed'];
}
if ($customer === null) {
return ['status' => 'not_found'];
}
// Load contacts — BC contacts are linked via Company_Name, not customer number.
// IntegrationCustomerNo is not filterable in BC OData, so we use the customer name.
$contacts = [];
$contactsError = '';
$customerName = (string) ($customer['Name'] ?? '');
try {
$contacts = $this->bcODataGateway->getContactsForCustomer($customerNo, $customerName);
} catch (\Throwable $e) {
$contactsError = $e->getMessage();
}
// Load tickets via PBI_FP_Tickets entity which supports server-side filtering.
$tickets = [];
$ticketsError = '';
try {
$tickets = $this->bcODataGateway->getTicketsForCustomer($customerNo, $customerName);
} catch (\Throwable $e) {
$ticketsError = $e->getMessage();
}
return [
'status' => 'success',
'customer' => $customer,
'contacts' => $contacts,
'tickets' => $tickets,
'contactsError' => $contactsError,
'ticketsError' => $ticketsError,
];
}
// --- Controlling dashboard ---
/**
* Build controlling dashboard from raw tickets and contract KPIs.
*
* @param array<int, array<string, mixed>> $tickets Raw OData tickets with Created_On, Process_Stage
* @param int $periodDays One of 30, 90, 180, 365
* @param array<string, mixed> $riskConfig Risk rules from settings
*
* @return array<string, mixed>
*/
public static function buildControllingDashboard(
array $tickets,
int $periodDays,
array $riskConfig
): array {
$nowTs = time();
$periodStart = $nowTs - ($periodDays * 86400);
$prevPeriodStart = $periodStart - ($periodDays * 86400);
// --- Single-pass counters ---
$createdInPeriod = 0;
$closedInPeriod = 0;
$createdInPrev = 0;
$closedInPrev = 0;
// Resolution: all tickets CLOSED in the period (regardless of creation date)
$resolutionHoursCurrent = [];
// Resolution: all tickets CLOSED in the previous period
$resolutionHoursPrev = [];
// Backlog: snapshot of currently open tickets (period-independent)
$openTickets = 0;
$unassignedOpen = 0;
$criticalOpen = 0;
$oldestOpenAgeHours = 0;
$criticalThresholdHours = 48;
// Trend buckets
$buckets = self::initTrendBuckets($periodDays, $nowTs);
foreach ($tickets as $ticket) {
$state = trim((string) ($ticket['Ticket_State'] ?? ''));
$isClosed = self::isClosedTicketState($state)
|| (int) ($ticket['Process_Stage'] ?? 0) === 40;
$createdTs = self::parseIsoToTimestamp((string) ($ticket['Created_On'] ?? ''));
$activityTs = self::resolveTicketActivityTimestamp($ticket);
// --- Created in current period ---
if ($createdTs !== null && $createdTs >= $periodStart) {
$createdInPeriod++;
$bucketKey = self::resolveBucketKey($createdTs, $periodDays);
if (isset($buckets[$bucketKey])) {
$buckets[$bucketKey]['created']++;
}
}
// --- Created in previous period ---
if ($createdTs !== null && $createdTs >= $prevPeriodStart && $createdTs < $periodStart) {
$createdInPrev++;
}
// --- Closed in current period (any creation date) ---
if ($isClosed && $activityTs !== null && $activityTs >= $periodStart) {
$closedInPeriod++;
$bucketKey = self::resolveBucketKey($activityTs, $periodDays);
if (isset($buckets[$bucketKey])) {
$buckets[$bucketKey]['closed']++;
}
// Resolution time: Last_Activity_Date Created_On
if ($createdTs !== null && $activityTs > $createdTs) {
$resolutionHoursCurrent[] = intdiv($activityTs - $createdTs, 3600);
}
}
// --- Closed in previous period (any creation date) ---
if ($isClosed && $activityTs !== null && $activityTs >= $prevPeriodStart && $activityTs < $periodStart) {
$closedInPrev++;
if ($createdTs !== null && $activityTs > $createdTs) {
$resolutionHoursPrev[] = intdiv($activityTs - $createdTs, 3600);
}
}
// --- Open ticket snapshot (period-independent) ---
if (!$isClosed) {
$openTickets++;
$supportUser = trim((string) ($ticket['Support_User_Name'] ?? ''));
if ($supportUser === '') {
$unassignedOpen++;
}
if ($activityTs !== null) {
$ageHours = intdiv(max(0, $nowTs - $activityTs), 3600);
if ($ageHours > $criticalThresholdHours) {
$criticalOpen++;
}
if ($ageHours > $oldestOpenAgeHours) {
$oldestOpenAgeHours = $ageHours;
}
}
}
}
// --- KPI 1: Volume (created in period, delta vs prev) ---
$volumeDelta = $createdInPeriod - $createdInPrev;
// --- KPI 2: Close-Rate (closed ÷ created, as factor) ---
$closeRate = $createdInPeriod > 0
? round($closedInPeriod / $createdInPeriod, 2)
: null;
$closeRatePrev = $createdInPrev > 0
? round($closedInPrev / $createdInPrev, 2)
: null;
// --- KPI 3: Resolution (median hours, all tickets closed in period) ---
$resolutionMedian = self::computeMedian($resolutionHoursCurrent);
$resolutionMedianPrev = self::computeMedian($resolutionHoursPrev);
// --- KPI 4: Backlog (open snapshot) ---
// No computation needed — $openTickets, $unassignedOpen, $criticalOpen are ready
// --- Efficiency (detail section) ---
$unassignedRate = $openTickets > 0
? round(($unassignedOpen / $openTickets) * 100, 1)
: 0.0;
// --- Risk evaluation ---
$rules = is_array($riskConfig['rules'] ?? null) ? $riskConfig['rules'] : [];
$riskIndicators = self::evaluateControllingRisks(
$rules,
$createdInPeriod,
$createdInPrev,
$resolutionMedian,
$oldestOpenAgeHours,
$unassignedRate
);
$triggeredCount = 0;
$totalRules = count($riskIndicators);
foreach ($riskIndicators as $indicator) {
if ($indicator['triggered']) {
$triggeredCount++;
}
}
$riskLevel = $triggeredCount === 0 ? 'low' : ($triggeredCount <= 2 ? 'medium' : 'high');
return [
'kpis' => [
'volume' => $createdInPeriod,
'volume_prev' => $createdInPrev,
'volume_delta' => $volumeDelta,
'close_rate' => $closeRate,
'close_rate_prev' => $closeRatePrev,
'resolution_hours' => $resolutionMedian,
'resolution_hours_prev' => $resolutionMedianPrev,
'backlog_open' => $openTickets,
'backlog_unassigned' => $unassignedOpen,
'backlog_critical' => $criticalOpen,
],
'trend' => array_values($buckets),
'risk_indicators' => $riskIndicators,
'risk_summary' => [
'triggered' => $triggeredCount,
'total' => $totalRules,
'level' => $riskLevel,
],
'meta' => [
'period_days' => $periodDays,
'created_in_period' => $createdInPeriod,
'closed_in_period' => $closedInPeriod,
'created_in_prev' => $createdInPrev,
'closed_in_prev' => $closedInPrev,
],
];
}
/**
* @return array<string, array{label: string, created: int, closed: int}>
*/
private static function initTrendBuckets(int $periodDays, int $nowTs): array
{
$buckets = [];
if ($periodDays <= 30) {
// Weekly buckets for 30-day period
for ($i = 3; $i >= 0; $i--) {
$weekStart = $nowTs - (($i + 1) * 7 * 86400);
$dt = new \DateTimeImmutable('@' . $weekStart);
$key = $dt->format('o-W');
$buckets[$key] = ['label' => 'KW' . $dt->format('W'), 'created' => 0, 'closed' => 0];
}
} else {
// Monthly buckets
$months = (int) ceil($periodDays / 30);
for ($i = $months - 1; $i >= 0; $i--) {
$dt = (new \DateTimeImmutable('@' . $nowTs))->modify("-{$i} months");
$key = $dt->format('Y-m');
$monthNames = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'];
$buckets[$key] = ['label' => $monthNames[(int) $dt->format('n') - 1], 'created' => 0, 'closed' => 0];
}
}
return $buckets;
}
private static function resolveBucketKey(int $timestamp, int $periodDays): string
{
$dt = new \DateTimeImmutable('@' . $timestamp);
if ($periodDays <= 30) {
return $dt->format('o-W');
}
return $dt->format('Y-m');
}
/**
* @param array<int, int> $values
*/
private static function computeMedian(array $values): ?float
{
if ($values === []) {
return null;
}
sort($values);
$count = count($values);
$mid = intdiv($count, 2);
if ($count % 2 === 0) {
return round(($values[$mid - 1] + $values[$mid]) / 2, 1);
}
return (float) $values[$mid];
}
private static function parseIsoToTimestamp(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;
}
/**
* @param array<string, array<string, mixed>> $rules
* @return array<int, array{rule_id: string, label: string, description: string, triggered: bool, value: string, raw_value: float|int, threshold_value: int, unit: string}>
*/
private static function evaluateControllingRisks(
array $rules,
int $createdInPeriod,
int $createdInPrev,
?float $avgResolution,
int $oldestOpenAgeHours,
float $unassignedRate
): array {
$indicators = [];
// Ticket volume increase
$rule = is_array($rules['ticket_volume_increase'] ?? null) ? $rules['ticket_volume_increase'] : [];
if (($rule['enabled'] ?? true) === true) {
$threshold = (int) ($rule['threshold_percent'] ?? 30);
$increase = $createdInPrev > 0
? round((($createdInPeriod - $createdInPrev) / $createdInPrev) * 100, 1)
: ($createdInPeriod > 0 ? 100.0 : 0.0);
$indicators[] = [
'rule_id' => 'ticket_volume_increase',
'label' => 'Ticket volume',
'description' => 'risk_desc_ticket_volume',
'triggered' => $increase >= $threshold,
'value' => ($increase > 0 ? '+' : '') . $increase . '%',
'raw_value' => max(0, $increase),
'threshold_value' => $threshold,
'unit' => '%',
];
}
// Avg resolution high
$rule = is_array($rules['avg_resolution_high'] ?? null) ? $rules['avg_resolution_high'] : [];
if (($rule['enabled'] ?? true) === true) {
$threshold = (int) ($rule['threshold_hours'] ?? 72);
$triggered = $avgResolution !== null && $avgResolution >= $threshold;
$rawValue = $avgResolution !== null ? round($avgResolution, 1) : 0;
$indicators[] = [
'rule_id' => 'avg_resolution_high',
'label' => 'Resolution time',
'description' => 'risk_desc_resolution_time',
'triggered' => $triggered,
'value' => $avgResolution !== null ? round($avgResolution, 1) . 'h' : '—',
'raw_value' => $rawValue,
'threshold_value' => $threshold,
'unit' => 'h',
];
}
// Open aging
$rule = is_array($rules['open_aging'] ?? null) ? $rules['open_aging'] : [];
if (($rule['enabled'] ?? true) === true) {
$threshold = (int) ($rule['threshold_hours'] ?? 168);
$indicators[] = [
'rule_id' => 'open_aging',
'label' => 'Oldest open ticket',
'description' => 'risk_desc_open_aging',
'triggered' => $oldestOpenAgeHours >= $threshold,
'value' => $oldestOpenAgeHours . 'h',
'raw_value' => $oldestOpenAgeHours,
'threshold_value' => $threshold,
'unit' => 'h',
];
}
// Unassigned ratio
$rule = is_array($rules['unassigned_ratio'] ?? null) ? $rules['unassigned_ratio'] : [];
if (($rule['enabled'] ?? true) === true) {
$threshold = (int) ($rule['threshold_percent'] ?? 20);
$indicators[] = [
'rule_id' => 'unassigned_ratio',
'label' => 'Unassigned',
'description' => 'risk_desc_unassigned',
'triggered' => $unassignedRate >= $threshold,
'value' => $unassignedRate . '%',
'raw_value' => $unassignedRate,
'threshold_value' => $threshold,
'unit' => '%',
];
}
return $indicators;
}
/**
* Build a team workload dashboard from a global (non-customer-scoped) ticket set.
*
* Groups tickets by Support_User_Name and computes per-agent metrics.
* Unassigned tickets (empty Support_User_Name) are collected into a dedicated entry.
*
* @param array<int, array<string, mixed>> $tickets Raw OData tickets
* @param int $periodDays One of 30, 90, 180, 365
*
* @return array{kpis: array<string, mixed>, members: array<int, array<string, mixed>>, meta: array<string, mixed>}
*/
public static function buildTeamWorkloadDashboard(array $tickets, int $periodDays): array
{
$nowTs = time();
$periodStart = $nowTs - ($periodDays * 86400);
$criticalThresholdHours = 48;
/** @var array<string, array{display_name: string, support_user: string, open_tickets: int, critical_tickets: int, age_hours_sum: int, resolved_in_period: int, open_ticket_details: list<array{no: string, description: string, age_hours: int, critical: bool, customer: string}>}> $agents */
$agents = [];
$globalOpen = 0;
$globalUnassigned = 0;
$globalAgeSum = 0;
$globalOpenCount = 0;
$globalResolved = 0;
foreach ($tickets as $ticket) {
$state = trim((string) ($ticket['Ticket_State'] ?? ''));
$isClosed = self::isClosedTicketState($state)
|| (int) ($ticket['Process_Stage'] ?? 0) === 40;
$rawUser = trim((string) ($ticket['Support_User_Name'] ?? ''));
$normalizedUser = $rawUser === '' ? '' : strtolower($rawUser);
$activityTs = self::resolveTicketActivityTimestamp($ticket);
if (!isset($agents[$normalizedUser])) {
$agents[$normalizedUser] = [
'display_name' => $rawUser,
'support_user' => $normalizedUser,
'open_tickets' => 0,
'critical_tickets' => 0,
'age_hours_sum' => 0,
'resolved_in_period' => 0,
'open_ticket_details' => [],
];
}
if ($isClosed) {
if ($activityTs !== null && $activityTs >= $periodStart) {
$agents[$normalizedUser]['resolved_in_period']++;
$globalResolved++;
}
} else {
$agents[$normalizedUser]['open_tickets']++;
$globalOpen++;
if ($rawUser === '') {
$globalUnassigned++;
}
$ageHours = 0;
$isCritical = false;
if ($activityTs !== null) {
$ageHours = intdiv(max(0, $nowTs - $activityTs), 3600);
$agents[$normalizedUser]['age_hours_sum'] += $ageHours;
if ($ageHours > $criticalThresholdHours) {
$agents[$normalizedUser]['critical_tickets']++;
$isCritical = true;
}
$globalAgeSum += $ageHours;
$globalOpenCount++;
}
$lastActivityRaw = trim((string) ($ticket['Last_Activity_Date'] ?? ''));
$lastActivityDisplay = ($lastActivityRaw !== '' && !str_starts_with($lastActivityRaw, '0001'))
? $lastActivityRaw
: trim((string) ($ticket['Created_On'] ?? ''));
$agents[$normalizedUser]['open_ticket_details'][] = [
'no' => trim((string) ($ticket['No'] ?? '')),
'description' => trim((string) ($ticket['Description'] ?? (string) ($ticket['Category_1_Code'] ?? ''))),
'age_hours' => $ageHours,
'critical' => $isCritical,
'customer_name' => trim((string) ($ticket['Company_Contact_Name'] ?? '')),
'customer_no' => trim((string) ($ticket['Customer_No'] ?? '')),
'last_activity' => $lastActivityDisplay,
];
}
}
$members = [];
foreach ($agents as $key => $agent) {
$avgAge = $agent['open_tickets'] > 0
? (int) round($agent['age_hours_sum'] / $agent['open_tickets'])
: 0;
// Sort ticket details: critical first, then by age descending
$details = $agent['open_ticket_details'];
usort($details, static function (array $a, array $b): int {
if ($a['critical'] !== $b['critical']) {
return $b['critical'] <=> $a['critical'];
}
return $b['age_hours'] <=> $a['age_hours'];
});
$members[] = [
'support_user' => $agent['support_user'],
'display_name' => $agent['display_name'],
'open_tickets' => $agent['open_tickets'],
'critical_tickets' => $agent['critical_tickets'],
'avg_age_hours' => $avgAge,
'resolved_in_period' => $agent['resolved_in_period'],
'open_ticket_details' => $details,
];
}
usort($members, static function (array $a, array $b): int {
if ($a['support_user'] === '') {
return 1;
}
if ($b['support_user'] === '') {
return -1;
}
return $b['open_tickets'] <=> $a['open_tickets'];
});
$globalAvgAge = $globalOpenCount > 0
? (int) round($globalAgeSum / $globalOpenCount)
: 0;
return [
'kpis' => [
'open_tickets' => $globalOpen,
'unassigned' => $globalUnassigned,
'avg_age_hours' => $globalAvgAge,
'resolved_in_period' => $globalResolved,
],
'members' => $members,
'meta' => [
'period_days' => $periodDays,
'total_tickets' => count($tickets),
'unique_users' => count(array_filter(array_keys($agents), static fn (string $k): bool => $k !== '')),
],
];
}
}