diff --git a/lib/Http/RouteRegistrar.php b/lib/Http/RouteRegistrar.php index a6766d8..891f38a 100644 --- a/lib/Http/RouteRegistrar.php +++ b/lib/Http/RouteRegistrar.php @@ -19,8 +19,8 @@ final class RouteRegistrar { $coreRoutePaths = []; foreach ($catalog->coreRoutes() as $route) { - $path = trim((string) ($route['path'] ?? '')); - $target = trim((string) ($route['target'] ?? '')); + $path = trim($route['path']); + $target = trim($route['target']); if ($path === '' || $target === '') { continue; } @@ -31,9 +31,9 @@ final class RouteRegistrar $modulePaths = []; foreach ($this->moduleRegistry->getRoutes() as $route) { - $path = trim((string) ($route['path'] ?? '')); - $target = trim((string) ($route['target'] ?? '')); - $moduleId = trim((string) ($route['module_id'] ?? '')); + $path = trim($route['path']); + $target = trim($route['target']); + $moduleId = trim($route['module_id']); if ($path === '' || $target === '') { continue; } diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php b/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php index e1ae5c5..d8085c0 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/BcODataGateway.php @@ -47,7 +47,7 @@ class BcODataGateway return []; } - $escaped = $this->escapeODataString($query); + $escaped = $this->escapeODataStrictUserInput($query); $upperQuery = mb_strtoupper($escaped); $select = '&$select=No,Name,Search_Name,Address,City,Post_Code,Phone_No,E_Mail,Salesperson_Code'; $baseUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_CUSTOMER); @@ -108,13 +108,13 @@ class BcODataGateway $cityClause = ''; if ($city !== '') { - $escapedCity = $this->escapeODataString($city); + $escapedCity = $this->escapeODataStrictUserInput($city); $cityClause = "startswith(City,'" . $escapedCity . "')"; } $searchStrategies = ['']; if ($search !== '') { - $escapedSearch = $this->escapeODataString($search); + $escapedSearch = $this->escapeODataStrictUserInput($search); $upperSearch = mb_strtoupper($escapedSearch); $searchStrategies = [ "contains(Search_Name,'" . $upperSearch . "') or contains(Name,'" . $escapedSearch . "') or contains(No,'" . $escapedSearch . "')", @@ -180,7 +180,7 @@ class BcODataGateway return null; } - $escaped = $this->escapeODataString($customerNo); + $escaped = $this->escapeODataStrictUserInput($customerNo); $filter = "No eq '" . $escaped . "'"; $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CUSTOMER) . '?$filter=' . rawurlencode($filter) @@ -199,20 +199,205 @@ class BcODataGateway /** * Get contacts for a customer by customer name. * - * BC OData does not support filtering by IntegrationCustomerNo, so we - * filter by Company_Name (the customer's name in the contact card). - * * @return array> */ public function getContactsForCustomer(string $customerNo, string $customerName = ''): array { + $result = $this->getContactsForCustomerWithMeta($customerNo, $customerName); + + return $result['contacts']; + } + + /** + * Get contacts for a customer with fallback metadata. + * + * @return array{ + * contacts: array>, + * meta: array{ + * source: string, + * fallback_used: bool, + * fallback_reason: string + * } + * } + */ + public function getContactsForCustomerWithMeta(string $customerNo, string $customerName = ''): array + { + $customerNo = trim($customerNo); $customerName = trim($customerName); - if ($customerName === '') { + if ($customerNo === '' || $customerName === '') { + return [ + 'contacts' => [], + 'meta' => [ + 'source' => 'primary.company_name', + 'fallback_used' => false, + 'fallback_reason' => '', + ], + ]; + } + + $primaryError = false; + $primaryContacts = []; + + try { + $primaryContacts = $this->fetchContactsByField('Company_Name', $customerName); + } catch (\Throwable) { + $primaryError = true; + } + + if ($primaryContacts !== []) { + return [ + 'contacts' => $primaryContacts, + 'meta' => [ + 'source' => 'primary.company_name', + 'fallback_used' => false, + 'fallback_reason' => '', + ], + ]; + } + + $fallbackReason = $primaryError ? 'primary_query_failed' : 'primary_empty'; + + try { + $integrationContacts = $this->fetchContactsByField('IntegrationCustomerNo', $customerNo); + if ($integrationContacts !== []) { + return [ + 'contacts' => $integrationContacts, + 'meta' => [ + 'source' => 'fallback.integration_customer_no', + 'fallback_used' => true, + 'fallback_reason' => $fallbackReason, + ], + ]; + } + } catch (\Throwable) { + // Continue with second fallback. + } + + try { + $companyNoContacts = $this->fetchContactsByField('Company_No', $customerNo); + if ($companyNoContacts !== []) { + return [ + 'contacts' => $companyNoContacts, + 'meta' => [ + 'source' => 'fallback.company_no', + 'fallback_used' => true, + 'fallback_reason' => $fallbackReason, + ], + ]; + } + } catch (\Throwable) { + // Fall back to primary result (empty set). + } + + return [ + 'contacts' => $this->dedupeRowsByNo($primaryContacts), + 'meta' => [ + 'source' => 'primary.company_name', + 'fallback_used' => false, + 'fallback_reason' => '', + ], + ]; + } + + /** + * Get tickets for a customer by customer name. + * + * @return array> + */ + public function getTicketsForCustomer(string $customerNo, string $customerName = ''): array + { + $result = $this->getTicketsForCustomerWithMeta($customerNo, $customerName); + + return $result['tickets']; + } + + /** + * Get tickets for a customer with fallback metadata. + * + * @return array{ + * tickets: array>, + * meta: array{ + * source: string, + * fallback_used: bool, + * fallback_reason: string + * } + * } + */ + public function getTicketsForCustomerWithMeta(string $customerNo, string $customerName = ''): array + { + $customerNo = trim($customerNo); + $customerName = trim($customerName); + if ($customerNo === '' || $customerName === '') { + return [ + 'tickets' => [], + 'meta' => [ + 'source' => 'primary.company_contact_name', + 'fallback_used' => false, + 'fallback_reason' => '', + ], + ]; + } + + $primaryError = false; + $primaryTickets = []; + + try { + $primaryTickets = $this->fetchTicketsByCompanyContactName($customerName); + } catch (\Throwable) { + $primaryError = true; + } + + if ($primaryTickets !== []) { + return [ + 'tickets' => $primaryTickets, + 'meta' => [ + 'source' => 'primary.company_contact_name', + 'fallback_used' => false, + 'fallback_reason' => '', + ], + ]; + } + + $fallbackReason = $primaryError ? 'primary_query_failed' : 'primary_empty'; + try { + $fallbackTickets = $this->fetchTicketsByCustomerNoFromLv($customerNo, $customerName); + } catch (\Throwable) { + $fallbackTickets = []; + } + + if ($fallbackTickets !== []) { + return [ + 'tickets' => $fallbackTickets, + 'meta' => [ + 'source' => 'fallback.customer_no_lv', + 'fallback_used' => true, + 'fallback_reason' => $fallbackReason, + ], + ]; + } + + return [ + 'tickets' => $primaryTickets, + 'meta' => [ + 'source' => 'primary.company_contact_name', + 'fallback_used' => false, + 'fallback_reason' => '', + ], + ]; + } + + /** + * @return array> + */ + private function fetchContactsByField(string $field, string $value): array + { + $value = trim($value); + if ($value === '') { return []; } - $escaped = $this->escapeODataString($customerName); - $filter = "Company_Name eq '" . $escaped . "'"; + $escaped = $this->escapeODataTrustedLiteral($value); + $filter = $field . " eq '" . $escaped . "'"; $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTACT) . '?$filter=' . rawurlencode($filter) . '&$top=100' @@ -223,25 +408,23 @@ class BcODataGateway return []; } - return $this->extractODataValues($response); + return $this->dedupeRowsByNo($this->extractODataValues($response)); } /** - * Get tickets for a customer by customer name. - * * Uses the PBI_FP_Tickets entity (FactPage / Power BI view) which supports - * server-side $filter and $orderby — unlike the Card-based "Tickets" entity. + * server-side $filter and $orderby. * * @return array> */ - public function getTicketsForCustomer(string $customerNo, string $customerName = ''): array + private function fetchTicketsByCompanyContactName(string $customerName): array { $customerName = trim($customerName); if ($customerName === '') { return []; } - $escaped = $this->escapeODataString($customerName); + $escaped = $this->escapeODataTrustedLiteral($customerName); $filter = "Company_Contact_Name eq '" . $escaped . "'"; $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS) . '?$filter=' . rawurlencode($filter) @@ -254,7 +437,59 @@ class BcODataGateway return []; } - return $this->extractODataValues($response); + return $this->dedupeRowsByNo($this->extractODataValues($response)); + } + + /** + * Fallback ticket source by customer number using LV entity. + * + * @return array> + */ + private function fetchTicketsByCustomerNoFromLv(string $customerNo, string $customerName): array + { + $customerNo = trim($customerNo); + if ($customerNo === '') { + return []; + } + + $escaped = $this->escapeODataTrustedLiteral($customerNo); + $filter = "Customer_No eq '" . $escaped . "'"; + $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS_LV) + . '?$filter=' . rawurlencode($filter) + . '&$top=300' + . '&$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 []; + } + + $rows = $this->extractODataValues($response); + $mapped = []; + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + $ticketNo = trim((string) ($row['No'] ?? '')); + if ($ticketNo === '') { + continue; + } + + $mapped[] = [ + 'No' => $ticketNo, + 'Description' => trim((string) ($row['Process_Code'] ?? '')), + 'Company_Contact_Name' => $customerName, + 'Current_Contact_Name' => '', + 'Category_1_Description' => trim((string) ($row['Category_1_Code'] ?? '')), + 'Ticket_State' => trim((string) ($row['Ticket_State'] ?? '')), + 'Support_User_Name' => trim((string) ($row['Support_User_Name'] ?? '')), + 'Created_On' => trim((string) ($row['Created_On'] ?? '')), + 'Last_Activity_Date' => trim((string) ($row['Last_Activity_Date'] ?? '')), + ]; + } + + return $this->dedupeRowsByNo($mapped); } /** @@ -275,7 +510,7 @@ class BcODataGateway } $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_ESCALATION) - . '?$top=200' + . '?$top=50' . '&$select=' . rawurlencode('Code,Description,Target_Time,No_of_Escalation_Level') . '&$orderby=' . rawurlencode('Code asc'); @@ -305,11 +540,11 @@ class BcODataGateway return []; } - $escaped = $this->escapeODataString($customerNo); + $escaped = $this->escapeODataTrustedLiteral($customerNo); $filter = "Customer_No eq '" . $escaped . "'"; $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS_LV) . '?$filter=' . rawurlencode($filter) - . '&$top=250' + . '&$top=200' . '&$select=' . rawurlencode('No,Customer_No,Category_1_Code,Escalation_Code,Ticket_State,Last_Activity_Date') . '&$orderby=' . rawurlencode('Last_Activity_Date desc'); @@ -336,11 +571,11 @@ class BcODataGateway return []; } - $escaped = $this->escapeODataString($customerNo); + $escaped = $this->escapeODataTrustedLiteral($customerNo); $filter = "Customer_No eq '" . $escaped . "'"; $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS_LV) . '?$filter=' . rawurlencode($filter) - . '&$top=500' + . '&$top=300' . '&$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'); @@ -418,7 +653,7 @@ class BcODataGateway */ private function fetchContractsByCustomerField(string $customerNo, string $customerField, string $select): array { - $escaped = $this->escapeODataString($customerNo); + $escaped = $this->escapeODataTrustedLiteral($customerNo); $filter = $customerField . " eq '" . $escaped . "'"; $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTRACTS) . '?$filter=' . rawurlencode($filter) @@ -446,12 +681,12 @@ class BcODataGateway return []; } - $escaped = $this->escapeODataString($customerNo); + $escaped = $this->escapeODataTrustedLiteral($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' + . '&$top=300' . '&$select=' . rawurlencode($select) . '&$orderby=' . rawurlencode('Header_No asc,Line_No asc'); @@ -475,7 +710,7 @@ class BcODataGateway return null; } - $escaped = $this->escapeODataString($ticketNo); + $escaped = $this->escapeODataStrictUserInput($ticketNo); $filter = "No eq '" . $escaped . "'"; $url = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS) . '?$filter=' . rawurlencode($filter) @@ -509,7 +744,7 @@ class BcODataGateway return []; } - $escaped = $this->escapeODataString($ticketNo); + $escaped = $this->escapeODataStrictUserInput($ticketNo); $filter = "Support_Ticket_No eq '" . $escaped . "'"; // Fetch detailed log (user, time, Record_ID) @@ -529,21 +764,31 @@ class BcODataGateway return []; } - // Fetch status subtypes (human-readable state names) - $lvUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKET_LOG_LV) - . '?$filter=' . rawurlencode($filter) - . '&$orderby=' . rawurlencode('Entry_No desc') - . '&$top=100' - . '&$select=Entry_No,State_Subtype'; + // Only fetch status subtypes if detail entries contain status-type rows + $hasStatusEntries = false; + foreach ($entries as $entry) { + if (($entry['Type'] ?? '') === 'Status') { + $hasStatusEntries = true; + break; + } + } - $lvResponse = $this->request('GET', $lvUrl); $subtypeMap = []; - if ($lvResponse !== null) { - foreach ($this->extractODataValues($lvResponse) as $lv) { - $entryNo = $lv['Entry_No'] ?? null; - $subtype = (string) ($lv['State_Subtype'] ?? ''); - if ($entryNo !== null && $subtype !== '' && $subtype !== '0') { - $subtypeMap[$entryNo] = $subtype; + if ($hasStatusEntries) { + $lvUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKET_LOG_LV) + . '?$filter=' . rawurlencode($filter) + . '&$orderby=' . rawurlencode('Entry_No desc') + . '&$top=100' + . '&$select=Entry_No,State_Subtype'; + + $lvResponse = $this->request('GET', $lvUrl); + if ($lvResponse !== null) { + foreach ($this->extractODataValues($lvResponse) as $lv) { + $entryNo = $lv['Entry_No'] ?? null; + $subtype = (string) ($lv['State_Subtype'] ?? ''); + if ($entryNo !== null && $subtype !== '' && $subtype !== '0') { + $subtypeMap[$entryNo] = $subtype; + } } } } @@ -639,7 +884,7 @@ class BcODataGateway return ['error' => 'Not configured or empty customer number']; } - $escapedCustomerNo = $this->escapeODataString($customerNo); + $escapedCustomerNo = $this->escapeODataStrictUserInput($customerNo); $results = []; // 1. Customer entity @@ -656,7 +901,7 @@ class BcODataGateway // 2. Contact entity — filter by Company_Name $contactBaseUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_CONTACT); if ($customerName !== '') { - $escapedCustomerName = $this->escapeODataString((string) $customerName); + $escapedCustomerName = $this->escapeODataTrustedLiteral((string) $customerName); $contactFilteredUrl = $contactBaseUrl . '?$filter=' . rawurlencode("Company_Name eq '" . $escapedCustomerName . "'") . '&$top=10&$select=' . rawurlencode('No,Name,Type,Company_No,Company_Name,E_Mail,Phone_No,Job_Title'); @@ -668,7 +913,7 @@ class BcODataGateway // 3. Tickets entity (PBI_FP_Tickets) — filter by Company_Contact_Name $ticketsBaseUrl = $this->settingsGateway->buildEntityUrl(self::ENTITY_TICKETS); if ($customerName !== '') { - $escapedCustomerName = $this->escapeODataString((string) $customerName); + $escapedCustomerName = $this->escapeODataTrustedLiteral((string) $customerName); $ticketsFilteredUrl = $ticketsBaseUrl . '?$filter=' . rawurlencode("Company_Contact_Name eq '" . $escapedCustomerName . "'") . '&$top=10&$orderby=' . rawurlencode('Created_On desc') @@ -887,15 +1132,35 @@ class BcODataGateway } /** - * Sanitize and escape a string for use inside an OData $filter string literal. - * - * Rejects characters that could manipulate OData filter logic (parentheses, - * boolean operators). Only allows letters, digits, spaces, dots, hyphens, - * underscores, umlauts and common punctuation. + * @param array> $rows + * @return array> + */ + private function dedupeRowsByNo(array $rows): array + { + $uniqueRows = []; + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + + $no = trim((string) ($row['No'] ?? '')); + $rowKey = $no !== '' ? $no : md5((string) json_encode($row)); + if (isset($uniqueRows[$rowKey])) { + continue; + } + + $uniqueRows[$rowKey] = $row; + } + + return array_values($uniqueRows); + } + + /** + * Sanitize and escape a user-provided string for use in OData filters. * * @throws \InvalidArgumentException If the value contains disallowed characters. */ - private function escapeODataString(string $value): string + private function escapeODataStrictUserInput(string $value): string { if (preg_match('/^[\p{L}\p{N}\s.\-_@\/&,;:#+*]+$/u', $value) !== 1) { throw new \InvalidArgumentException('Search query contains invalid characters.'); @@ -904,6 +1169,14 @@ class BcODataGateway return str_replace("'", "''", $value); } + /** + * Escape trusted literals (e.g. BC-sourced names/codes) for OData filters. + */ + private function escapeODataTrustedLiteral(string $value): string + { + return str_replace("'", "''", trim($value)); + } + private function resolveCurrentTenantId(): int { $session = $this->sessionStore->all(); diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/BcSoapGateway.php b/modules/helpdesk/lib/Module/Helpdesk/Service/BcSoapGateway.php index 43050cf..c0a1939 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/BcSoapGateway.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/BcSoapGateway.php @@ -109,14 +109,14 @@ class BcSoapGateway } $parsed = $this->parseGetTicketCommunicationTextResponse($rawBody); - if (!($parsed['ok'] ?? false)) { + if (!$parsed['ok']) { return [ 'ok' => false, 'soap_used' => false, 'return_value' => 0, 'communication_text' => '', 'message_entries' => '', - 'error' => (string) ($parsed['error'] ?? 'Could not parse SOAP response'), + 'error' => isset($parsed['error']) ? (string) $parsed['error'] : 'Could not parse SOAP response', ]; } diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php b/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php index c50e96f..a99a316 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/DebitorDetailService.php @@ -56,7 +56,16 @@ class DebitorDetailService /** * Load contacts for a customer (for async data endpoint). * - * @return array{ok: bool, contacts?: array>, error?: string} + * @return array{ + * ok: bool, + * contacts?: array>, + * meta?: array{ + * source: string, + * fallback_used: bool, + * fallback_reason: string + * }, + * error?: string + * } */ public function loadContacts(string $customerNo, string $customerName): array { @@ -72,18 +81,38 @@ class DebitorDetailService } try { - $contacts = $this->bcODataGateway->getContactsForCustomer($customerNo, $customerName); + $result = $this->bcODataGateway->getContactsForCustomerWithMeta($customerNo, $customerName); } catch (\Throwable $e) { return ['ok' => false, 'error' => $e->getMessage()]; } - return ['ok' => true, 'contacts' => $contacts]; + $contacts = $result['contacts']; + $meta = $result['meta']; + + return [ + 'ok' => true, + 'contacts' => $contacts, + 'meta' => [ + 'source' => $meta['source'], + 'fallback_used' => $meta['fallback_used'], + 'fallback_reason' => $meta['fallback_reason'], + ], + ]; } /** * Load tickets for a customer (for async data endpoint). * - * @return array{ok: bool, tickets?: array>, error?: string} + * @return array{ + * ok: bool, + * tickets?: array>, + * meta?: array{ + * source: string, + * fallback_used: bool, + * fallback_reason: string + * }, + * error?: string + * } */ public function loadTickets(string $customerNo, string $customerName): array { @@ -99,12 +128,23 @@ class DebitorDetailService } try { - $tickets = $this->bcODataGateway->getTicketsForCustomer($customerNo, $customerName); + $result = $this->bcODataGateway->getTicketsForCustomerWithMeta($customerNo, $customerName); } catch (\Throwable $e) { return ['ok' => false, 'error' => $e->getMessage()]; } - return ['ok' => true, 'tickets' => $tickets]; + $tickets = $result['tickets']; + $meta = $result['meta']; + + return [ + 'ok' => true, + 'tickets' => $tickets, + 'meta' => [ + 'source' => $meta['source'], + 'fallback_used' => $meta['fallback_used'], + 'fallback_reason' => $meta['fallback_reason'], + ], + ]; } /** @@ -162,7 +202,7 @@ class DebitorDetailService $tickets = is_array($result['tickets'] ?? null) ? $result['tickets'] : []; $escalation = $this->loadEscalationHealth($customerNo, $tickets); - if (!($escalation['ok'] ?? false)) { + if (!$escalation['ok']) { $escalation = null; } @@ -277,7 +317,7 @@ class DebitorDetailService } $targetSeconds = $targetSecondsByCode[$escalationCode] ?? null; - if (!is_int($targetSeconds) || $targetSeconds <= 0) { + if (!is_int($targetSeconds)) { continue; } @@ -316,12 +356,12 @@ class DebitorDetailService } usort($entries, static function (array $a, array $b): int { - $cmp = ((int) ($b['overdue_seconds'] ?? 0)) <=> ((int) ($a['overdue_seconds'] ?? 0)); + $cmp = $b['overdue_seconds'] <=> $a['overdue_seconds']; if ($cmp !== 0) { return $cmp; } - return strcmp((string) ($a['ticket_no'] ?? ''), (string) ($b['ticket_no'] ?? '')); + return strcmp($a['ticket_no'], $b['ticket_no']); }); return [ @@ -1362,11 +1402,6 @@ class DebitorDetailService '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, @@ -1457,7 +1492,7 @@ class DebitorDetailService /** * @param array> $rules - * @return array + * @return array */ private static function evaluateControllingRisks( array $rules, @@ -1478,10 +1513,13 @@ class DebitorDetailService : ($createdInPeriod > 0 ? 100.0 : 0.0); $indicators[] = [ 'rule_id' => 'ticket_volume_increase', - 'label' => 'Ticket volume increase', - 'triggered' => $increase > $threshold, + 'label' => 'Ticket volume', + 'description' => 'risk_desc_ticket_volume', + 'triggered' => $increase >= $threshold, 'value' => ($increase > 0 ? '+' : '') . $increase . '%', - 'threshold' => $threshold . '%', + 'raw_value' => max(0, $increase), + 'threshold_value' => $threshold, + 'unit' => '%', ]; } @@ -1489,13 +1527,17 @@ class DebitorDetailService $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; + $triggered = $avgResolution !== null && $avgResolution >= $threshold; + $rawValue = $avgResolution !== null ? round($avgResolution, 1) : 0; $indicators[] = [ 'rule_id' => 'avg_resolution_high', - 'label' => 'High avg. resolution time', + 'label' => 'Resolution time', + 'description' => 'risk_desc_resolution_time', 'triggered' => $triggered, 'value' => $avgResolution !== null ? round($avgResolution, 1) . 'h' : '—', - 'threshold' => $threshold . 'h', + 'raw_value' => $rawValue, + 'threshold_value' => $threshold, + 'unit' => 'h', ]; } @@ -1505,10 +1547,13 @@ class DebitorDetailService $threshold = (int) ($rule['threshold_hours'] ?? 168); $indicators[] = [ 'rule_id' => 'open_aging', - 'label' => 'Open ticket aging', - 'triggered' => $oldestOpenAgeHours > $threshold, + 'label' => 'Oldest open ticket', + 'description' => 'risk_desc_open_aging', + 'triggered' => $oldestOpenAgeHours >= $threshold, 'value' => $oldestOpenAgeHours . 'h', - 'threshold' => $threshold . 'h', + 'raw_value' => $oldestOpenAgeHours, + 'threshold_value' => $threshold, + 'unit' => 'h', ]; } @@ -1518,10 +1563,13 @@ class DebitorDetailService $threshold = (int) ($rule['threshold_percent'] ?? 20); $indicators[] = [ 'rule_id' => 'unassigned_ratio', - 'label' => 'Unassigned ticket ratio', - 'triggered' => $unassignedRate > $threshold, + 'label' => 'Unassigned', + 'description' => 'risk_desc_unassigned', + 'triggered' => $unassignedRate >= $threshold, 'value' => $unassignedRate . '%', - 'threshold' => $threshold . '%', + 'raw_value' => $unassignedRate, + 'threshold_value' => $threshold, + 'unit' => '%', ]; } diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskSettingsGateway.php b/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskSettingsGateway.php index b0bc0e8..67931a3 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskSettingsGateway.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/HelpdeskSettingsGateway.php @@ -383,7 +383,7 @@ class HelpdeskSettingsGateway { $normalized = self::normalizeSystemRecommendationsConfig($config); $encoded = json_encode($normalized, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - if (!is_string($encoded) || $encoded === '') { + if (!is_string($encoded)) { return false; } @@ -617,7 +617,7 @@ class HelpdeskSettingsGateway { $normalized = self::normalizeControllingRiskConfig($config); $encoded = json_encode($normalized, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - if (!is_string($encoded) || $encoded === '') { + if (!is_string($encoded)) { return false; } diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/SystemRecommendationEngine.php b/modules/helpdesk/lib/Module/Helpdesk/Service/SystemRecommendationEngine.php index cac612d..4d241d6 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/SystemRecommendationEngine.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/SystemRecommendationEngine.php @@ -127,7 +127,7 @@ class SystemRecommendationEngine continue; } - $escalationCode = strtoupper(trim((string) ($match['escalation_code'] ?? ''))); + $escalationCode = strtoupper(trim($match['escalation_code'])); if ($escalationCode === '' || !isset($codeMap[$escalationCode])) { continue; } diff --git a/modules/helpdesk/pages/helpdesk/debitor(default).phtml b/modules/helpdesk/pages/helpdesk/debitor(default).phtml index fd83d36..b10f432 100644 --- a/modules/helpdesk/pages/helpdesk/debitor(default).phtml +++ b/modules/helpdesk/pages/helpdesk/debitor(default).phtml @@ -93,48 +93,93 @@ $searchConfig = is_array($searchConfig ?? null) ? $searchConfig : null;
-
-