feat(helpdesk): add dashboards, communication feed, settings UI and fix routing
Add support/sales/controlling dashboards with KPIs, trend charts and risk indicators. Add debitor communication timeline, contact filters, system recommendations engine, and configurable controlling risk rules. Rename search→index to fix query-string preservation on back navigation. Remove fake escalation rate metric, add KPI info tooltips, and switch trend chart colors to red (created) / green (closed) for clarity. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<int, array<string, mixed>>
|
||||
*/
|
||||
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<int, array<string, mixed>>
|
||||
*/
|
||||
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<int, array<string, mixed>>
|
||||
*/
|
||||
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<int, array<string, mixed>>
|
||||
*/
|
||||
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<int, array<string, mixed>>
|
||||
*/
|
||||
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<int, array<string, mixed>>
|
||||
*/
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Helpdesk\Service;
|
||||
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
|
||||
final class DebitorCacheControl
|
||||
{
|
||||
public const TTL_STANDARD_SECONDS = 300;
|
||||
public const TTL_ESCALATION_DEFINITIONS_SECONDS = 1800;
|
||||
|
||||
private const REFRESH_TRUE_VALUES = ['1', 'true', 'yes'];
|
||||
|
||||
private function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
*/
|
||||
public static function parseRefreshFlag(mixed $value): bool
|
||||
{
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$normalized = strtolower(trim((string) ($value ?? '')));
|
||||
if ($normalized === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($normalized, self::REFRESH_TRUE_VALUES, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $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<int, string>
|
||||
*/
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<int, string>, 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<int, string>, 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<string, mixed> $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<string, mixed> $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<int, string>, 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<int, string>
|
||||
*/
|
||||
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<string, array<string, mixed>>},
|
||||
* 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<string, array<string, mixed>>}
|
||||
*/
|
||||
public function getControllingRiskConfig(): array
|
||||
{
|
||||
$envelope = $this->getControllingRiskConfigEnvelope();
|
||||
|
||||
return $envelope['config'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $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<string, mixed> $config
|
||||
* @return array{version: int, rules: array<string, array<string, mixed>>}
|
||||
*/
|
||||
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']),
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\Helpdesk\Service;
|
||||
|
||||
/**
|
||||
* Rule-template based recommendation engine for support dashboard actions.
|
||||
*/
|
||||
class SystemRecommendationEngine
|
||||
{
|
||||
/** @var array<int, string> */
|
||||
private const CLOSED_TICKET_STATES = ['Erledigt', 'Resolved', 'Closed', 'Geschlossen'];
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $tickets
|
||||
* @param array<string, mixed>|null $escalationHealth
|
||||
* @param array<string, mixed> $config
|
||||
* @return array{
|
||||
* recommendations: array<int, array{
|
||||
* recommendation_id: string,
|
||||
* rule_id: string,
|
||||
* ticket_no: string,
|
||||
* message_key: string,
|
||||
* message_params: array<string, string|int|float|bool>,
|
||||
* priority: int,
|
||||
* urgency_score: int,
|
||||
* age_hours: int|null,
|
||||
* source: string
|
||||
* }>,
|
||||
* meta: array{
|
||||
* applied_rules: array<int, string>,
|
||||
* 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<int, array{
|
||||
* recommendation_id: string,
|
||||
* rule_id: string,
|
||||
* ticket_no: string,
|
||||
* message_key: string,
|
||||
* message_params: array<string, string|int|float|bool>,
|
||||
* priority: int,
|
||||
* urgency_score: int,
|
||||
* age_hours: int|null,
|
||||
* source: string
|
||||
* }> $candidates
|
||||
*/
|
||||
$candidates = [];
|
||||
/** @var array<string, true> $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<string, mixed> $rules
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function getRuleConfig(array $rules, string $ruleId): array
|
||||
{
|
||||
$rule = $rules[$ruleId] ?? [];
|
||||
if (!is_array($rule)) {
|
||||
$rule = [];
|
||||
}
|
||||
|
||||
return $rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $matches
|
||||
* @return array<string, array{escalation_code: string, age_hours: int|null}>
|
||||
*/
|
||||
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<int, string>|string $codesValue
|
||||
* @return array<int, string>
|
||||
*/
|
||||
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<string, array{ticket_no: string, support_user_name: string, age_hours: int|null}> $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<int, array{
|
||||
* recommendation_id: string,
|
||||
* rule_id: string,
|
||||
* ticket_no: string,
|
||||
* message_key: string,
|
||||
* message_params: array<string, string|int|float|bool>,
|
||||
* priority: int,
|
||||
* urgency_score: int,
|
||||
* age_hours: int|null,
|
||||
* source: string
|
||||
* }> $candidates
|
||||
* @return array<int, array{
|
||||
* recommendation_id: string,
|
||||
* rule_id: string,
|
||||
* ticket_no: string,
|
||||
* message_key: string,
|
||||
* message_params: array<string, string|int|float|bool>,
|
||||
* 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<int, array<string, mixed>> $tickets
|
||||
* @return array<string, array{ticket_no: string, support_user_name: string, age_hours: int|null}>
|
||||
*/
|
||||
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<string, mixed> $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<string, string|int|float|bool> $messageParams
|
||||
* @return array{
|
||||
* recommendation_id: string,
|
||||
* rule_id: string,
|
||||
* ticket_no: string,
|
||||
* message_key: string,
|
||||
* message_params: array<string, string|int|float|bool>,
|
||||
* 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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<string, string>
|
||||
*/
|
||||
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));
|
||||
|
||||
Reference in New Issue
Block a user