*/ 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, 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>, * 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>, * 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, * 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> $tickets Current ticket list used in support dashboard * @return array{ * ok: bool, * overdue_count?: int, * entries?: array, * matches?: array, * 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, * summary?: array{ * total_contracts: int, * active_contracts: int, * product_types: array * }, * 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> $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> $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, * 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> $contracts * @return array{ * entries: array, * summary: array{ * total_contracts: int, * active_contracts: int, * product_types: array * } * } */ 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>, * contacts?: array>, * 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> $contracts * @param array> $lines * @return array{ * kpis: array{active_contracts: int, monthly_volume: float, support_hours: float, next_invoicing_date: string}, * contracts: array> * } */ 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 $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 $openTickets * @return array */ 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>, 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, contacts?: array>, tickets?: array>, 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> $tickets Raw OData tickets with Created_On, Process_Stage * @param int $periodDays One of 30, 90, 180, 365 * @param array $riskConfig Risk rules from settings * * @return array */ 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 */ 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 $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> $rules * @return array */ 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> $tickets Raw OData tickets * @param int $periodDays One of 30, 90, 180, 365 * * @return array{kpis: array, members: array>, meta: array} */ public static function buildTeamWorkloadDashboard(array $tickets, int $periodDays): array { $nowTs = time(); $periodStart = $nowTs - ($periodDays * 86400); $criticalThresholdHours = 48; /** @var array}> $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 !== '')), ], ]; } }