diff --git a/modules/helpdesk/i18n/default_de.json b/modules/helpdesk/i18n/default_de.json index 4c6db51..a652f52 100644 --- a/modules/helpdesk/i18n/default_de.json +++ b/modules/helpdesk/i18n/default_de.json @@ -41,7 +41,9 @@ "Status": "Status", "Category": "Kategorie", "Contact": "Kontakt", - "Support": "Bearbeiter", + "Support": "Support", + "Sales": "Vertrieb", + "Controlling": "Controlling", "Ticket": "Ticket", "Debtor": "Kunde", "Debtor not found": "Kunde nicht gefunden", @@ -49,6 +51,7 @@ "Back to search": "Zurück zur Suche", "Back": "Zurück", "Settings": "Einstellungen", + "Connection": "Verbindung", "Save": "Speichern", "Settings saved": "Einstellungen gespeichert", "Configuration incomplete": "Konfiguration unvollständig", @@ -95,8 +98,97 @@ "Location": "Standort", "No open tickets": "Keine offenen Tickets", "No recent activity found.": "Keine aktuellen Aktivitäten gefunden.", + "Total tickets": "Alle Tickets", + "Total contracts": "Alle Verträge", + "Critical (>48h)": "Kritisch (>48h)", + "Oldest open": "Ältestes offenes", + "Ticket list": "Ticketliste", + "Next actions": "Nächste Aktionen", + "System recommendations": "System-Empfehlungen", + "Action": "Aktion", + "Age (h)": "Alter (h)", + "No support actions required right now.": "Aktuell sind keine Support-Aktionen erforderlich.", + "No system recommendations right now.": "Aktuell gibt es keine System-Empfehlungen.", + "Contracts & products": "Verträge & Produkte", + "No contracts found for this customer.": "Für diesen Debitor wurden keine Verträge gefunden.", + "Contract No.": "Vertragsnr.", + "Product type": "Produkttyp", + "Next invoicing": "Nächste Abrechnung", + "Amount": "Betrag", + "Active contracts": "Aktive Verträge", + "Could not load contracts.": "Verträge konnten nicht geladen werden.", + "Escalations": "Eskalationen", + "Escalations are not available yet.": "Eskalationen sind derzeit noch nicht verfügbar.", + "Escalated tickets": "Eskalierte Tickets", + "No escalated tickets right now.": "Aktuell gibt es keine eskalierten Tickets.", + "Could not load escalated tickets.": "Eskalierte Tickets konnten nicht geladen werden.", + "Escalation code": "Eskalationscode", + "SLA target": "SLA-Ziel", + "Overdue by": "Überfällig seit", + "day": "Tag", + "days": "Tage", + "Ticket volume": "Ticket-Volumen", + "Close rate": "Close-Rate", + "Avg. resolution": "Ø Lösungszeit", + "Open backlog": "Offener Backlog", + "kpi_tooltip_close_rate": "Geschlossene \u00f7 erstellte Tickets im Zeitraum. \u00dcber 1\u00d7 = Backlog schrumpft.", + "kpi_tooltip_resolution": "Median der Bearbeitungsdauer aller im Zeitraum geschlossenen Tickets.", + "kpi_tooltip_backlog": "Aktueller Stand aller offenen Tickets \u2013 unabh\u00e4ngig vom gew\u00e4hlten Zeitraum.", + "Backlog shrinking": "Backlog schrumpft", + "Backlog growing": "Backlog wächst", + "unassigned": "ohne Bearbeiter", + "Ticket trend": "Ticket-Trend", + "Support efficiency": "Support-Effizienz", + "Contract metrics": "Vertrags-Kennzahlen", + "Risk indicators": "Risiko-Indikatoren", + "vs. prev. period": "vs. Vorperiode", + "Low": "Niedrig", + "Medium": "Mittel", + "High": "Hoch", + "flags": "Flags", + "No controlling data available.": "Keine Controlling-Daten verfügbar.", + "Ticket volume increase": "Ticketvolumen-Anstieg", + "High avg. resolution time": "Hohe Ø Lösungszeit", + "Open ticket aging": "Offene Tickets überfällig", + "Unassigned ticket ratio": "Nicht-zugewiesene Tickets", + "Could not load controlling dashboard.": "Controlling-Dashboard konnte nicht geladen werden.", + "Avg. open age": "Ø Alter offen", + "Without assignee": "Ohne Bearbeiter", + "30 days": "30 Tage", + "90 days": "90 Tage", + "6 months": "6 Monate", + "1 year": "1 Jahr", + "Escalation rate": "Eskalationsrate", + "Threshold (%)": "Schwellwert (%)", + "Threshold (hours)": "Schwellwert (Stunden)", + "Controlling risk configuration was invalid. Defaults were applied.": "Die Controlling-Risiko-Konfiguration war ungültig. Es wurden Standardwerte geladen.", + "Open ticket without activity for": "Offenes Ticket ohne Aktivität seit", + "hours": "Stunden", + "Open ticket without assigned support user": "Offenes Ticket ohne zugewiesenen Support-Bearbeiter", + "Follow up oldest open ticket": "Ältestes offenes Ticket nachverfolgen", + "System recommendation configuration was invalid. Defaults were applied.": "Die Konfiguration der System-Empfehlungen war ungültig. Es wurden Standardwerte geladen.", + "Maximum recommendations": "Maximale Empfehlungen", + "Rule templates": "Regel-Templates", + "Enabled": "Aktiv", + "Priority": "Priorität", + "Thresholds": "Schwellwerte", + "Escalation overdue": "Eskalation überfällig", + "High-risk aging": "High-Risk Alterung", + "Unassigned open": "Offen ohne Bearbeiter", + "Stale open": "Offen ohne Aktivität", + "Customer backlog": "Kunden-Backlog", + "Minimum overdue (minutes)": "Mindestens überfällig (Minuten)", + "High-risk codes (comma separated)": "High-Risk Codes (kommagetrennt)", + "Minimum age (hours)": "Mindestalter (Stunden)", + "Minimum open tickets": "Mindestens offene Tickets", + "Escalation SLA exceeded by {overdue_hours}h ({escalation_code}).": "Eskalations-SLA um {overdue_hours}h überschritten ({escalation_code}).", + "High-risk ticket ({escalation_code}) is open for {age_hours}h.": "High-Risk-Ticket ({escalation_code}) ist seit {age_hours}h offen.", + "Ticket is unassigned for {age_hours}h.": "Ticket ist seit {age_hours}h ohne Bearbeiter.", + "No activity for {age_hours}h.": "Keine Aktivität seit {age_hours}h.", + "Customer backlog: {open_tickets} open tickets.": "Kunden-Backlog: {open_tickets} offene Tickets.", "Created": "Erstellt", "Loading...": "Laden...", + "Refresh data": "Daten aktualisieren", "Mobile": "Mobil", "Activity": "Aktivität", "Activity timeline": "Aktivitätsverlauf", @@ -127,5 +219,34 @@ "All": "Alle", "Open": "Offen", "In Progress": "In Bearbeitung", - "Closed": "Geschlossen" + "Closed": "Geschlossen", + "Active contracts": "Aktive Verträge", + "Monthly volume": "Monatliches Volumen", + "Support hours": "Support-Stunden", + "Next invoicing": "Nächste Abrechnung", + "Contract overview": "Vertragsübersicht", + "No contract details available.": "Keine Vertragsdetails verfügbar.", + "Could not load sales dashboard.": "Vertriebsdashboard konnte nicht geladen werden.", + "Quantity": "Menge", + "Unit price": "Stückpreis", + "Line amount": "Zeilenbetrag", + "Line state": "Zeilenstatus", + "Starting date": "Startdatum", + "Invoicing period": "Abrechnungszeitraum", + "Salesperson": "Vertrieb", + "Contacts": "Ansprechpartner", + "positions": "Positionen", + "per month": "pro Monat", + "Last invoicing": "Letzte Abrechnung", + "Analysis": "Analyse", + "New (30d)": "Neu (30T)", + "Resolved (30d)": "Gelöst (30T)", + "Avg. age open": "Ø Alter offen", + "vs. prev. 30d": "vs. Vorz. 30T", + "net (30d)": "netto (30T)", + "critical": "kritisch", + "Avg. contract value": "Ø Vertragswert", + "of {total} total": "von {total} gesamt", + "in {days} days": "in {days} Tagen", + "largest": "größter" } diff --git a/modules/helpdesk/i18n/default_en.json b/modules/helpdesk/i18n/default_en.json index 40124b6..87f7ce5 100644 --- a/modules/helpdesk/i18n/default_en.json +++ b/modules/helpdesk/i18n/default_en.json @@ -42,6 +42,8 @@ "Category": "Category", "Contact": "Contact", "Support": "Support", + "Sales": "Sales", + "Controlling": "Controlling", "Ticket": "Ticket", "Debtor": "Debtor", "Debtor not found": "Debtor not found", @@ -49,6 +51,7 @@ "Back to search": "Back to search", "Back": "Back", "Settings": "Settings", + "Connection": "Connection", "Save": "Save", "Settings saved": "Settings saved", "Configuration incomplete": "Configuration incomplete", @@ -95,8 +98,97 @@ "Location": "Location", "No open tickets": "No open tickets", "No recent activity found.": "No recent activity found.", + "Total tickets": "Total tickets", + "Total contracts": "Total contracts", + "Critical (>48h)": "Critical (>48h)", + "Oldest open": "Oldest open", + "Ticket list": "Ticket list", + "Next actions": "Next actions", + "System recommendations": "System recommendations", + "Action": "Action", + "Age (h)": "Age (h)", + "No support actions required right now.": "No support actions are required right now.", + "No system recommendations right now.": "No system recommendations right now.", + "Contracts & products": "Contracts & products", + "No contracts found for this customer.": "No contracts found for this customer.", + "Contract No.": "Contract No.", + "Product type": "Product type", + "Next invoicing": "Next invoicing", + "Amount": "Amount", + "Active contracts": "Active contracts", + "Could not load contracts.": "Could not load contracts.", + "Escalations": "Escalations", + "Escalations are not available yet.": "Escalations are not available yet.", + "Escalated tickets": "Escalated tickets", + "No escalated tickets right now.": "No escalated tickets right now.", + "Could not load escalated tickets.": "Could not load escalated tickets.", + "Escalation code": "Escalation code", + "SLA target": "SLA target", + "Overdue by": "Overdue by", + "day": "day", + "days": "days", + "Ticket volume": "Ticket volume", + "Close rate": "Close rate", + "Avg. resolution": "Avg. resolution", + "Open backlog": "Open backlog", + "kpi_tooltip_close_rate": "Closed \u00f7 created tickets in period. Above 1\u00d7 = backlog is shrinking.", + "kpi_tooltip_resolution": "Median processing time of all tickets closed in the selected period.", + "kpi_tooltip_backlog": "Current snapshot of all open tickets \u2013 independent of the selected period.", + "Backlog shrinking": "Backlog shrinking", + "Backlog growing": "Backlog growing", + "unassigned": "unassigned", + "Ticket trend": "Ticket trend", + "Support efficiency": "Support efficiency", + "Contract metrics": "Contract metrics", + "Risk indicators": "Risk indicators", + "vs. prev. period": "vs. prev. period", + "Low": "Low", + "Medium": "Medium", + "High": "High", + "flags": "flags", + "No controlling data available.": "No controlling data available.", + "Ticket volume increase": "Ticket volume increase", + "High avg. resolution time": "High avg. resolution time", + "Open ticket aging": "Open ticket aging", + "Unassigned ticket ratio": "Unassigned ticket ratio", + "Could not load controlling dashboard.": "Could not load controlling dashboard.", + "Avg. open age": "Avg. open age", + "Without assignee": "Without assignee", + "30 days": "30 days", + "90 days": "90 days", + "6 months": "6 months", + "1 year": "1 year", + "Escalation rate": "Escalation rate", + "Threshold (%)": "Threshold (%)", + "Threshold (hours)": "Threshold (hours)", + "Controlling risk configuration was invalid. Defaults were applied.": "Controlling risk configuration was invalid. Defaults were applied.", + "Open ticket without activity for": "Open ticket without activity for", + "hours": "hours", + "Open ticket without assigned support user": "Open ticket without assigned support user", + "Follow up oldest open ticket": "Follow up oldest open ticket", + "System recommendation configuration was invalid. Defaults were applied.": "System recommendation configuration was invalid. Defaults were applied.", + "Maximum recommendations": "Maximum recommendations", + "Rule templates": "Rule templates", + "Enabled": "Enabled", + "Priority": "Priority", + "Thresholds": "Thresholds", + "Escalation overdue": "Escalation overdue", + "High-risk aging": "High-risk aging", + "Unassigned open": "Unassigned open", + "Stale open": "Stale open", + "Customer backlog": "Customer backlog", + "Minimum overdue (minutes)": "Minimum overdue (minutes)", + "High-risk codes (comma separated)": "High-risk codes (comma separated)", + "Minimum age (hours)": "Minimum age (hours)", + "Minimum open tickets": "Minimum open tickets", + "Escalation SLA exceeded by {overdue_hours}h ({escalation_code}).": "Escalation SLA exceeded by {overdue_hours}h ({escalation_code}).", + "High-risk ticket ({escalation_code}) is open for {age_hours}h.": "High-risk ticket ({escalation_code}) is open for {age_hours}h.", + "Ticket is unassigned for {age_hours}h.": "Ticket is unassigned for {age_hours}h.", + "No activity for {age_hours}h.": "No activity for {age_hours}h.", + "Customer backlog: {open_tickets} open tickets.": "Customer backlog: {open_tickets} open tickets.", "Created": "Created", "Loading...": "Loading...", + "Refresh data": "Refresh data", "Mobile": "Mobile", "Activity": "Activity", "Activity timeline": "Activity timeline", @@ -127,5 +219,34 @@ "All": "All", "Open": "Open", "In Progress": "In Progress", - "Closed": "Closed" + "Closed": "Closed", + "Active contracts": "Active contracts", + "Monthly volume": "Monthly volume", + "Support hours": "Support hours", + "Next invoicing": "Next invoicing", + "Contract overview": "Contract overview", + "No contract details available.": "No contract details available.", + "Could not load sales dashboard.": "Could not load sales dashboard.", + "Quantity": "Quantity", + "Unit price": "Unit price", + "Line amount": "Line amount", + "Line state": "Line state", + "Starting date": "Starting date", + "Invoicing period": "Invoicing period", + "Salesperson": "Salesperson", + "Contacts": "Contacts", + "positions": "positions", + "per month": "per month", + "Last invoicing": "Last invoicing", + "Analysis": "Analysis", + "New (30d)": "New (30d)", + "Resolved (30d)": "Resolved (30d)", + "Avg. age open": "Avg. age open", + "vs. prev. 30d": "vs. prev. 30d", + "net (30d)": "net (30d)", + "critical": "critical", + "Avg. contract value": "Avg. contract value", + "of {total} total": "of {total} total", + "in {days} days": "in {days} days", + "largest": "largest" } diff --git a/modules/helpdesk/lib/Module/Helpdesk/HelpdeskContainerRegistrar.php b/modules/helpdesk/lib/Module/Helpdesk/HelpdeskContainerRegistrar.php index 2290404..24f5bbe 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/HelpdeskContainerRegistrar.php +++ b/modules/helpdesk/lib/Module/Helpdesk/HelpdeskContainerRegistrar.php @@ -12,6 +12,7 @@ use MintyPHP\Module\Helpdesk\Service\DebitorSearchService; use MintyPHP\Module\Helpdesk\Service\HelpdeskOAuthTokenService; use MintyPHP\Module\Helpdesk\Service\HelpdeskSettingsGateway; use MintyPHP\Module\Helpdesk\Service\HelpdeskTokenRepository; +use MintyPHP\Module\Helpdesk\Service\SystemRecommendationEngine; use MintyPHP\Module\Helpdesk\Service\TicketCommunicationService; use MintyPHP\Service\Access\PermissionService; use MintyPHP\Service\Settings\SettingServicesFactory; @@ -65,5 +66,7 @@ final class HelpdeskContainerRegistrar implements ContainerRegistrar $c->get(BcSoapGateway::class), $c->get(BcODataGateway::class) )); + + $container->set(SystemRecommendationEngine::class, static fn (): SystemRecommendationEngine => new SystemRecommendationEngine()); } } diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php b/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php index 8cedd48..e1ae5c5 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php @@ -15,8 +15,12 @@ class BcODataGateway public const ENTITY_CUSTOMER = 'Integration_Customer_Card'; public const ENTITY_CONTACT = 'Integration_Contact_Card'; public const ENTITY_TICKETS = 'PBI_FP_Tickets'; + public const ENTITY_TICKETS_LV = 'PBI_LV_Tickets'; + public const ENTITY_ESCALATION = 'PBI_LV_Escalation'; + public const ENTITY_CONTRACTS = 'FS_Contracts_Test'; public const ENTITY_TICKET_LOG = 'PBI_FP_TicketLog'; public const ENTITY_TICKET_LOG_LV = 'PBI_LV_SupportTicketLog'; + public const ENTITY_CONTRACT_LINES = 'FS_Contract_Lines_Test'; private const CONNECT_TIMEOUT = 10; private const REQUEST_TIMEOUT = 30; @@ -253,6 +257,212 @@ class BcODataGateway return $this->extractODataValues($response); } + /** + * Get escalation definitions (code -> target time). + * + * @return array> + */ + public function getEscalationDefinitions(): array + { + $tenantScope = DebitorCacheControl::resolveTenantScope($this->sessionStore->all()); + $cacheKey = DebitorCacheControl::escalationDefinitionsKey($tenantScope); + $cached = $this->sessionStore->get($cacheKey); + if (is_array($cached) && isset($cached['fetched_at']) && (time() - (int) $cached['fetched_at']) < DebitorCacheControl::TTL_ESCALATION_DEFINITIONS_SECONDS) { + $values = $cached['values'] ?? []; + if (is_array($values)) { + return $values; + } + } + + $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_ESCALATION) + . '?$top=200' + . '&$select=' . rawurlencode('Code,Description,Target_Time,No_of_Escalation_Level') + . '&$orderby=' . rawurlencode('Code asc'); + + $response = $this->request('GET', $url); + if ($response === null) { + return []; + } + + $values = $this->extractODataValues($response); + $this->sessionStore->set($cacheKey, [ + 'values' => $values, + 'fetched_at' => time(), + ]); + + return $values; + } + + /** + * Get ticket escalation rows for a customer (ticket no + escalation code). + * + * @return array> + */ + public function getEscalationTicketsForCustomer(string $customerNo): array + { + $customerNo = trim($customerNo); + if ($customerNo === '') { + return []; + } + + $escaped = $this->escapeODataString($customerNo); + $filter = "Customer_No eq '" . $escaped . "'"; + $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS_LV) + . '?$filter=' . rawurlencode($filter) + . '&$top=250' + . '&$select=' . rawurlencode('No,Customer_No,Category_1_Code,Escalation_Code,Ticket_State,Last_Activity_Date') + . '&$orderby=' . rawurlencode('Last_Activity_Date desc'); + + $response = $this->request('GET', $url); + if ($response === null) { + return []; + } + + return $this->extractODataValues($response); + } + + /** + * Get ticket rows for controlling dashboard (wider field set, higher limit). + * + * Uses the same PBI_LV_Tickets entity but includes Created_On, Process_Stage, + * Process_Code and Support_User_Name for resolution time and efficiency metrics. + * + * @return array> + */ + public function getTicketsForControlling(string $customerNo): array + { + $customerNo = trim($customerNo); + if ($customerNo === '') { + return []; + } + + $escaped = $this->escapeODataString($customerNo); + $filter = "Customer_No eq '" . $escaped . "'"; + $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS_LV) + . '?$filter=' . rawurlencode($filter) + . '&$top=500' + . '&$select=' . rawurlencode('No,Customer_No,Category_1_Code,Escalation_Code,Ticket_State,Last_Activity_Date,Created_On,Process_Stage,Process_Code,Support_User_Name') + . '&$orderby=' . rawurlencode('Created_On desc'); + + $response = $this->request('GET', $url); + if ($response === null) { + return []; + } + + return $this->extractODataValues($response); + } + + /** + * Get contracts for a customer by customer number. + * + * Reads from the custom FS_Contracts_Test OData entity and matches both + * bill-to and sell-to customer number to stay robust for mixed setups. + * + * Some BC installations do not support logical OR in $filter for this entity. + * Therefore, we execute two separate requests and merge the rows. + * + * @return array> + */ + public function getContractsForCustomer(string $customerNo): array + { + $customerNo = trim($customerNo); + if ($customerNo === '') { + return []; + } + + $select = 'No,Periodic_Invoicing_Type,Description,State,Sell_to_Cust_No,Sell_to_Cust_Name,Bill_to_Cust_No,Bill_to_Cust_Name,Next_Invoicing_Date,TotalPayoffAmount,No_of_Active_Position,Support_Hours,Starting_Date,Invoicing_Period,MontlyAmount_InvoicingDate,Salesperson_Purchaser_Code,Main_Contract'; + $allRows = []; + $errors = []; + + foreach (['Bill_to_Cust_No', 'Sell_to_Cust_No'] as $customerField) { + try { + $allRows = array_merge($allRows, $this->fetchContractsByCustomerField($customerNo, $customerField, $select)); + } catch (\RuntimeException $e) { + $errors[] = $e; + } + } + + if ($allRows === [] && $errors !== []) { + throw $errors[0]; + } + + $uniqueRows = []; + foreach ($allRows as $row) { + $contractNo = trim((string) ($row['No'] ?? '')); + $rowKey = $contractNo !== '' ? $contractNo : md5((string) json_encode($row)); + if (isset($uniqueRows[$rowKey])) { + continue; + } + $uniqueRows[$rowKey] = $row; + } + + $merged = array_values($uniqueRows); + usort($merged, static function (array $a, array $b): int { + $aDate = trim((string) ($a['Next_Invoicing_Date'] ?? '')); + $bDate = trim((string) ($b['Next_Invoicing_Date'] ?? '')); + if ($aDate === '' && $bDate !== '') { + return 1; + } + if ($aDate !== '' && $bDate === '') { + return -1; + } + + return strcmp($aDate, $bDate); + }); + + return $merged; + } + + /** + * @return array> + */ + private function fetchContractsByCustomerField(string $customerNo, string $customerField, string $select): array + { + $escaped = $this->escapeODataString($customerNo); + $filter = $customerField . " eq '" . $escaped . "'"; + $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTRACTS) + . '?$filter=' . rawurlencode($filter) + . '&$top=150' + . '&$select=' . rawurlencode($select) + . '&$orderby=' . rawurlencode('Next_Invoicing_Date asc'); + + $response = $this->request('GET', $url); + if ($response === null) { + return []; + } + + return $this->extractODataValues($response); + } + + /** + * Get contract line items for a customer. + * + * @return array> + */ + public function getContractLinesForCustomer(string $customerNo): array + { + $customerNo = trim($customerNo); + if ($customerNo === '') { + return []; + } + + $escaped = $this->escapeODataString($customerNo); + $filter = "Customer_No eq '" . $escaped . "'"; + $select = 'Header_No,Line_No,PI_Header_State,PI_Header_Description,Type,Description,Quantity,Unit_of_Measure_Code,Unit_Price,Line_Discount_Percent,Line_Amount,Line_State,Starting_Date,Ending_Date,Position,Attached_to_Line_No'; + $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTRACT_LINES) + . '?$filter=' . rawurlencode($filter) + . '&$top=500' + . '&$select=' . rawurlencode($select) + . '&$orderby=' . rawurlencode('Header_No asc,Line_No asc'); + + $response = $this->request('GET', $url); + if ($response === null) { + return []; + } + + return $this->extractODataValues($response); + } + /** * Get a single ticket by ticket number. * diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorCacheControl.php b/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorCacheControl.php new file mode 100644 index 0000000..e0357ee --- /dev/null +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorCacheControl.php @@ -0,0 +1,121 @@ + $session + */ + public static function resolveTenantScope(array $session): string + { + $tenantId = (int) (($session['current_tenant']['id'] ?? 0)); + + return $tenantId > 0 ? (string) $tenantId : 'global'; + } + + public static function ticketsKey(string $tenantScope, string $customerNo): string + { + return 'module.helpdesk.debitor.v2.tickets.' . $tenantScope . '.' . trim($customerNo); + } + + public static function contactsKey(string $tenantScope, string $customerNo): string + { + return 'module.helpdesk.debitor.v2.contacts.' . $tenantScope . '.' . trim($customerNo); + } + + public static function communicationKey(string $tenantScope, string $customerNo): string + { + return 'module.helpdesk.debitor.v2.communication.' . $tenantScope . '.' . trim($customerNo); + } + + public static function escalationKey(string $tenantScope, string $customerNo): string + { + return 'module.helpdesk.debitor.v2.escalation.' . $tenantScope . '.' . trim($customerNo); + } + + public static function contractsKey(string $tenantScope, string $customerNo): string + { + return 'module.helpdesk.debitor.v2.contracts.' . $tenantScope . '.' . trim($customerNo); + } + + public static function contractLinesKey(string $tenantScope, string $customerNo): string + { + return 'module.helpdesk.debitor.v2.contract-lines.' . $tenantScope . '.' . trim($customerNo); + } + + public static function controllingTicketsKey(string $tenantScope, string $customerNo): string + { + return 'module.helpdesk.debitor.v2.controlling-tickets.' . $tenantScope . '.' . trim($customerNo); + } + + public static function escalationDefinitionsKey(string $tenantScope): string + { + return 'module.helpdesk.debitor.v1.escalation-definitions.' . $tenantScope; + } + + /** + * @return array + */ + public static function allDebitorCacheKeys(string $tenantScope, string $customerNo): array + { + return [ + self::ticketsKey($tenantScope, $customerNo), + self::contactsKey($tenantScope, $customerNo), + self::communicationKey($tenantScope, $customerNo), + self::escalationKey($tenantScope, $customerNo), + self::contractsKey($tenantScope, $customerNo), + self::contractLinesKey($tenantScope, $customerNo), + self::controllingTicketsKey($tenantScope, $customerNo), + // Legacy keys kept for hard refresh compatibility + 'module.helpdesk.tickets_cache.' . $tenantScope . '.' . trim($customerNo), + 'module.helpdesk.contacts_cache.v1.' . $tenantScope . '.' . trim($customerNo), + 'module.helpdesk.debitor_comm_cache.v3.' . $tenantScope . '.' . trim($customerNo), + 'module.helpdesk.escalation_cache.v1.' . $tenantScope . '.' . trim($customerNo), + 'module.helpdesk.contracts_cache.v2.' . $tenantScope . '.' . trim($customerNo), + ]; + } + + public static function invalidateDebitorCaches( + SessionStoreInterface $sessionStore, + string $tenantScope, + string $customerNo, + bool $includeTenantShared = true + ): void { + foreach (self::allDebitorCacheKeys($tenantScope, $customerNo) as $key) { + $sessionStore->remove($key); + } + + if ($includeTenantShared) { + $sessionStore->remove(self::escalationDefinitionsKey($tenantScope)); + } + } +} diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php b/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php index 02f062c..c50e96f 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php @@ -10,6 +10,9 @@ namespace MintyPHP\Module\Helpdesk\Service; */ class DebitorDetailService { + /** @var array */ + private const CLOSED_TICKET_STATES = ['Erledigt', 'Resolved', 'Closed', 'Geschlossen']; + public function __construct( private readonly BcODataGateway $bcODataGateway, private readonly HelpdeskSettingsGateway $settingsGateway @@ -120,6 +123,271 @@ class DebitorDetailService 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'] ?? false)) { + $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) || $targetSeconds <= 0) { + 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 = ((int) ($b['overdue_seconds'] ?? 0)) <=> ((int) ($a['overdue_seconds'] ?? 0)); + if ($cmp !== 0) { + return $cmp; + } + + return strcmp((string) ($a['ticket_no'] ?? ''), (string) ($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. * @@ -130,14 +398,12 @@ class DebitorDetailService */ public static function summarizeTickets(array $tickets): array { - $closedStates = ['Erledigt', 'Resolved', 'Closed', 'Geschlossen']; - $openCount = 0; $lastActivity = ''; foreach ($tickets as $ticket) { $state = (string) ($ticket['Ticket_State'] ?? ''); - if (!in_array($state, $closedStates, true)) { + if (!self::isClosedTicketState($state)) { $openCount++; } @@ -155,6 +421,694 @@ class DebitorDetailService ]; } + /** + * @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). * @@ -238,4 +1192,339 @@ class DebitorDetailService '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), + 'efficiency' => [ + 'resolution_hours' => $resolutionMedian, + 'unassigned_count' => $unassignedOpen, + 'oldest_open_hours' => $oldestOpenAgeHours, + ], + '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 increase', + 'triggered' => $increase > $threshold, + 'value' => ($increase > 0 ? '+' : '') . $increase . '%', + 'threshold' => $threshold . '%', + ]; + } + + // 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; + $indicators[] = [ + 'rule_id' => 'avg_resolution_high', + 'label' => 'High avg. resolution time', + 'triggered' => $triggered, + 'value' => $avgResolution !== null ? round($avgResolution, 1) . 'h' : '—', + 'threshold' => $threshold . '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' => 'Open ticket aging', + 'triggered' => $oldestOpenAgeHours > $threshold, + 'value' => $oldestOpenAgeHours . 'h', + 'threshold' => $threshold . '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 ticket ratio', + 'triggered' => $unassignedRate > $threshold, + 'value' => $unassignedRate . '%', + 'threshold' => $threshold . '%', + ]; + } + + return $indicators; + } } diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskSettingsGateway.php b/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskSettingsGateway.php index 462a66a..b0bc0e8 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskSettingsGateway.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskSettingsGateway.php @@ -22,12 +22,68 @@ class HelpdeskSettingsGateway public const KEY_OAUTH_CLIENT_ID = 'helpdesk.bc_oauth_client_id'; public const KEY_OAUTH_CLIENT_SECRET_ENC = 'helpdesk.bc_oauth_client_secret_enc'; public const KEY_OAUTH_TOKEN_ENDPOINT = 'helpdesk.bc_oauth_token_endpoint'; + public const KEY_SYSTEM_RECOMMENDATIONS_CONFIG = 'helpdesk.system_recommendations_config'; + public const KEY_CONTROLLING_RISK_CONFIG = 'helpdesk.controlling_risk_config'; public const AUTH_MODE_BASIC = 'basic'; public const AUTH_MODE_OAUTH2 = 'oauth2'; public const DEFAULT_ODATA_BASE_URL = 'https://bc.icoreon.de:7048/BusinessCentral-NUP/ODataV4'; public const DEFAULT_COMPANY_NAME = 'breadcrumb mediasolutions GmbH'; + private const DEFAULT_CONTROLLING_RISK_CONFIG = [ + 'version' => 1, + 'rules' => [ + 'ticket_volume_increase' => [ + 'enabled' => true, + 'threshold_percent' => 30, + ], + 'avg_resolution_high' => [ + 'enabled' => true, + 'threshold_hours' => 72, + ], + 'open_aging' => [ + 'enabled' => true, + 'threshold_hours' => 168, + ], + 'unassigned_ratio' => [ + 'enabled' => true, + 'threshold_percent' => 20, + ], + ], + ]; + + private const DEFAULT_SYSTEM_RECOMMENDATIONS_CONFIG = [ + 'version' => 1, + 'max_items' => 5, + 'rules' => [ + 'escalation_overdue' => [ + 'enabled' => true, + 'priority' => 100, + 'min_overdue_minutes' => 0, + ], + 'high_risk_aging' => [ + 'enabled' => true, + 'priority' => 90, + 'codes' => ['MAX', 'HOCH'], + 'min_age_hours' => 8, + ], + 'unassigned_open' => [ + 'enabled' => true, + 'priority' => 80, + 'min_age_hours' => 2, + ], + 'stale_open' => [ + 'enabled' => true, + 'priority' => 70, + 'min_age_hours' => 48, + ], + 'customer_backlog' => [ + 'enabled' => true, + 'priority' => 60, + 'min_open_tickets' => 6, + ], + ], + ]; public function __construct( private readonly SettingsMetadataGateway $settingsMetadataGateway, @@ -259,6 +315,209 @@ class HelpdeskSettingsGateway ); } + /** + * @return array{ + * config: array{ + * version: int, + * max_items: int, + * rules: array{ + * escalation_overdue: array{enabled: bool, priority: int, min_overdue_minutes: int}, + * high_risk_aging: array{enabled: bool, priority: int, codes: array, min_age_hours: int}, + * unassigned_open: array{enabled: bool, priority: int, min_age_hours: int}, + * stale_open: array{enabled: bool, priority: int, min_age_hours: int}, + * customer_backlog: array{enabled: bool, priority: int, min_open_tickets: int} + * } + * }, + * source: 'default'|'settings'|'fallback_invalid_json' + * } + */ + public function getSystemRecommendationsConfigEnvelope(): array + { + $rawValue = $this->settingsMetadataGateway->getValue(self::KEY_SYSTEM_RECOMMENDATIONS_CONFIG); + $rawValue = trim((string) ($rawValue ?? '')); + if ($rawValue === '') { + return [ + 'config' => self::DEFAULT_SYSTEM_RECOMMENDATIONS_CONFIG, + 'source' => 'default', + ]; + } + + $decoded = json_decode($rawValue, true); + if (!is_array($decoded)) { + return [ + 'config' => self::DEFAULT_SYSTEM_RECOMMENDATIONS_CONFIG, + 'source' => 'fallback_invalid_json', + ]; + } + + return [ + 'config' => self::normalizeSystemRecommendationsConfig($decoded), + 'source' => 'settings', + ]; + } + + /** + * @return array{ + * version: int, + * max_items: int, + * rules: array{ + * escalation_overdue: array{enabled: bool, priority: int, min_overdue_minutes: int}, + * high_risk_aging: array{enabled: bool, priority: int, codes: array, min_age_hours: int}, + * unassigned_open: array{enabled: bool, priority: int, min_age_hours: int}, + * stale_open: array{enabled: bool, priority: int, min_age_hours: int}, + * customer_backlog: array{enabled: bool, priority: int, min_open_tickets: int} + * } + * } + */ + public function getSystemRecommendationsConfig(): array + { + $envelope = $this->getSystemRecommendationsConfigEnvelope(); + + return $envelope['config']; + } + + /** + * @param array $config + */ + public function setSystemRecommendationsConfig(array $config): bool + { + $normalized = self::normalizeSystemRecommendationsConfig($config); + $encoded = json_encode($normalized, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if (!is_string($encoded) || $encoded === '') { + return false; + } + + return $this->settingsMetadataGateway->set( + self::KEY_SYSTEM_RECOMMENDATIONS_CONFIG, + $encoded, + 'helpdesk.system_recommendations_config' + ); + } + + /** + * @param array $config + * @return array{ + * version: int, + * max_items: int, + * rules: array{ + * escalation_overdue: array{enabled: bool, priority: int, min_overdue_minutes: int}, + * high_risk_aging: array{enabled: bool, priority: int, codes: array, min_age_hours: int}, + * unassigned_open: array{enabled: bool, priority: int, min_age_hours: int}, + * stale_open: array{enabled: bool, priority: int, min_age_hours: int}, + * customer_backlog: array{enabled: bool, priority: int, min_open_tickets: int} + * } + * } + */ + private static function normalizeSystemRecommendationsConfig(array $config): array + { + $defaults = self::DEFAULT_SYSTEM_RECOMMENDATIONS_CONFIG; + $rawRules = is_array($config['rules'] ?? null) ? $config['rules'] : []; + + $escalationOverdue = is_array($rawRules['escalation_overdue'] ?? null) ? $rawRules['escalation_overdue'] : []; + $highRiskAging = is_array($rawRules['high_risk_aging'] ?? null) ? $rawRules['high_risk_aging'] : []; + $unassignedOpen = is_array($rawRules['unassigned_open'] ?? null) ? $rawRules['unassigned_open'] : []; + $staleOpen = is_array($rawRules['stale_open'] ?? null) ? $rawRules['stale_open'] : []; + $customerBacklog = is_array($rawRules['customer_backlog'] ?? null) ? $rawRules['customer_backlog'] : []; + + return [ + 'version' => 1, + 'max_items' => self::normalizeIntRange($config['max_items'] ?? $defaults['max_items'], 1, 20, (int) $defaults['max_items']), + 'rules' => [ + 'escalation_overdue' => [ + 'enabled' => self::normalizeBool($escalationOverdue['enabled'] ?? $defaults['rules']['escalation_overdue']['enabled']), + 'priority' => self::normalizeIntRange($escalationOverdue['priority'] ?? $defaults['rules']['escalation_overdue']['priority'], 1, 999, (int) $defaults['rules']['escalation_overdue']['priority']), + 'min_overdue_minutes' => self::normalizeIntRange($escalationOverdue['min_overdue_minutes'] ?? $defaults['rules']['escalation_overdue']['min_overdue_minutes'], 0, 43200, (int) $defaults['rules']['escalation_overdue']['min_overdue_minutes']), + ], + 'high_risk_aging' => [ + 'enabled' => self::normalizeBool($highRiskAging['enabled'] ?? $defaults['rules']['high_risk_aging']['enabled']), + 'priority' => self::normalizeIntRange($highRiskAging['priority'] ?? $defaults['rules']['high_risk_aging']['priority'], 1, 999, (int) $defaults['rules']['high_risk_aging']['priority']), + 'codes' => self::normalizeCodeList($highRiskAging['codes'] ?? $defaults['rules']['high_risk_aging']['codes']), + 'min_age_hours' => self::normalizeIntRange($highRiskAging['min_age_hours'] ?? $defaults['rules']['high_risk_aging']['min_age_hours'], 0, 720, (int) $defaults['rules']['high_risk_aging']['min_age_hours']), + ], + 'unassigned_open' => [ + 'enabled' => self::normalizeBool($unassignedOpen['enabled'] ?? $defaults['rules']['unassigned_open']['enabled']), + 'priority' => self::normalizeIntRange($unassignedOpen['priority'] ?? $defaults['rules']['unassigned_open']['priority'], 1, 999, (int) $defaults['rules']['unassigned_open']['priority']), + 'min_age_hours' => self::normalizeIntRange($unassignedOpen['min_age_hours'] ?? $defaults['rules']['unassigned_open']['min_age_hours'], 0, 720, (int) $defaults['rules']['unassigned_open']['min_age_hours']), + ], + 'stale_open' => [ + 'enabled' => self::normalizeBool($staleOpen['enabled'] ?? $defaults['rules']['stale_open']['enabled']), + 'priority' => self::normalizeIntRange($staleOpen['priority'] ?? $defaults['rules']['stale_open']['priority'], 1, 999, (int) $defaults['rules']['stale_open']['priority']), + 'min_age_hours' => self::normalizeIntRange($staleOpen['min_age_hours'] ?? $defaults['rules']['stale_open']['min_age_hours'], 0, 1440, (int) $defaults['rules']['stale_open']['min_age_hours']), + ], + 'customer_backlog' => [ + 'enabled' => self::normalizeBool($customerBacklog['enabled'] ?? $defaults['rules']['customer_backlog']['enabled']), + 'priority' => self::normalizeIntRange($customerBacklog['priority'] ?? $defaults['rules']['customer_backlog']['priority'], 1, 999, (int) $defaults['rules']['customer_backlog']['priority']), + 'min_open_tickets' => self::normalizeIntRange($customerBacklog['min_open_tickets'] ?? $defaults['rules']['customer_backlog']['min_open_tickets'], 1, 500, (int) $defaults['rules']['customer_backlog']['min_open_tickets']), + ], + ], + ]; + } + + /** + * @param mixed $value + */ + private static function normalizeBool(mixed $value): bool + { + if (is_bool($value)) { + return $value; + } + + if (is_string($value)) { + $normalized = strtolower(trim($value)); + if (in_array($normalized, ['1', 'true', 'yes', 'on'], true)) { + return true; + } + if (in_array($normalized, ['0', 'false', 'no', 'off'], true)) { + return false; + } + } + + return (bool) $value; + } + + /** + * @param mixed $value + */ + private static function normalizeIntRange(mixed $value, int $min, int $max, int $default): int + { + if (!is_numeric($value)) { + return $default; + } + + return max($min, min($max, (int) $value)); + } + + /** + * @param mixed $value + * @return array + */ + private static function normalizeCodeList(mixed $value): array + { + $rawCodes = []; + if (is_array($value)) { + $rawCodes = $value; + } elseif (is_string($value)) { + $rawCodes = explode(',', $value); + } + + $normalized = []; + foreach ($rawCodes as $code) { + $candidate = strtoupper(trim((string) $code)); + if ($candidate === '') { + continue; + } + + $normalized[] = $candidate; + } + + $normalized = array_values(array_unique($normalized)); + if ($normalized === []) { + return ['MAX', 'HOCH']; + } + + return $normalized; + } + /** * Build the full OData entity URL for a given entity set. */ @@ -307,4 +566,102 @@ class HelpdeskSettingsGateway { return $this->validateConfiguration() === []; } + + // --- Controlling risk configuration --- + + /** + * @return array{ + * config: array{version: int, rules: array>}, + * source: 'default'|'settings'|'fallback_invalid_json' + * } + */ + public function getControllingRiskConfigEnvelope(): array + { + $rawValue = $this->settingsMetadataGateway->getValue(self::KEY_CONTROLLING_RISK_CONFIG); + $rawValue = trim((string) ($rawValue ?? '')); + if ($rawValue === '') { + return [ + 'config' => self::DEFAULT_CONTROLLING_RISK_CONFIG, + 'source' => 'default', + ]; + } + + $decoded = json_decode($rawValue, true); + if (!is_array($decoded)) { + return [ + 'config' => self::DEFAULT_CONTROLLING_RISK_CONFIG, + 'source' => 'fallback_invalid_json', + ]; + } + + return [ + 'config' => self::normalizeControllingRiskConfig($decoded), + 'source' => 'settings', + ]; + } + + /** + * @return array{version: int, rules: array>} + */ + public function getControllingRiskConfig(): array + { + $envelope = $this->getControllingRiskConfigEnvelope(); + + return $envelope['config']; + } + + /** + * @param array $config + */ + public function setControllingRiskConfig(array $config): bool + { + $normalized = self::normalizeControllingRiskConfig($config); + $encoded = json_encode($normalized, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if (!is_string($encoded) || $encoded === '') { + return false; + } + + return $this->settingsMetadataGateway->set( + self::KEY_CONTROLLING_RISK_CONFIG, + $encoded, + 'helpdesk.controlling_risk_config' + ); + } + + /** + * @param array $config + * @return array{version: int, rules: array>} + */ + private static function normalizeControllingRiskConfig(array $config): array + { + $defaults = self::DEFAULT_CONTROLLING_RISK_CONFIG; + $rawRules = is_array($config['rules'] ?? null) ? $config['rules'] : []; + + $ticketVolume = is_array($rawRules['ticket_volume_increase'] ?? null) ? $rawRules['ticket_volume_increase'] : []; + $avgResolution = is_array($rawRules['avg_resolution_high'] ?? null) ? $rawRules['avg_resolution_high'] : []; + $openAging = is_array($rawRules['open_aging'] ?? null) ? $rawRules['open_aging'] : []; + $unassigned = is_array($rawRules['unassigned_ratio'] ?? null) ? $rawRules['unassigned_ratio'] : []; + + return [ + 'version' => 1, + 'rules' => [ + 'ticket_volume_increase' => [ + 'enabled' => self::normalizeBool($ticketVolume['enabled'] ?? $defaults['rules']['ticket_volume_increase']['enabled']), + 'threshold_percent' => self::normalizeIntRange($ticketVolume['threshold_percent'] ?? $defaults['rules']['ticket_volume_increase']['threshold_percent'], 5, 200, (int) $defaults['rules']['ticket_volume_increase']['threshold_percent']), + ], + 'avg_resolution_high' => [ + 'enabled' => self::normalizeBool($avgResolution['enabled'] ?? $defaults['rules']['avg_resolution_high']['enabled']), + 'threshold_hours' => self::normalizeIntRange($avgResolution['threshold_hours'] ?? $defaults['rules']['avg_resolution_high']['threshold_hours'], 1, 720, (int) $defaults['rules']['avg_resolution_high']['threshold_hours']), + ], + 'open_aging' => [ + 'enabled' => self::normalizeBool($openAging['enabled'] ?? $defaults['rules']['open_aging']['enabled']), + 'threshold_hours' => self::normalizeIntRange($openAging['threshold_hours'] ?? $defaults['rules']['open_aging']['threshold_hours'], 1, 2160, (int) $defaults['rules']['open_aging']['threshold_hours']), + ], + 'unassigned_ratio' => [ + 'enabled' => self::normalizeBool($unassigned['enabled'] ?? $defaults['rules']['unassigned_ratio']['enabled']), + 'threshold_percent' => self::normalizeIntRange($unassigned['threshold_percent'] ?? $defaults['rules']['unassigned_ratio']['threshold_percent'], 1, 100, (int) $defaults['rules']['unassigned_ratio']['threshold_percent']), + ], + ], + ]; + } } diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/SystemRecommendationEngine.php b/modules/helpdesk/lib/Module/Helpdesk/Service/SystemRecommendationEngine.php new file mode 100644 index 0000000..cac612d --- /dev/null +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/SystemRecommendationEngine.php @@ -0,0 +1,472 @@ + */ + private const CLOSED_TICKET_STATES = ['Erledigt', 'Resolved', 'Closed', 'Geschlossen']; + + /** + * @param array> $tickets + * @param array|null $escalationHealth + * @param array $config + * @return array{ + * recommendations: array, + * priority: int, + * urgency_score: int, + * age_hours: int|null, + * source: string + * }>, + * meta: array{ + * applied_rules: array, + * max_items: int, + * escalation_data_available: bool + * } + * } + */ + public function buildRecommendations(array $tickets, ?array $escalationHealth, array $config): array + { + $maxItems = max(1, min(20, (int) ($config['max_items'] ?? 5))); + $rules = is_array($config['rules'] ?? null) ? $config['rules'] : []; + + $openTickets = $this->normalizeOpenTickets($tickets); + $openTicketCount = count($openTickets); + + $escalationAvailable = is_array($escalationHealth) && (($escalationHealth['ok'] ?? false) === true); + $escalationOverdueEntries = $escalationAvailable && is_array($escalationHealth['entries'] ?? null) + ? $escalationHealth['entries'] + : []; + $escalationMatches = $escalationAvailable && is_array($escalationHealth['matches'] ?? null) + ? $escalationHealth['matches'] + : []; + $escalationByTicket = $this->buildEscalationByTicketMap($escalationMatches); + + /** @var array, + * priority: int, + * urgency_score: int, + * age_hours: int|null, + * source: string + * }> $candidates + */ + $candidates = []; + /** @var array $appliedRulesMap */ + $appliedRulesMap = []; + + // Rule 1: escalation_overdue + $rule = $this->getRuleConfig($rules, 'escalation_overdue'); + if ($rule['enabled'] && $escalationAvailable) { + $appliedRulesMap['escalation_overdue'] = true; + $minOverdueMinutes = max(0, (int) ($rule['min_overdue_minutes'] ?? 0)); + $minOverdueSeconds = $minOverdueMinutes * 60; + + foreach ($escalationOverdueEntries as $entry) { + if (!is_array($entry)) { + continue; + } + + $ticketNo = trim((string) ($entry['ticket_no'] ?? '')); + if ($ticketNo === '') { + continue; + } + + $overdueSeconds = max(0, (int) ($entry['overdue_seconds'] ?? 0)); + if ($overdueSeconds < $minOverdueSeconds) { + continue; + } + + $ageHours = $openTickets[$ticketNo]['age_hours'] ?? null; + $overdueHours = max(1, (int) ceil($overdueSeconds / 3600)); + $escalationCode = trim((string) ($entry['escalation_code'] ?? '')); + + $candidates[] = $this->createCandidate( + 'escalation_overdue', + $ticketNo, + 'helpdesk.recommendation.escalation_overdue', + [ + 'overdue_hours' => $overdueHours, + 'escalation_code' => $escalationCode, + ], + (int) $rule['priority'], + $overdueSeconds, + $ageHours, + 'escalation' + ); + } + } + + // Rule 2: high_risk_aging + $rule = $this->getRuleConfig($rules, 'high_risk_aging'); + if ($rule['enabled'] && $escalationAvailable) { + $appliedRulesMap['high_risk_aging'] = true; + $minAgeHours = max(0, (int) ($rule['min_age_hours'] ?? 8)); + $codes = $this->normalizeCodes($rule['codes'] ?? []); + $codeMap = array_fill_keys($codes, true); + + foreach ($openTickets as $ticketNo => $ticket) { + $ageHours = $ticket['age_hours']; + if (!is_int($ageHours) || $ageHours < $minAgeHours) { + continue; + } + + $match = $escalationByTicket[$ticketNo] ?? null; + if (!is_array($match)) { + continue; + } + + $escalationCode = strtoupper(trim((string) ($match['escalation_code'] ?? ''))); + if ($escalationCode === '' || !isset($codeMap[$escalationCode])) { + continue; + } + + $candidates[] = $this->createCandidate( + 'high_risk_aging', + $ticketNo, + 'helpdesk.recommendation.high_risk_aging', + [ + 'age_hours' => $ageHours, + 'escalation_code' => $escalationCode, + ], + (int) $rule['priority'], + $ageHours, + $ageHours, + 'tickets' + ); + } + } + + // Rule 3: unassigned_open + $rule = $this->getRuleConfig($rules, 'unassigned_open'); + if ($rule['enabled']) { + $appliedRulesMap['unassigned_open'] = true; + $minAgeHours = max(0, (int) ($rule['min_age_hours'] ?? 2)); + + foreach ($openTickets as $ticketNo => $ticket) { + $ageHours = $ticket['age_hours']; + if (!is_int($ageHours) || $ageHours < $minAgeHours) { + continue; + } + + if ($ticket['support_user_name'] !== '') { + continue; + } + + $candidates[] = $this->createCandidate( + 'unassigned_open', + $ticketNo, + 'helpdesk.recommendation.unassigned_open', + ['age_hours' => $ageHours], + (int) $rule['priority'], + $ageHours, + $ageHours, + 'tickets' + ); + } + } + + // Rule 4: stale_open + $rule = $this->getRuleConfig($rules, 'stale_open'); + if ($rule['enabled']) { + $appliedRulesMap['stale_open'] = true; + $minAgeHours = max(0, (int) ($rule['min_age_hours'] ?? 48)); + + foreach ($openTickets as $ticketNo => $ticket) { + $ageHours = $ticket['age_hours']; + if (!is_int($ageHours) || $ageHours < $minAgeHours) { + continue; + } + + $candidates[] = $this->createCandidate( + 'stale_open', + $ticketNo, + 'helpdesk.recommendation.stale_open', + ['age_hours' => $ageHours], + (int) $rule['priority'], + $ageHours, + $ageHours, + 'tickets' + ); + } + } + + // Rule 5: customer_backlog + $rule = $this->getRuleConfig($rules, 'customer_backlog'); + if ($rule['enabled']) { + $appliedRulesMap['customer_backlog'] = true; + $minOpenTickets = max(1, (int) ($rule['min_open_tickets'] ?? 6)); + if ($openTicketCount >= $minOpenTickets) { + $oldest = $this->findOldestOpenTicket($openTickets); + if (is_array($oldest)) { + $ageHours = $oldest['age_hours']; + $urgencyScore = ($openTicketCount * 1000) + (is_int($ageHours) ? $ageHours : 0); + $candidates[] = $this->createCandidate( + 'customer_backlog', + (string) $oldest['ticket_no'], + 'helpdesk.recommendation.customer_backlog', + ['open_tickets' => $openTicketCount], + (int) $rule['priority'], + $urgencyScore, + is_int($ageHours) ? $ageHours : null, + 'tickets' + ); + } + } + } + + $deduped = $this->dedupeByRuleAndTicket($candidates); + usort($deduped, static function (array $a, array $b): int { + $priorityCmp = (int) $b['priority'] <=> (int) $a['priority']; + if ($priorityCmp !== 0) { + return $priorityCmp; + } + + $urgencyCmp = (int) $b['urgency_score'] <=> (int) $a['urgency_score']; + if ($urgencyCmp !== 0) { + return $urgencyCmp; + } + + return strcmp((string) $a['ticket_no'], (string) $b['ticket_no']); + }); + + return [ + 'recommendations' => array_slice($deduped, 0, $maxItems), + 'meta' => [ + 'applied_rules' => array_values(array_keys($appliedRulesMap)), + 'max_items' => $maxItems, + 'escalation_data_available' => $escalationAvailable, + ], + ]; + } + + /** + * @param array $rules + * @return array + */ + private function getRuleConfig(array $rules, string $ruleId): array + { + $rule = $rules[$ruleId] ?? []; + if (!is_array($rule)) { + $rule = []; + } + + return $rule; + } + + /** + * @param array> $matches + * @return array + */ + private function buildEscalationByTicketMap(array $matches): array + { + $map = []; + foreach ($matches as $match) { + $ticketNo = trim((string) ($match['ticket_no'] ?? '')); + if ($ticketNo === '') { + continue; + } + + $map[$ticketNo] = [ + 'escalation_code' => trim((string) ($match['escalation_code'] ?? '')), + 'age_hours' => is_numeric($match['age_hours'] ?? null) ? (int) $match['age_hours'] : null, + ]; + } + + return $map; + } + + /** + * @param array|string $codesValue + * @return array + */ + private function normalizeCodes(array|string $codesValue): array + { + if (is_string($codesValue)) { + $codesValue = explode(',', $codesValue); + } + + $normalized = []; + foreach ($codesValue as $code) { + $value = strtoupper(trim((string) $code)); + if ($value === '') { + continue; + } + + $normalized[] = $value; + } + + return array_values(array_unique($normalized)); + } + + /** + * @param array $openTickets + * @return array{ticket_no: string, support_user_name: string, age_hours: int|null}|null + */ + private function findOldestOpenTicket(array $openTickets): ?array + { + $oldest = null; + $oldestAge = -1; + + foreach ($openTickets as $ticket) { + $ageHours = is_int($ticket['age_hours']) ? $ticket['age_hours'] : -1; + if ($oldest === null || $ageHours > $oldestAge) { + $oldest = $ticket; + $oldestAge = $ageHours; + } + } + + return $oldest; + } + + /** + * @param array, + * priority: int, + * urgency_score: int, + * age_hours: int|null, + * source: string + * }> $candidates + * @return array, + * priority: int, + * urgency_score: int, + * age_hours: int|null, + * source: string + * }> + */ + private function dedupeByRuleAndTicket(array $candidates): array + { + $unique = []; + foreach ($candidates as $candidate) { + $key = (string) $candidate['rule_id'] . '|' . (string) $candidate['ticket_no']; + if (isset($unique[$key])) { + continue; + } + + $unique[$key] = $candidate; + } + + return array_values($unique); + } + + /** + * @param array> $tickets + * @return array + */ + private function normalizeOpenTickets(array $tickets): array + { + $nowTs = time(); + $openTickets = []; + + foreach ($tickets as $ticket) { + if (!is_array($ticket)) { + continue; + } + + $state = trim((string) ($ticket['Ticket_State'] ?? '')); + if (in_array($state, self::CLOSED_TICKET_STATES, true)) { + continue; + } + + $ticketNo = trim((string) ($ticket['No'] ?? '')); + if ($ticketNo === '') { + continue; + } + + $activityTs = $this->resolveActivityTimestamp($ticket); + $ageHours = null; + if ($activityTs !== null) { + $ageHours = max(0, intdiv(max(0, $nowTs - $activityTs), 3600)); + } + + $openTickets[$ticketNo] = [ + 'ticket_no' => $ticketNo, + 'support_user_name' => trim((string) ($ticket['Support_User_Name'] ?? '')), + 'age_hours' => $ageHours, + ]; + } + + return $openTickets; + } + + /** + * @param array $ticket + */ + private function resolveActivityTimestamp(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; + } + + /** + * @param array $messageParams + * @return array{ + * recommendation_id: string, + * rule_id: string, + * ticket_no: string, + * message_key: string, + * message_params: array, + * priority: int, + * urgency_score: int, + * age_hours: int|null, + * source: string + * } + */ + private function createCandidate( + string $ruleId, + string $ticketNo, + string $messageKey, + array $messageParams, + int $priority, + int $urgencyScore, + ?int $ageHours, + string $source + ): array { + return [ + 'recommendation_id' => $ruleId . '-' . $ticketNo, + 'rule_id' => $ruleId, + 'ticket_no' => $ticketNo, + 'message_key' => $messageKey, + 'message_params' => $messageParams, + 'priority' => $priority, + 'urgency_score' => max(0, $urgencyScore), + 'age_hours' => $ageHours, + 'source' => $source, + ]; + } +} diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/TicketCommunicationService.php b/modules/helpdesk/lib/Module/Helpdesk/Service/TicketCommunicationService.php index 9d514d5..9c323c3 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/TicketCommunicationService.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/TicketCommunicationService.php @@ -21,8 +21,12 @@ class TicketCommunicationService * error?: string * } */ - public function loadTicketCommunicationFeed(string $ticketNo, int $maxEntries = 40): array - { + public function loadTicketCommunicationFeed( + string $ticketNo, + int $maxEntries = 40, + array $actorNameMap = [], + bool $allowPerTicketActorLookup = true + ): array { $ticketNo = trim($ticketNo); if ($ticketNo === '') { return ['ok' => false, 'error' => 'Missing ticket number']; @@ -42,18 +46,18 @@ class TicketCommunicationService $messageEntries = $this->buildSoapMessageEntriesFromTicketLog($ticketLog); $soapResult = $this->bcSoapGateway->getTicketCommunicationText($ticketNo, $messageEntries); $soapError = (string) ($soapResult['error'] ?? ''); - $soapUsed = (bool) ($soapResult['soap_used'] ?? false); + $soapUsed = (bool) $soapResult['soap_used']; $soapEntries = []; $soapMessageMap = []; - if (($soapResult['ok'] ?? false) === true) { + if ($soapResult['ok'] === true) { $soapEntries = $this->parseSoapEntries( $ticketNo, - (string) ($soapResult['communication_text'] ?? ''), - (string) ($soapResult['message_entries'] ?? '') + (string) $soapResult['communication_text'], + (string) $soapResult['message_entries'] ); $soapMessageMap = $this->extractSoapMessageMap( - (string) ($soapResult['communication_text'] ?? ''), - (string) ($soapResult['message_entries'] ?? '') + (string) $soapResult['communication_text'], + (string) $soapResult['message_entries'] ); } @@ -61,10 +65,15 @@ class TicketCommunicationService $odataEntries = $this->mapODataEntries($ticketNo, $ticketLog, $soapMessageMap); } - $actorNameMap = $this->resolveContactActorNames($ticketNo, $odataEntries, $soapEntries); if ($actorNameMap !== []) { $odataEntries = $this->applyActorNameMap($odataEntries, $actorNameMap); $soapEntries = $this->applyActorNameMap($soapEntries, $actorNameMap); + } elseif ($allowPerTicketActorLookup) { + $resolvedActorNameMap = $this->resolveContactActorNames($ticketNo, $odataEntries, $soapEntries); + if ($resolvedActorNameMap !== []) { + $odataEntries = $this->applyActorNameMap($odataEntries, $resolvedActorNameMap); + $soapEntries = $this->applyActorNameMap($soapEntries, $resolvedActorNameMap); + } } $merged = $this->mergeEntries($odataEntries, $soapEntries, $maxEntries); @@ -161,11 +170,17 @@ class TicketCommunicationService $allEntries = []; $ticketsWithData = 0; $soapPartialFailures = 0; + $actorNameMap = $this->buildContactActorNameMapForDebitor($customerName); foreach (array_keys($ticketsToProcess) as $ticketNo) { - $feed = $this->loadTicketCommunicationFeed($ticketNo, $maxEntriesPerTicket); + $feed = $this->loadTicketCommunicationFeed( + $ticketNo, + $maxEntriesPerTicket, + $actorNameMap, + false + ); $ticketEntries = []; - if ($feed['ok'] ?? false) { + if ($feed['ok']) { $ticketEntries = is_array($feed['entries'] ?? null) ? $feed['entries'] : []; } @@ -428,7 +443,7 @@ class TicketCommunicationService if (preg_match('/^(\d{4}-\d{2}-\d{2})(?:[T\s](\d{2}:\d{2}(?::\d{2})?))?\s*[-|]\s*(.+)$/u', $working, $m) === 1) { $date = $m[1]; - $time = isset($m[2]) ? $this->normalizeTime($m[2]) : ''; + $time = $this->normalizeTime($m[2]); $timestampIso = $this->combineDateTimeToIso($date, $time); $working = trim($m[3]); } elseif (preg_match('/^(\d{2}\.\d{2}\.\d{4})\s+(\d{2}:\d{2}(?::\d{2})?)\s*[-|]\s*(.+)$/u', $working, $m) === 1) { @@ -837,6 +852,38 @@ class TicketCommunicationService return $value !== '' && preg_match('/^KT\d+$/', $value) === 1; } + /** + * @return array + */ + private function buildContactActorNameMapForDebitor(string $customerName): array + { + $customerName = trim($customerName); + if ($customerName === '') { + return []; + } + + try { + $contacts = $this->bcODataGateway->getContactsForCustomer('', $customerName); + } catch (\Throwable) { + return []; + } + + $nameMap = []; + foreach ($contacts as $contact) { + if (!is_array($contact)) { + continue; + } + $contactNo = strtoupper(trim((string) ($contact['No'] ?? ''))); + $contactName = trim((string) ($contact['Name'] ?? '')); + if ($contactNo === '' || $contactName === '') { + continue; + } + $nameMap[$contactNo] = $contactName; + } + + return $nameMap; + } + private function normalizeActorRole(string $createdByType, string $actor): string { $type = mb_strtolower(trim($createdByType)); diff --git a/modules/helpdesk/module.php b/modules/helpdesk/module.php index a4c9d04..2370dbf 100644 --- a/modules/helpdesk/module.php +++ b/modules/helpdesk/module.php @@ -14,13 +14,14 @@ return [ 'requires' => [], 'routes' => [ - ['path' => 'helpdesk/debitor', 'target' => 'helpdesk/search'], ['path' => 'helpdesk/search-data', 'target' => 'helpdesk/search-data'], ['path' => 'helpdesk/debitor/{id}', 'target' => 'helpdesk/debitor'], ['path' => 'helpdesk/ticket/{id}', 'target' => 'helpdesk/ticket'], ['path' => 'helpdesk/settings', 'target' => 'helpdesk/settings'], ['path' => 'helpdesk/settings/test-connection-data', 'target' => 'helpdesk/settings/test-connection-data'], ['path' => 'helpdesk/settings/diagnose-data', 'target' => 'helpdesk/settings/diagnose-data'], + ['path' => 'helpdesk/debitor-support-dashboard-data', 'target' => 'helpdesk/debitor-support-dashboard-data'], + ['path' => 'helpdesk/debitor-overview-data', 'target' => 'helpdesk/debitor-overview-data'], ['path' => 'helpdesk/debitor-summary-data', 'target' => 'helpdesk/debitor-summary-data'], ['path' => 'helpdesk/debitor-contacts-data', 'target' => 'helpdesk/debitor-contacts-data'], ['path' => 'helpdesk/debitor-tickets-data', 'target' => 'helpdesk/debitor-tickets-data'], @@ -28,6 +29,8 @@ return [ ['path' => 'helpdesk/ticket-log-data', 'target' => 'helpdesk/ticket-log-data'], ['path' => 'helpdesk/ticket-communication-data', 'target' => 'helpdesk/ticket-communication-data'], ['path' => 'helpdesk/debitor-communication-data', 'target' => 'helpdesk/debitor-communication-data'], + ['path' => 'helpdesk/debitor-sales-dashboard-data', 'target' => 'helpdesk/debitor-sales-dashboard-data'], + ['path' => 'helpdesk/debitor-controlling-dashboard-data', 'target' => 'helpdesk/debitor-controlling-dashboard-data'], ], 'public_paths' => [], @@ -41,7 +44,7 @@ return [ 'key' => 'helpdesk', 'label' => 'Helpdesk', 'icon' => 'bi-headset', - 'href' => 'helpdesk/debitor', + 'href' => 'helpdesk', 'permission' => 'helpdesk.access', 'panel_template' => 'templates/aside-helpdesk-panel.phtml', 'details_storage' => 'aside-helpdesk-sections-v1', diff --git a/modules/helpdesk/pages/helpdesk/debitor($id).php b/modules/helpdesk/pages/helpdesk/debitor($id).php index 4ae6c3c..622613b 100644 --- a/modules/helpdesk/pages/helpdesk/debitor($id).php +++ b/modules/helpdesk/pages/helpdesk/debitor($id).php @@ -15,7 +15,7 @@ $request = requestInput(); $customerNo = trim((string) ($id ?? '')); if ($customerNo === '') { - Router::redirect('helpdesk/debitor'); + Router::redirect('helpdesk'); return; } @@ -26,15 +26,15 @@ $detail = $detailService->loadCustomer($customerNo); $detailStatus = (string) ($detail['status'] ?? ''); if ($detailStatus === 'not_found') { - Flash::error(t('Debtor not found'), 'helpdesk/debitor', 'debtor_not_found'); - Router::redirect('helpdesk/debitor'); + Flash::error(t('Debtor not found'), 'helpdesk', 'debtor_not_found'); + Router::redirect('helpdesk'); return; } if ($detailStatus === 'not_configured') { - Flash::error(t('BC connection is not configured'), 'helpdesk/debitor', 'not_configured'); - Router::redirect('helpdesk/debitor'); + Flash::error(t('BC connection is not configured'), 'helpdesk', 'not_configured'); + Router::redirect('helpdesk'); return; } @@ -52,10 +52,11 @@ if ($returnTarget !== '') { $searchQuery = trim((string) $request->query('search', '')); $backUrl = $searchQuery !== '' - ? lurl('helpdesk/debitor') . '?search=' . rawurlencode($searchQuery) - : lurl('helpdesk/debitor'); + ? lurl('helpdesk') . '?search=' . rawurlencode($searchQuery) + : lurl('helpdesk'); } +// --- Tickets filter context --- $ticketsFilterSchema = require __DIR__ . '/debitor-tickets-filter-schema.php'; $ticketsFilterState = gridParseFilters($request->queryAll(), gridSchemaQuery($ticketsFilterSchema)); $ticketsToolbarOptionSets = [ @@ -92,5 +93,36 @@ $filterChipMeta = [ ], ]; +// --- Contacts filter context --- +$contactsFilterSchema = require __DIR__ . '/debitor-contacts-filter-schema.php'; +$contactsFilterState = gridParseFilters($request->queryAll(), gridSchemaQuery($contactsFilterSchema)); +$contactsToolbarOptionSets = [ + 'type_items' => [], +]; +$contactsListFilterContext = gridBuildListFilterContext($contactsFilterSchema, [ + 'filter_state' => $contactsFilterState, + 'search_keys' => ['search'], + 'toolbar_option_sets' => $contactsToolbarOptionSets, +]); +$contactsSearchToolbarFilterSchema = $contactsListFilterContext['searchToolbarFilterSchema']; +$contactsDrawerToolbarFilterSchema = $contactsListFilterContext['drawerToolbarFilterSchema']; +$contactsToolbarFilterState = $contactsListFilterContext['toolbarFilterState']; +$contactsToolbarOptionSets = $contactsListFilterContext['toolbarOptionSets']; +$contactsSchemaByKey = $contactsListFilterContext['schemaByKey']; +$contactsClientFilterSchema = $contactsListFilterContext['clientFilterSchema']; +$contactsSearchConfig = $contactsListFilterContext['searchConfig']; +$contactsFilterChipMeta = [ + 'search' => [ + 'label' => t('Search'), + 'type' => 'text', + ], + 'type' => [ + 'label' => t('Type'), + 'type' => 'select', + 'default' => (string) (($contactsSchemaByKey['type']['default'] ?? '')), + 'options' => gridOptionMapFromItems((array) ($contactsToolbarOptionSets['type_items'] ?? [])), + ], +]; + Buffer::set('title', $customerName . ' — ' . t('Helpdesk')); Buffer::set('style_groups', json_encode(['helpdesk'])); diff --git a/modules/helpdesk/pages/helpdesk/debitor(default).phtml b/modules/helpdesk/pages/helpdesk/debitor(default).phtml index 67d6fa2..fd83d36 100644 --- a/modules/helpdesk/pages/helpdesk/debitor(default).phtml +++ b/modules/helpdesk/pages/helpdesk/debitor(default).phtml @@ -14,7 +14,7 @@ $customer = is_array($customer ?? null) ? $customer : []; $hasError = $hasError ?? false; $errorMessage = $errorMessage ?? ''; $customerName = $customerName ?? ''; -$backUrl = $backUrl ?? lurl('helpdesk/debitor'); +$backUrl = $backUrl ?? lurl('helpdesk'); $searchToolbarFilterSchema = is_array($searchToolbarFilterSchema ?? null) ? $searchToolbarFilterSchema : []; $drawerToolbarFilterSchema = is_array($drawerToolbarFilterSchema ?? null) ? $drawerToolbarFilterSchema : []; @@ -24,38 +24,44 @@ $filterChipMeta = is_array($filterChipMeta ?? null) ? $filterChipMeta : []; $clientFilterSchema = is_array($clientFilterSchema ?? null) ? $clientFilterSchema : []; $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null; -$supportType = (string) ($customer['Support_Type'] ?? ''); -$salesperson = (string) ($customer['Salesperson_Code'] ?? ''); -$city = (string) ($customer['City'] ?? ''); -$postCode = (string) ($customer['Post_Code'] ?? ''); -$locationDisplay = $postCode !== '' && $city !== '' ? $postCode . ' ' . $city : ($city ?: $postCode); - ?>
t('Home'), 'path' => 'admin'], - ['label' => t('Helpdesk'), 'path' => 'helpdesk/debitor'], + ['label' => t('Helpdesk'), 'path' => 'helpdesk'], ['label' => $customerName], ]; require templatePath('partials/app-breadcrumb.phtml'); ?> t('Refresh data'), + 'type' => 'button', + 'name' => 'support-refresh', + 'class' => 'secondary outline', + ]; + } + $titlebar = [ 'title' => $customerName, 'backHref' => $backUrl, 'backTitle' => t('Back'), - 'actions' => [], + 'actions' => $titlebarActions, ]; require templatePath('partials/app-details-titlebar.phtml'); ?> @@ -71,154 +77,177 @@ $locationDisplay = $postCode !== '' && $city !== '' ? $postCode . ' ' . $city :
- -
- - '#', - 'label' => t('Open tickets'), - 'count' => '—', - 'icon' => 'bi bi-ticket-detailed-fill', - 'iconBg' => '#f3e8ff', - 'iconColor' => '#7c3aed', - ]); - ?> - - - '#', - 'label' => t('Last activity'), - 'count' => '—', - 'icon' => 'bi bi-clock-history', - 'iconBg' => '#fde8d8', - 'iconColor' => '#b35c14', - ]); - ?> - - - '#', - 'label' => t('Contacts'), - 'count' => '—', - 'icon' => 'bi bi-people-fill', - 'iconBg' => '#d9f2e6', - 'iconColor' => '#1f6a3a', - ]); - ?> - - '#', - 'label' => t('Support type'), - 'count' => $supportType !== '' ? $supportType : '—', - 'icon' => 'bi bi-headset', - 'iconBg' => '#d9e8fb', - 'iconColor' => '#1a56db', - ]); - ?> -
- - +
- - -
- -
-
-